@cryptiklemur/lattice 1.1.0 → 1.3.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/.serena/project.yml +138 -0
- package/client/src/components/project-settings/ProjectClaude.tsx +14 -0
- package/client/src/components/project-settings/ProjectMemory.tsx +471 -0
- package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
- package/client/src/components/settings/ClaudeSettings.tsx +14 -0
- package/client/src/components/sidebar/AddProjectModal.tsx +433 -0
- package/client/src/components/sidebar/ProjectRail.tsx +8 -4
- package/client/src/components/sidebar/SettingsSidebar.tsx +2 -1
- package/client/src/hooks/useSidebar.ts +16 -0
- package/client/src/router.tsx +62 -0
- package/client/src/stores/sidebar.ts +28 -0
- package/client/src/styles/global.css +8 -0
- package/package.json +1 -1
- package/server/src/daemon.ts +1 -0
- package/server/src/handlers/fs.ts +159 -0
- package/server/src/handlers/memory.ts +179 -0
- package/server/src/handlers/settings.ts +6 -1
- package/shared/src/messages.ts +97 -2
- package/shared/src/project-settings.ts +1 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { X, FolderOpen, FileText, Loader2 } from "lucide-react";
|
|
3
|
+
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
4
|
+
import { useProjects } from "../../hooks/useProjects";
|
|
5
|
+
import type { ServerMessage } from "@lattice/shared";
|
|
6
|
+
|
|
7
|
+
interface BrowseEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
path: string;
|
|
10
|
+
hasClaudeMd: boolean;
|
|
11
|
+
projectName: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AddProjectModalProps {
|
|
15
|
+
isOpen: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AddProjectModal({ isOpen, onClose }: AddProjectModalProps) {
|
|
20
|
+
var { send, subscribe, unsubscribe } = useWebSocket();
|
|
21
|
+
var { projects } = useProjects();
|
|
22
|
+
|
|
23
|
+
var [path, setPath] = useState("");
|
|
24
|
+
var [entries, setEntries] = useState<BrowseEntry[]>([]);
|
|
25
|
+
var [title, setTitle] = useState("");
|
|
26
|
+
var [titleManuallySet, setTitleManuallySet] = useState(false);
|
|
27
|
+
var [dropdownOpen, setDropdownOpen] = useState(false);
|
|
28
|
+
var [highlightIndex, setHighlightIndex] = useState(-1);
|
|
29
|
+
var [error, setError] = useState<string | null>(null);
|
|
30
|
+
var [adding, setAdding] = useState(false);
|
|
31
|
+
var [suggestions, setSuggestions] = useState<Array<{ path: string; name: string; hasClaudeMd: boolean }>>([]);
|
|
32
|
+
var [homedir, setHomedir] = useState("");
|
|
33
|
+
var debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
var inputRef = useRef<HTMLInputElement>(null);
|
|
35
|
+
var dropdownRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
var inputFocusedRef = useRef(false);
|
|
37
|
+
var addingRef = useRef(false);
|
|
38
|
+
|
|
39
|
+
useEffect(function () {
|
|
40
|
+
if (!isOpen) return;
|
|
41
|
+
|
|
42
|
+
setPath("");
|
|
43
|
+
setEntries([]);
|
|
44
|
+
setTitle("");
|
|
45
|
+
setTitleManuallySet(false);
|
|
46
|
+
setDropdownOpen(false);
|
|
47
|
+
setError(null);
|
|
48
|
+
setAdding(false);
|
|
49
|
+
setSuggestions([]);
|
|
50
|
+
addingRef.current = false;
|
|
51
|
+
|
|
52
|
+
send({ type: "browse:list", path: "~" } as any);
|
|
53
|
+
send({ type: "browse:suggestions" } as any);
|
|
54
|
+
|
|
55
|
+
function handleBrowseResult(msg: ServerMessage) {
|
|
56
|
+
if (msg.type !== "browse:list_result") return;
|
|
57
|
+
var data = msg as { type: "browse:list_result"; path: string; homedir: string; entries: BrowseEntry[] };
|
|
58
|
+
setEntries(data.entries);
|
|
59
|
+
setHomedir(data.homedir);
|
|
60
|
+
setHighlightIndex(-1);
|
|
61
|
+
if (inputFocusedRef.current && data.entries.length > 0) {
|
|
62
|
+
setDropdownOpen(true);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleSuggestions(msg: ServerMessage) {
|
|
67
|
+
if (msg.type !== "browse:suggestions_result") return;
|
|
68
|
+
var data = msg as { type: "browse:suggestions_result"; suggestions: Array<{ path: string; name: string; hasClaudeMd: boolean }> };
|
|
69
|
+
setSuggestions(data.suggestions);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function handleProjectsList(msg: ServerMessage) {
|
|
73
|
+
if (msg.type !== "projects:list") return;
|
|
74
|
+
if (addingRef.current) {
|
|
75
|
+
addingRef.current = false;
|
|
76
|
+
setAdding(false);
|
|
77
|
+
onClose();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
subscribe("browse:list_result", handleBrowseResult);
|
|
82
|
+
subscribe("browse:suggestions_result", handleSuggestions);
|
|
83
|
+
subscribe("projects:list", handleProjectsList);
|
|
84
|
+
|
|
85
|
+
return function () {
|
|
86
|
+
unsubscribe("browse:list_result", handleBrowseResult);
|
|
87
|
+
unsubscribe("browse:suggestions_result", handleSuggestions);
|
|
88
|
+
unsubscribe("projects:list", handleProjectsList);
|
|
89
|
+
};
|
|
90
|
+
}, [isOpen]);
|
|
91
|
+
|
|
92
|
+
useEffect(function () {
|
|
93
|
+
if (!isOpen) return;
|
|
94
|
+
|
|
95
|
+
function handleMouseDown(e: MouseEvent) {
|
|
96
|
+
if (
|
|
97
|
+
dropdownRef.current &&
|
|
98
|
+
!dropdownRef.current.contains(e.target as Node) &&
|
|
99
|
+
inputRef.current &&
|
|
100
|
+
!inputRef.current.contains(e.target as Node)
|
|
101
|
+
) {
|
|
102
|
+
setDropdownOpen(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
107
|
+
return function () {
|
|
108
|
+
document.removeEventListener("mousedown", handleMouseDown);
|
|
109
|
+
};
|
|
110
|
+
}, [isOpen]);
|
|
111
|
+
|
|
112
|
+
useEffect(function () {
|
|
113
|
+
if (!isOpen) return;
|
|
114
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
115
|
+
|
|
116
|
+
var trimmed = path.trim();
|
|
117
|
+
if (!trimmed) {
|
|
118
|
+
send({ type: "browse:list", path: "~" } as any);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
debounceRef.current = setTimeout(function () {
|
|
123
|
+
var parentDir = trimmed;
|
|
124
|
+
if (!trimmed.endsWith("/")) {
|
|
125
|
+
var lastSlash = trimmed.lastIndexOf("/");
|
|
126
|
+
parentDir = lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : trimmed;
|
|
127
|
+
}
|
|
128
|
+
send({ type: "browse:list", path: parentDir } as any);
|
|
129
|
+
}, 200);
|
|
130
|
+
}, [path, isOpen]);
|
|
131
|
+
|
|
132
|
+
function resolvePath(p: string): string {
|
|
133
|
+
if (p.startsWith("~/") && homedir) return homedir + p.slice(1);
|
|
134
|
+
if (p === "~" && homedir) return homedir;
|
|
135
|
+
return p;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function handleSelectEntry(entry: BrowseEntry) {
|
|
139
|
+
var newPath = entry.path + "/";
|
|
140
|
+
setPath(newPath);
|
|
141
|
+
setDropdownOpen(false);
|
|
142
|
+
setError(null);
|
|
143
|
+
|
|
144
|
+
if (!titleManuallySet) {
|
|
145
|
+
if (entry.projectName) {
|
|
146
|
+
setTitle(entry.projectName);
|
|
147
|
+
} else {
|
|
148
|
+
setTitle(entry.name);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
send({ type: "browse:list", path: entry.path } as any);
|
|
153
|
+
|
|
154
|
+
if (inputRef.current) {
|
|
155
|
+
inputRef.current.focus();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleTitleChange(value: string) {
|
|
160
|
+
setTitle(value);
|
|
161
|
+
setTitleManuallySet(value.length > 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleInputFocus() {
|
|
165
|
+
inputFocusedRef.current = true;
|
|
166
|
+
if (getFilteredEntries().length > 0) {
|
|
167
|
+
setDropdownOpen(true);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleInputBlur() {
|
|
172
|
+
inputFocusedRef.current = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getFilteredEntries(): BrowseEntry[] {
|
|
176
|
+
var trimmed = path.trim();
|
|
177
|
+
if (!trimmed || trimmed.endsWith("/")) return entries;
|
|
178
|
+
|
|
179
|
+
var lastSlash = trimmed.lastIndexOf("/");
|
|
180
|
+
var filter = lastSlash >= 0 ? trimmed.slice(lastSlash + 1).toLowerCase() : trimmed.toLowerCase();
|
|
181
|
+
if (!filter) return entries;
|
|
182
|
+
|
|
183
|
+
return entries.filter(function (e) {
|
|
184
|
+
return e.name.toLowerCase().startsWith(filter);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isValidPath(): boolean {
|
|
189
|
+
var trimmed = path.trim();
|
|
190
|
+
if (!trimmed) return false;
|
|
191
|
+
var resolved = resolvePath(trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed);
|
|
192
|
+
var alreadyAdded = projects.some(function (p) { return p.path === resolved; });
|
|
193
|
+
if (alreadyAdded) return false;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getValidationMessage(): { type: "info" | "error" | "success"; text: string } | null {
|
|
198
|
+
var trimmed = path.trim();
|
|
199
|
+
if (!trimmed) return { type: "info", text: "Type a path to browse directories" };
|
|
200
|
+
|
|
201
|
+
var resolved = resolvePath(trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed);
|
|
202
|
+
var alreadyAdded = projects.some(function (p) { return p.path === resolved; });
|
|
203
|
+
if (alreadyAdded) return { type: "error", text: "Already added as a project" };
|
|
204
|
+
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
209
|
+
var filtered = getFilteredEntries();
|
|
210
|
+
if (!dropdownOpen || filtered.length === 0) {
|
|
211
|
+
if (e.key === "Enter" && canAdd) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
handleAdd();
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (e.key === "ArrowDown") {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
setHighlightIndex(function (prev) {
|
|
221
|
+
var next = prev + 1;
|
|
222
|
+
if (next >= filtered.length) next = 0;
|
|
223
|
+
scrollHighlightIntoView(next);
|
|
224
|
+
return next;
|
|
225
|
+
});
|
|
226
|
+
} else if (e.key === "ArrowUp") {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
setHighlightIndex(function (prev) {
|
|
229
|
+
var next = prev - 1;
|
|
230
|
+
if (next < 0) next = filtered.length - 1;
|
|
231
|
+
scrollHighlightIntoView(next);
|
|
232
|
+
return next;
|
|
233
|
+
});
|
|
234
|
+
} else if (e.key === "Tab" || e.key === "Enter") {
|
|
235
|
+
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
handleSelectEntry(filtered[highlightIndex]);
|
|
238
|
+
setHighlightIndex(-1);
|
|
239
|
+
} else if (e.key === "Enter" && canAdd) {
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
handleAdd();
|
|
242
|
+
}
|
|
243
|
+
} else if (e.key === "Escape") {
|
|
244
|
+
setDropdownOpen(false);
|
|
245
|
+
setHighlightIndex(-1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function scrollHighlightIntoView(index: number) {
|
|
250
|
+
if (!dropdownRef.current) return;
|
|
251
|
+
var items = dropdownRef.current.children;
|
|
252
|
+
if (items[index]) {
|
|
253
|
+
(items[index] as HTMLElement).scrollIntoView({ block: "nearest" });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
var canAdd = isValidPath() && !adding;
|
|
258
|
+
|
|
259
|
+
function handleAdd() {
|
|
260
|
+
var trimmed = path.trim();
|
|
261
|
+
if (!trimmed || adding) return;
|
|
262
|
+
|
|
263
|
+
var resolved = resolvePath(trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed);
|
|
264
|
+
var finalTitle = title.trim() || resolved.split("/").pop() || "Untitled";
|
|
265
|
+
|
|
266
|
+
setAdding(true);
|
|
267
|
+
addingRef.current = true;
|
|
268
|
+
send({
|
|
269
|
+
type: "settings:update",
|
|
270
|
+
settings: {
|
|
271
|
+
projects: [{ path: resolved, title: finalTitle }],
|
|
272
|
+
},
|
|
273
|
+
} as any);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!isOpen) return null;
|
|
277
|
+
|
|
278
|
+
var filtered = getFilteredEntries();
|
|
279
|
+
var validation = getValidationMessage();
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
|
283
|
+
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
284
|
+
<div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-3xl mx-4 overflow-hidden">
|
|
285
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
|
|
286
|
+
<h2 className="text-[15px] font-mono font-bold text-base-content">Add Project</h2>
|
|
287
|
+
<button
|
|
288
|
+
onClick={onClose}
|
|
289
|
+
aria-label="Close"
|
|
290
|
+
className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
|
|
291
|
+
>
|
|
292
|
+
<X size={16} />
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div className="px-5 py-4 space-y-4">
|
|
297
|
+
<div>
|
|
298
|
+
<label htmlFor="project-path" className="block text-[12px] font-semibold text-base-content/40 mb-1.5">
|
|
299
|
+
Project Path
|
|
300
|
+
</label>
|
|
301
|
+
<div className="relative">
|
|
302
|
+
<input
|
|
303
|
+
ref={inputRef}
|
|
304
|
+
id="project-path"
|
|
305
|
+
type="text"
|
|
306
|
+
value={path}
|
|
307
|
+
onChange={function (e) { setPath(e.target.value); setDropdownOpen(true); setHighlightIndex(-1); setError(null); }}
|
|
308
|
+
onFocus={handleInputFocus}
|
|
309
|
+
onBlur={handleInputBlur}
|
|
310
|
+
onKeyDown={handleKeyDown}
|
|
311
|
+
placeholder="~/projects/my-app"
|
|
312
|
+
autoFocus
|
|
313
|
+
className="w-full h-9 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content font-mono text-[12px] focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
|
|
314
|
+
/>
|
|
315
|
+
|
|
316
|
+
{dropdownOpen && filtered.length > 0 && (
|
|
317
|
+
<div
|
|
318
|
+
ref={dropdownRef}
|
|
319
|
+
className="absolute top-full left-0 right-0 mt-1 z-50 bg-base-200 border border-base-content/15 rounded-xl shadow-lg max-h-48 overflow-y-auto"
|
|
320
|
+
>
|
|
321
|
+
{filtered.map(function (entry, idx) {
|
|
322
|
+
var isHighlighted = idx === highlightIndex;
|
|
323
|
+
return (
|
|
324
|
+
<button
|
|
325
|
+
key={entry.path}
|
|
326
|
+
onMouseDown={function (e) { e.preventDefault(); }}
|
|
327
|
+
onClick={function () { handleSelectEntry(entry); setHighlightIndex(-1); }}
|
|
328
|
+
onMouseEnter={function () { setHighlightIndex(idx); }}
|
|
329
|
+
className={
|
|
330
|
+
"w-full flex items-center gap-2 px-3 py-2 text-left text-[12px] font-mono transition-colors duration-[80ms] " +
|
|
331
|
+
(isHighlighted
|
|
332
|
+
? "bg-base-content/10 text-base-content"
|
|
333
|
+
: "text-base-content/60 hover:bg-base-content/5 hover:text-base-content")
|
|
334
|
+
}
|
|
335
|
+
>
|
|
336
|
+
<FolderOpen size={12} className="text-base-content/25 flex-shrink-0" />
|
|
337
|
+
<span className="flex-1 truncate">{entry.name}/</span>
|
|
338
|
+
{entry.projectName && (
|
|
339
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-accent/15 text-accent/70 flex-shrink-0 truncate max-w-[100px]">
|
|
340
|
+
{entry.projectName}
|
|
341
|
+
</span>
|
|
342
|
+
)}
|
|
343
|
+
{entry.hasClaudeMd && (
|
|
344
|
+
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-md bg-primary/15 text-primary/70 flex-shrink-0">
|
|
345
|
+
<FileText size={8} />
|
|
346
|
+
CLAUDE.md
|
|
347
|
+
</span>
|
|
348
|
+
)}
|
|
349
|
+
</button>
|
|
350
|
+
);
|
|
351
|
+
})}
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
<div className="text-[10px] text-base-content/25 mt-1">Type a path or click directories to navigate</div>
|
|
356
|
+
|
|
357
|
+
{!path.trim() && suggestions.length > 0 && !dropdownOpen && (
|
|
358
|
+
<div className="mt-2">
|
|
359
|
+
<div className="text-[11px] font-semibold text-base-content/30 mb-1.5">Projects Claude has worked in</div>
|
|
360
|
+
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
|
361
|
+
{suggestions.map(function (s) {
|
|
362
|
+
return (
|
|
363
|
+
<button
|
|
364
|
+
key={s.path}
|
|
365
|
+
title={s.path}
|
|
366
|
+
onClick={function () {
|
|
367
|
+
setPath(s.path + "/");
|
|
368
|
+
if (!titleManuallySet) setTitle(s.name);
|
|
369
|
+
setSuggestions([]);
|
|
370
|
+
send({ type: "browse:list", path: s.path } as any);
|
|
371
|
+
}}
|
|
372
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] font-mono text-base-content/50 hover:bg-base-content/5 hover:text-base-content rounded-lg transition-colors duration-[80ms]"
|
|
373
|
+
>
|
|
374
|
+
<FolderOpen size={12} className="text-base-content/25 flex-shrink-0" />
|
|
375
|
+
<span className="flex-1 truncate">{s.path}</span>
|
|
376
|
+
{s.hasClaudeMd && (
|
|
377
|
+
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-md bg-primary/15 text-primary/70 flex-shrink-0">
|
|
378
|
+
<FileText size={8} />
|
|
379
|
+
CLAUDE.md
|
|
380
|
+
</span>
|
|
381
|
+
)}
|
|
382
|
+
</button>
|
|
383
|
+
);
|
|
384
|
+
})}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
<div>
|
|
391
|
+
<label htmlFor="project-title" className="block text-[12px] font-semibold text-base-content/40 mb-1.5">
|
|
392
|
+
Title <span className="font-normal opacity-60">(optional)</span>
|
|
393
|
+
</label>
|
|
394
|
+
<input
|
|
395
|
+
id="project-title"
|
|
396
|
+
type="text"
|
|
397
|
+
value={title}
|
|
398
|
+
onChange={function (e) { handleTitleChange(e.target.value); }}
|
|
399
|
+
placeholder="Auto-derived from path"
|
|
400
|
+
className="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]"
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
{validation && (
|
|
405
|
+
<div className={
|
|
406
|
+
"flex items-center gap-2 px-3 py-2 rounded-xl border text-[11px] " +
|
|
407
|
+
(validation.type === "error"
|
|
408
|
+
? "bg-error/10 border-error/20 text-error"
|
|
409
|
+
: validation.type === "success"
|
|
410
|
+
? "bg-success/10 border-success/20 text-success"
|
|
411
|
+
: "bg-base-content/5 border-base-content/10 text-base-content/40")
|
|
412
|
+
}>
|
|
413
|
+
{validation.text}
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<div className="px-5 py-3 border-t border-base-content/15 flex justify-end gap-2">
|
|
419
|
+
<button onClick={onClose} className="btn btn-ghost btn-sm text-[12px]">
|
|
420
|
+
Cancel
|
|
421
|
+
</button>
|
|
422
|
+
<button
|
|
423
|
+
onClick={handleAdd}
|
|
424
|
+
disabled={!canAdd}
|
|
425
|
+
className={"btn btn-primary btn-sm text-[12px]" + (!canAdd ? " opacity-50 cursor-not-allowed" : "")}
|
|
426
|
+
>
|
|
427
|
+
{adding ? <Loader2 size={14} className="animate-spin" /> : "Add Project"}
|
|
428
|
+
</button>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
@@ -230,10 +230,9 @@ export function ProjectRail(props: ProjectRailProps) {
|
|
|
230
230
|
)}
|
|
231
231
|
|
|
232
232
|
<button
|
|
233
|
-
onClick={function () {}}
|
|
234
|
-
className="w-[42px] h-[42px] flex items-center justify-center rounded-full border-2 border-dashed border-base-content/25 text-base-content/20 transition-colors duration-[120ms] flex-shrink-0 cursor-
|
|
235
|
-
title="Add project
|
|
236
|
-
disabled
|
|
233
|
+
onClick={function () { sidebar.openAddProject(); }}
|
|
234
|
+
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"
|
|
235
|
+
title="Add project"
|
|
237
236
|
>
|
|
238
237
|
<Plus size={18} />
|
|
239
238
|
</button>
|
|
@@ -279,13 +278,18 @@ export function ProjectRail(props: ProjectRailProps) {
|
|
|
279
278
|
role="menuitem"
|
|
280
279
|
className="w-full text-left px-3 py-1.5 text-sm text-error hover:bg-error/10 transition-colors"
|
|
281
280
|
onClick={function () {
|
|
281
|
+
var slug = contextMenu.slug;
|
|
282
282
|
setContextMenu(function (prev) { return { ...prev, visible: false }; });
|
|
283
|
+
if (slug) {
|
|
284
|
+
sidebar.openConfirmRemove(slug);
|
|
285
|
+
}
|
|
283
286
|
}}
|
|
284
287
|
>
|
|
285
288
|
Remove Project
|
|
286
289
|
</button>
|
|
287
290
|
</div>
|
|
288
291
|
)}
|
|
292
|
+
|
|
289
293
|
</div>
|
|
290
294
|
);
|
|
291
295
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArrowLeft, Palette, FileText, Terminal, Plug, Puzzle, Network, Settings, ScrollText, Shield } from "lucide-react";
|
|
1
|
+
import { ArrowLeft, Palette, FileText, Terminal, Plug, Puzzle, Network, Settings, ScrollText, Shield, Brain } from "lucide-react";
|
|
2
2
|
import { useSidebar } from "../../hooks/useSidebar";
|
|
3
3
|
import type { SettingsSection, ProjectSettingsSection } from "../../stores/sidebar";
|
|
4
4
|
|
|
@@ -52,6 +52,7 @@ var PROJECT_SETTINGS_NAV = [
|
|
|
52
52
|
items: [
|
|
53
53
|
{ id: "rules" as ProjectSettingsSection, label: "Rules", icon: <ScrollText size={14} /> },
|
|
54
54
|
{ id: "permissions" as ProjectSettingsSection, label: "Permissions", icon: <Shield size={14} /> },
|
|
55
|
+
{ id: "memory" as ProjectSettingsSection, label: "Memory", icon: <Brain size={14} /> },
|
|
55
56
|
],
|
|
56
57
|
},
|
|
57
58
|
];
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
openNodeSettings,
|
|
19
19
|
closeNodeSettings,
|
|
20
20
|
navigateToSession,
|
|
21
|
+
openAddProject,
|
|
22
|
+
closeAddProject,
|
|
23
|
+
openConfirmRemove,
|
|
24
|
+
closeConfirmRemove,
|
|
21
25
|
} from "../stores/sidebar";
|
|
22
26
|
import type { SidebarState, SettingsSection, ProjectSettingsSection } from "../stores/sidebar";
|
|
23
27
|
|
|
@@ -39,6 +43,12 @@ export function useSidebar(): SidebarState & {
|
|
|
39
43
|
openNodeSettings: () => void;
|
|
40
44
|
closeNodeSettings: () => void;
|
|
41
45
|
navigateToSession: (projectSlug: string, sessionId: string) => void;
|
|
46
|
+
openAddProject: () => void;
|
|
47
|
+
closeAddProject: () => void;
|
|
48
|
+
addProjectOpen: boolean;
|
|
49
|
+
openConfirmRemove: (slug: string) => void;
|
|
50
|
+
closeConfirmRemove: () => void;
|
|
51
|
+
confirmRemoveSlug: string | null;
|
|
42
52
|
} {
|
|
43
53
|
var store = getSidebarStore();
|
|
44
54
|
var state = useStore(store, function (s) { return s; });
|
|
@@ -70,5 +80,11 @@ export function useSidebar(): SidebarState & {
|
|
|
70
80
|
openNodeSettings: openNodeSettings,
|
|
71
81
|
closeNodeSettings: closeNodeSettings,
|
|
72
82
|
nodeSettingsOpen: state.nodeSettingsOpen,
|
|
83
|
+
openAddProject: openAddProject,
|
|
84
|
+
closeAddProject: closeAddProject,
|
|
85
|
+
addProjectOpen: state.addProjectOpen,
|
|
86
|
+
openConfirmRemove: openConfirmRemove,
|
|
87
|
+
closeConfirmRemove: closeConfirmRemove,
|
|
88
|
+
confirmRemoveSlug: state.confirmRemoveSlug,
|
|
73
89
|
};
|
|
74
90
|
}
|
package/client/src/router.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { ProjectSettingsView } from "./components/project-settings/ProjectSettin
|
|
|
9
9
|
import { DashboardView } from "./components/dashboard/DashboardView";
|
|
10
10
|
import { ProjectDashboardView } from "./components/dashboard/ProjectDashboardView";
|
|
11
11
|
import { NodeSettingsModal } from "./components/sidebar/NodeSettingsModal";
|
|
12
|
+
import { AddProjectModal } from "./components/sidebar/AddProjectModal";
|
|
12
13
|
import { useSidebar } from "./hooks/useSidebar";
|
|
13
14
|
import { useWebSocket } from "./hooks/useWebSocket";
|
|
14
15
|
import { exitSettings, getSidebarStore, handlePopState, closeDrawer } from "./stores/sidebar";
|
|
@@ -251,6 +252,62 @@ function LoadingScreen() {
|
|
|
251
252
|
);
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
function RemoveProjectConfirm() {
|
|
256
|
+
var sidebar = useSidebar();
|
|
257
|
+
var ws = useWebSocket();
|
|
258
|
+
var slug = sidebar.confirmRemoveSlug;
|
|
259
|
+
|
|
260
|
+
if (!slug) return null;
|
|
261
|
+
|
|
262
|
+
var projects = (function () {
|
|
263
|
+
try {
|
|
264
|
+
var store = getSidebarStore();
|
|
265
|
+
return store.state;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
})();
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
|
273
|
+
<div className="absolute inset-0 bg-black/50" onClick={sidebar.closeConfirmRemove} />
|
|
274
|
+
<div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden">
|
|
275
|
+
<div className="px-5 py-4 border-b border-base-content/15">
|
|
276
|
+
<h2 className="text-[15px] font-mono font-bold text-base-content">Remove Project</h2>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="px-5 py-4">
|
|
279
|
+
<p className="text-[13px] text-base-content/60">
|
|
280
|
+
Remove <span className="font-semibold text-base-content">{slug}</span> from Lattice? This won't delete any files on disk.
|
|
281
|
+
</p>
|
|
282
|
+
</div>
|
|
283
|
+
<div className="px-5 py-3 border-t border-base-content/15 flex justify-end gap-2">
|
|
284
|
+
<button
|
|
285
|
+
onClick={sidebar.closeConfirmRemove}
|
|
286
|
+
className="btn btn-ghost btn-sm text-[12px]"
|
|
287
|
+
>
|
|
288
|
+
Cancel
|
|
289
|
+
</button>
|
|
290
|
+
<button
|
|
291
|
+
onClick={function () {
|
|
292
|
+
ws.send({
|
|
293
|
+
type: "settings:update",
|
|
294
|
+
settings: { removeProject: slug },
|
|
295
|
+
} as any);
|
|
296
|
+
if (sidebar.activeProjectSlug === slug) {
|
|
297
|
+
sidebar.goToDashboard();
|
|
298
|
+
}
|
|
299
|
+
sidebar.closeConfirmRemove();
|
|
300
|
+
}}
|
|
301
|
+
className="btn btn-error btn-sm text-[12px]"
|
|
302
|
+
>
|
|
303
|
+
Remove
|
|
304
|
+
</button>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
254
311
|
function RootLayout() {
|
|
255
312
|
var [setupComplete, setSetupComplete] = useState(function () {
|
|
256
313
|
return localStorage.getItem("lattice-setup-complete") === "1";
|
|
@@ -313,6 +370,11 @@ function RootLayout() {
|
|
|
313
370
|
isOpen={sidebar.nodeSettingsOpen}
|
|
314
371
|
onClose={sidebar.closeNodeSettings}
|
|
315
372
|
/>
|
|
373
|
+
<AddProjectModal
|
|
374
|
+
isOpen={sidebar.addProjectOpen}
|
|
375
|
+
onClose={sidebar.closeAddProject}
|
|
376
|
+
/>
|
|
377
|
+
<RemoveProjectConfirm />
|
|
316
378
|
</div>
|
|
317
379
|
);
|
|
318
380
|
}
|
|
@@ -26,6 +26,8 @@ export interface SidebarState {
|
|
|
26
26
|
projectDropdownOpen: boolean;
|
|
27
27
|
drawerOpen: boolean;
|
|
28
28
|
nodeSettingsOpen: boolean;
|
|
29
|
+
addProjectOpen: boolean;
|
|
30
|
+
confirmRemoveSlug: string | null;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
var SETTINGS_SECTIONS: SettingsSection[] = ["appearance", "claude", "environment", "mcp", "skills", "nodes"];
|
|
@@ -70,6 +72,8 @@ var sidebarStore = new Store<SidebarState>({
|
|
|
70
72
|
projectDropdownOpen: false,
|
|
71
73
|
drawerOpen: false,
|
|
72
74
|
nodeSettingsOpen: false,
|
|
75
|
+
addProjectOpen: false,
|
|
76
|
+
confirmRemoveSlug: null,
|
|
73
77
|
});
|
|
74
78
|
|
|
75
79
|
function pushUrl(projectSlug: string | null, sessionId: string | null): void {
|
|
@@ -334,3 +338,27 @@ export function closeNodeSettings(): void {
|
|
|
334
338
|
return { ...state, nodeSettingsOpen: false };
|
|
335
339
|
});
|
|
336
340
|
}
|
|
341
|
+
|
|
342
|
+
export function openAddProject(): void {
|
|
343
|
+
sidebarStore.setState(function (state) {
|
|
344
|
+
return { ...state, addProjectOpen: true };
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function closeAddProject(): void {
|
|
349
|
+
sidebarStore.setState(function (state) {
|
|
350
|
+
return { ...state, addProjectOpen: false };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function openConfirmRemove(slug: string): void {
|
|
355
|
+
sidebarStore.setState(function (state) {
|
|
356
|
+
return { ...state, confirmRemoveSlug: slug };
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function closeConfirmRemove(): void {
|
|
361
|
+
sidebarStore.setState(function (state) {
|
|
362
|
+
return { ...state, confirmRemoveSlug: null };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@plugin "daisyui";
|
|
3
3
|
@plugin "@tailwindcss/typography";
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
button:not(:disabled),
|
|
7
|
+
a[href],
|
|
8
|
+
[role="button"]:not([aria-disabled="true"]) {
|
|
9
|
+
cursor: pointer;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
4
12
|
@plugin "daisyui/theme" {
|
|
5
13
|
name: "lattice-dark";
|
|
6
14
|
default: true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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/src/daemon.ts
CHANGED
|
@@ -24,6 +24,7 @@ import "./handlers/loop";
|
|
|
24
24
|
import "./handlers/scheduler";
|
|
25
25
|
import "./handlers/notes";
|
|
26
26
|
import "./handlers/skills";
|
|
27
|
+
import "./handlers/memory";
|
|
27
28
|
import { startScheduler } from "./features/scheduler";
|
|
28
29
|
import { loadNotes } from "./features/sticky-notes";
|
|
29
30
|
import { cleanupClientTerminals } from "./handlers/terminal";
|