@cryptiklemur/lattice 1.14.2 → 1.16.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 +121 -0
- package/bun.lock +14 -1
- package/client/src/App.tsx +2 -0
- package/client/src/components/analytics/ChartCard.tsx +6 -10
- package/client/src/components/analytics/QuickStats.tsx +3 -3
- package/client/src/components/analytics/charts/NodeFleetOverview.tsx +1 -1
- package/client/src/components/chat/ChatView.tsx +119 -7
- package/client/src/components/chat/Message.tsx +41 -6
- package/client/src/components/chat/PromptQuestion.tsx +4 -4
- package/client/src/components/chat/TodoCard.tsx +2 -2
- package/client/src/components/chat/ToolResultRenderer.tsx +6 -2
- package/client/src/components/dashboard/DashboardView.tsx +2 -0
- package/client/src/components/mesh/PairingDialog.tsx +6 -17
- package/client/src/components/project-settings/ProjectEnvironment.tsx +1 -1
- package/client/src/components/project-settings/ProjectMemory.tsx +10 -19
- package/client/src/components/project-settings/ProjectRules.tsx +3 -3
- package/client/src/components/project-settings/ProjectSkills.tsx +2 -2
- package/client/src/components/settings/BudgetSettings.tsx +161 -0
- package/client/src/components/settings/Environment.tsx +1 -1
- package/client/src/components/settings/SettingsView.tsx +3 -0
- 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/sidebar/SessionList.tsx +33 -12
- package/client/src/components/sidebar/SettingsSidebar.tsx +2 -1
- package/client/src/components/sidebar/Sidebar.tsx +152 -2
- package/client/src/components/sidebar/UserIsland.tsx +76 -37
- package/client/src/components/ui/IconPicker.tsx +9 -36
- package/client/src/components/ui/KeyboardShortcuts.tsx +129 -0
- package/client/src/components/ui/Toast.tsx +22 -2
- package/client/src/components/workspace/BookmarksView.tsx +156 -0
- package/client/src/components/workspace/TabBar.tsx +34 -5
- package/client/src/components/workspace/TaskEditModal.tsx +7 -2
- package/client/src/components/workspace/WorkspaceView.tsx +29 -6
- package/client/src/hooks/useBookmarks.ts +57 -0
- package/client/src/hooks/useFocusTrap.ts +72 -0
- package/client/src/hooks/useProjects.ts +1 -1
- package/client/src/hooks/useSession.ts +38 -1
- package/client/src/hooks/useTimeTick.ts +35 -0
- package/client/src/hooks/useVoiceRecorder.ts +17 -3
- package/client/src/hooks/useWorkspace.ts +10 -1
- package/client/src/router.tsx +6 -11
- package/client/src/stores/bookmarks.ts +45 -0
- package/client/src/stores/session.ts +24 -0
- package/client/src/stores/sidebar.ts +2 -2
- package/client/src/stores/workspace.ts +114 -3
- package/client/src/vite-env.d.ts +6 -0
- package/client/tsconfig.json +4 -0
- package/package.json +2 -1
- package/playwright.config.ts +19 -0
- package/server/package.json +2 -0
- package/server/src/analytics/engine.ts +43 -9
- package/server/src/daemon.ts +11 -7
- package/server/src/handlers/bookmarks.ts +50 -0
- package/server/src/handlers/chat.ts +64 -0
- package/server/src/handlers/fs.ts +1 -1
- package/server/src/handlers/memory.ts +1 -1
- package/server/src/handlers/mesh.ts +1 -1
- package/server/src/handlers/project-settings.ts +2 -2
- package/server/src/handlers/session.ts +12 -11
- package/server/src/handlers/settings.ts +5 -2
- package/server/src/handlers/skills.ts +1 -1
- package/server/src/logger.ts +12 -0
- package/server/src/mesh/connector.ts +7 -6
- package/server/src/project/bookmarks.ts +83 -0
- package/server/src/project/context-breakdown.ts +1 -1
- package/server/src/project/registry.ts +5 -5
- package/server/src/project/sdk-bridge.ts +77 -6
- package/server/src/project/session.ts +6 -5
- package/server/src/ws/router.ts +5 -4
- package/server/tsconfig.json +4 -0
- package/shared/src/messages.ts +53 -2
- package/shared/src/models.ts +17 -1
- package/shared/src/project-settings.ts +0 -1
- package/shared/tsconfig.json +4 -0
- package/tests/accessibility.spec.ts +77 -0
- package/tests/keyboard-shortcuts.spec.ts +74 -0
- package/tests/message-actions.spec.ts +112 -0
- package/tests/onboarding.spec.ts +72 -0
- package/tests/session-flow.spec.ts +117 -0
- package/tests/session-preview.spec.ts +83 -0
|
@@ -3,6 +3,7 @@ import { useMesh } from "../../hooks/useMesh";
|
|
|
3
3
|
import { useProjects } from "../../hooks/useProjects";
|
|
4
4
|
import { useSidebar } from "../../hooks/useSidebar";
|
|
5
5
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
6
|
+
import { useTimeTick } from "../../hooks/useTimeTick";
|
|
6
7
|
import { LatticeLogomark } from "../ui/LatticeLogomark";
|
|
7
8
|
import { QuickStats } from "../analytics/QuickStats";
|
|
8
9
|
import {
|
|
@@ -25,6 +26,7 @@ function relativeTime(ts: number): string {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function DashboardView() {
|
|
29
|
+
useTimeTick();
|
|
28
30
|
var { nodes } = useMesh();
|
|
29
31
|
var { projects } = useProjects();
|
|
30
32
|
var sidebar = useSidebar();
|
|
@@ -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"
|
|
@@ -127,7 +127,7 @@ export function ProjectEnvironment({
|
|
|
127
127
|
</div>
|
|
128
128
|
<div className="flex gap-1.5 items-center">
|
|
129
129
|
<div className="h-9 sm:h-7 px-3 bg-base-300/50 border border-base-content/10 rounded-xl flex items-center font-mono text-[12px] text-base-content/40 w-full">
|
|
130
|
-
{v}
|
|
130
|
+
{String(v)}
|
|
131
131
|
</div>
|
|
132
132
|
</div>
|
|
133
133
|
<span className="text-[10px] uppercase tracking-wider text-base-content/30 w-7 text-center hidden sm:block">
|
|
@@ -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">
|
|
@@ -19,7 +19,7 @@ export function ProjectRules({
|
|
|
19
19
|
var globalRules = settings.global.rules ?? [];
|
|
20
20
|
|
|
21
21
|
var [rules, setRules] = useState<RuleEntry[]>(function () {
|
|
22
|
-
return (settings.rules ?? []).map(function (r) {
|
|
22
|
+
return (settings.rules ?? []).map(function (r: { filename: string; content: string }) {
|
|
23
23
|
return { filename: r.filename, content: r.content };
|
|
24
24
|
});
|
|
25
25
|
});
|
|
@@ -34,7 +34,7 @@ export function ProjectRules({
|
|
|
34
34
|
if (save.saving) {
|
|
35
35
|
save.confirmSave();
|
|
36
36
|
} else {
|
|
37
|
-
setRules((settings.rules ?? []).map(function (r) {
|
|
37
|
+
setRules((settings.rules ?? []).map(function (r: { filename: string; content: string }) {
|
|
38
38
|
return { filename: r.filename, content: r.content };
|
|
39
39
|
}));
|
|
40
40
|
save.resetFromServer();
|
|
@@ -136,7 +136,7 @@ export function ProjectRules({
|
|
|
136
136
|
)}
|
|
137
137
|
{globalRules.length > 0 && (
|
|
138
138
|
<div className="flex flex-col gap-1.5">
|
|
139
|
-
{globalRules.map(function (rule, idx) {
|
|
139
|
+
{globalRules.map(function (rule: typeof globalRules[number], idx: number) {
|
|
140
140
|
var isExpanded = expandedGlobal.has(idx);
|
|
141
141
|
return (
|
|
142
142
|
<div key={rule.filename + "-" + idx} className="border border-base-content/10 rounded-xl overflow-hidden">
|
|
@@ -46,7 +46,7 @@ export function ProjectSkills({ settings, projectSlug }: ProjectSkillsProps) {
|
|
|
46
46
|
<div>
|
|
47
47
|
<div className="text-[12px] font-semibold text-base-content/40 mb-2">Global Skills</div>
|
|
48
48
|
<div className="space-y-2">
|
|
49
|
-
{globalSkills.map(function (skill) {
|
|
49
|
+
{globalSkills.map(function (skill: typeof globalSkills[number]) {
|
|
50
50
|
return (
|
|
51
51
|
<SkillItem
|
|
52
52
|
key={skill.path}
|
|
@@ -64,7 +64,7 @@ export function ProjectSkills({ settings, projectSlug }: ProjectSkillsProps) {
|
|
|
64
64
|
<div>
|
|
65
65
|
<div className="text-[12px] font-semibold text-base-content/40 mb-2">Project Skills</div>
|
|
66
66
|
<div className="space-y-2">
|
|
67
|
-
{projectSkills.map(function (skill) {
|
|
67
|
+
{projectSkills.map(function (skill: typeof projectSkills[number]) {
|
|
68
68
|
return (
|
|
69
69
|
<SkillItem
|
|
70
70
|
key={skill.path}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { useStore } from "@tanstack/react-store";
|
|
3
|
+
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
4
|
+
import { useSaveState } from "../../hooks/useSaveState";
|
|
5
|
+
import { SaveFooter } from "../ui/SaveFooter";
|
|
6
|
+
import { getSessionStore } from "../../stores/session";
|
|
7
|
+
import type { ServerMessage, SettingsDataMessage, SettingsUpdateMessage } from "@lattice/shared";
|
|
8
|
+
|
|
9
|
+
var ENFORCEMENT_OPTIONS = [
|
|
10
|
+
{ id: "warning", label: "Warning", description: "Shows a warning but does not block" },
|
|
11
|
+
{ id: "soft-block", label: "Confirm", description: "Asks for confirmation before sending" },
|
|
12
|
+
{ id: "hard-block", label: "Block", description: "Prevents sending until tomorrow" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function BudgetSettings() {
|
|
16
|
+
var { send, subscribe, unsubscribe } = useWebSocket();
|
|
17
|
+
var budgetStatus = useStore(getSessionStore(), function (s) { return s.budgetStatus; });
|
|
18
|
+
var [enabled, setEnabled] = useState(false);
|
|
19
|
+
var [dailyLimit, setDailyLimit] = useState(10);
|
|
20
|
+
var [enforcement, setEnforcement] = useState("warning");
|
|
21
|
+
var save = useSaveState();
|
|
22
|
+
|
|
23
|
+
useEffect(function () {
|
|
24
|
+
function handleMessage(msg: ServerMessage) {
|
|
25
|
+
if (msg.type !== "settings:data") return;
|
|
26
|
+
var data = msg as SettingsDataMessage;
|
|
27
|
+
var cfg = data.config;
|
|
28
|
+
|
|
29
|
+
if (save.saving) {
|
|
30
|
+
save.confirmSave();
|
|
31
|
+
} else {
|
|
32
|
+
if (cfg.costBudget) {
|
|
33
|
+
setEnabled(true);
|
|
34
|
+
setDailyLimit(cfg.costBudget.dailyLimit);
|
|
35
|
+
setEnforcement(cfg.costBudget.enforcement);
|
|
36
|
+
} else {
|
|
37
|
+
setEnabled(false);
|
|
38
|
+
setDailyLimit(10);
|
|
39
|
+
setEnforcement("warning");
|
|
40
|
+
}
|
|
41
|
+
save.resetFromServer();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
subscribe("settings:data", handleMessage);
|
|
46
|
+
send({ type: "settings:get" });
|
|
47
|
+
|
|
48
|
+
return function () {
|
|
49
|
+
unsubscribe("settings:data", handleMessage);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
function handleSave() {
|
|
54
|
+
save.startSave();
|
|
55
|
+
var updateMsg: SettingsUpdateMessage = {
|
|
56
|
+
type: "settings:update",
|
|
57
|
+
settings: {
|
|
58
|
+
costBudget: enabled ? { dailyLimit: dailyLimit, enforcement: enforcement as "warning" | "soft-block" | "hard-block" } : undefined,
|
|
59
|
+
} as SettingsUpdateMessage["settings"],
|
|
60
|
+
};
|
|
61
|
+
send(updateMsg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="py-2">
|
|
66
|
+
<p className="text-[12px] text-base-content/40 mb-5">
|
|
67
|
+
Set a daily spending limit to control API costs. The budget resets at midnight.
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
{budgetStatus && (
|
|
71
|
+
<div className="mb-5 p-3 rounded-xl bg-base-300 border border-base-content/10">
|
|
72
|
+
<div className="text-[11px] text-base-content/40 font-mono mb-1">Today's spend</div>
|
|
73
|
+
<div className="text-[18px] font-mono font-bold text-base-content">
|
|
74
|
+
${budgetStatus.dailySpend.toFixed(2)}
|
|
75
|
+
{budgetStatus.dailyLimit > 0 && (
|
|
76
|
+
<span className="text-[13px] text-base-content/30 font-normal">
|
|
77
|
+
{" / $" + budgetStatus.dailyLimit.toFixed(2)}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<div className="mb-5">
|
|
85
|
+
<label className="flex items-center gap-3 cursor-pointer">
|
|
86
|
+
<input
|
|
87
|
+
type="checkbox"
|
|
88
|
+
checked={enabled}
|
|
89
|
+
onChange={function (e) {
|
|
90
|
+
setEnabled(e.target.checked);
|
|
91
|
+
save.markDirty();
|
|
92
|
+
}}
|
|
93
|
+
className="toggle toggle-sm toggle-primary"
|
|
94
|
+
/>
|
|
95
|
+
<span className="text-[13px] text-base-content">Enable daily budget</span>
|
|
96
|
+
</label>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{enabled && (
|
|
100
|
+
<>
|
|
101
|
+
<div className="mb-5">
|
|
102
|
+
<label htmlFor="budget-daily-limit" className="block text-[12px] font-semibold text-base-content/40 mb-2">
|
|
103
|
+
Daily limit (USD)
|
|
104
|
+
</label>
|
|
105
|
+
<input
|
|
106
|
+
id="budget-daily-limit"
|
|
107
|
+
type="number"
|
|
108
|
+
min={0.01}
|
|
109
|
+
step={0.5}
|
|
110
|
+
value={dailyLimit}
|
|
111
|
+
onChange={function (e) {
|
|
112
|
+
var val = parseFloat(e.target.value);
|
|
113
|
+
if (!isNaN(val) && val > 0) {
|
|
114
|
+
setDailyLimit(val);
|
|
115
|
+
save.markDirty();
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
className="w-full h-9 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] font-mono focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="mb-6" role="radiogroup" aria-label="Enforcement mode">
|
|
123
|
+
<div className="text-[12px] font-semibold text-base-content/40 mb-2">Enforcement</div>
|
|
124
|
+
<div className="flex flex-col gap-2">
|
|
125
|
+
{ENFORCEMENT_OPTIONS.map(function (opt) {
|
|
126
|
+
var active = enforcement === opt.id;
|
|
127
|
+
return (
|
|
128
|
+
<button
|
|
129
|
+
key={opt.id}
|
|
130
|
+
role="radio"
|
|
131
|
+
aria-checked={active}
|
|
132
|
+
onClick={function () {
|
|
133
|
+
setEnforcement(opt.id);
|
|
134
|
+
save.markDirty();
|
|
135
|
+
}}
|
|
136
|
+
className={
|
|
137
|
+
"w-full text-left px-3 py-2.5 rounded-lg border text-[12px] transition-colors duration-[120ms] cursor-pointer focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-base-100 " +
|
|
138
|
+
(active
|
|
139
|
+
? "border-primary bg-base-300 text-base-content"
|
|
140
|
+
: "border-base-content/15 bg-base-300 text-base-content/40 hover:border-base-content/30 hover:text-base-content/60")
|
|
141
|
+
}
|
|
142
|
+
>
|
|
143
|
+
<div className="font-semibold">{opt.label}</div>
|
|
144
|
+
<div className={"text-[11px] mt-0.5 " + (active ? "text-base-content/50" : "text-base-content/30")}>{opt.description}</div>
|
|
145
|
+
</button>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
<SaveFooter
|
|
154
|
+
dirty={save.dirty}
|
|
155
|
+
saving={save.saving}
|
|
156
|
+
saveState={save.saveState}
|
|
157
|
+
onSave={handleSave}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -10,12 +10,14 @@ import { Editor } from "./Editor";
|
|
|
10
10
|
import { GlobalRules } from "./GlobalRules";
|
|
11
11
|
import { GlobalMemory } from "./GlobalMemory";
|
|
12
12
|
import { Notifications } from "./Notifications";
|
|
13
|
+
import { BudgetSettings } from "./BudgetSettings";
|
|
13
14
|
import type { SettingsSection } from "../../stores/sidebar";
|
|
14
15
|
|
|
15
16
|
var SECTION_CONFIG: Record<string, { title: string }> = {
|
|
16
17
|
appearance: { title: "Appearance" },
|
|
17
18
|
notifications: { title: "Notifications" },
|
|
18
19
|
claude: { title: "Claude Settings" },
|
|
20
|
+
budget: { title: "Budget" },
|
|
19
21
|
environment: { title: "Environment" },
|
|
20
22
|
mcp: { title: "MCP Servers" },
|
|
21
23
|
skills: { title: "Skills" },
|
|
@@ -29,6 +31,7 @@ function renderSection(section: SettingsSection) {
|
|
|
29
31
|
if (section === "appearance") return <Appearance />;
|
|
30
32
|
if (section === "notifications") return <Notifications />;
|
|
31
33
|
if (section === "claude") return <ClaudeSettings />;
|
|
34
|
+
if (section === "budget") return <BudgetSettings />;
|
|
32
35
|
if (section === "environment") return <Environment />;
|
|
33
36
|
if (section === "mcp") return <GlobalMcp />;
|
|
34
37
|
if (section === "skills") return <GlobalSkills />;
|
|
@@ -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
|
|
|
@@ -4,6 +4,7 @@ import { Clock, MessageSquare, Cpu, DollarSign } from "lucide-react";
|
|
|
4
4
|
import type { SessionSummary, SessionPreview, SessionListMessage, SessionCreatedMessage, SessionPreviewMessage } from "@lattice/shared";
|
|
5
5
|
import type { ServerMessage } from "@lattice/shared";
|
|
6
6
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
7
|
+
import { useTimeTick } from "../../hooks/useTimeTick";
|
|
7
8
|
import { markSessionHasUpdates, sessionHasUpdates, markSessionRead } from "../../stores/session";
|
|
8
9
|
import { formatSessionTitle } from "../../utils/formatSessionTitle";
|
|
9
10
|
|
|
@@ -65,12 +66,18 @@ interface ContextMenu {
|
|
|
65
66
|
session: SessionSummary;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
export interface DateRange {
|
|
70
|
+
from?: number;
|
|
71
|
+
to?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
interface SessionListProps {
|
|
69
75
|
projectSlug: string | null;
|
|
70
76
|
activeSessionId: string | null;
|
|
71
77
|
onSessionActivate: (session: SessionSummary) => void;
|
|
72
78
|
onSessionDeactivate?: () => void;
|
|
73
79
|
filter?: string;
|
|
80
|
+
dateRange?: DateRange;
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
function formatDate(ts: number): string {
|
|
@@ -169,6 +176,7 @@ function PreviewPopover(props: { preview: SessionPreview | null; anchorRect: DOM
|
|
|
169
176
|
}
|
|
170
177
|
|
|
171
178
|
export function SessionList(props: SessionListProps) {
|
|
179
|
+
useTimeTick();
|
|
172
180
|
var ws = useWebSocket();
|
|
173
181
|
var [sessions, setSessions] = useState<SessionSummary[]>([]);
|
|
174
182
|
var [loading, setLoading] = useState<boolean>(false);
|
|
@@ -196,14 +204,14 @@ export function SessionList(props: SessionListProps) {
|
|
|
196
204
|
if (msg.type === "session:list") {
|
|
197
205
|
var listMsg = msg as SessionListMessage;
|
|
198
206
|
if (listMsg.projectSlug === props.projectSlug) {
|
|
199
|
-
var incoming = listMsg.sessions.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; });
|
|
207
|
+
var incoming = listMsg.sessions.slice().sort(function (a: typeof listMsg.sessions[number], b: typeof listMsg.sessions[number]) { return b.updatedAt - a.updatedAt; });
|
|
200
208
|
var listOffset = listMsg.offset || 0;
|
|
201
209
|
var listTotal = listMsg.totalCount || incoming.length;
|
|
202
210
|
|
|
203
211
|
if (listOffset > 0) {
|
|
204
212
|
setSessions(function (prev) {
|
|
205
|
-
var existingIds = new Set(prev.map(function (s) { return s.id; }));
|
|
206
|
-
var newSessions = incoming.filter(function (s) { return !existingIds.has(s.id); });
|
|
213
|
+
var existingIds = new Set(prev.map(function (s: typeof prev[number]) { return s.id; }));
|
|
214
|
+
var newSessions = incoming.filter(function (s: typeof incoming[number]) { return !existingIds.has(s.id); });
|
|
207
215
|
return prev.concat(newSessions);
|
|
208
216
|
});
|
|
209
217
|
setLoadingMore(false);
|
|
@@ -220,9 +228,9 @@ export function SessionList(props: SessionListProps) {
|
|
|
220
228
|
}
|
|
221
229
|
setSessions(function (existing) {
|
|
222
230
|
if (existing.length <= PAGE_SIZE) return incoming;
|
|
223
|
-
var incomingIds = new Set(incoming.map(function (s) { return s.id; }));
|
|
224
|
-
var kept = existing.filter(function (s) { return !incomingIds.has(s.id); });
|
|
225
|
-
return incoming.concat(kept).sort(function (a, b) { return b.updatedAt - a.updatedAt; });
|
|
231
|
+
var incomingIds = new Set(incoming.map(function (s: typeof incoming[number]) { return s.id; }));
|
|
232
|
+
var kept = existing.filter(function (s: typeof existing[number]) { return !incomingIds.has(s.id); });
|
|
233
|
+
return incoming.concat(kept).sort(function (a: typeof incoming[number], b: typeof incoming[number]) { return b.updatedAt - a.updatedAt; });
|
|
226
234
|
});
|
|
227
235
|
setLoading(false);
|
|
228
236
|
if (hadChanges) {
|
|
@@ -422,13 +430,26 @@ export function SessionList(props: SessionListProps) {
|
|
|
422
430
|
}
|
|
423
431
|
|
|
424
432
|
var grouped = useMemo(function () {
|
|
425
|
-
var displayed =
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
433
|
+
var displayed = sessions;
|
|
434
|
+
if (props.filter) {
|
|
435
|
+
var term = props.filter.toLowerCase();
|
|
436
|
+
displayed = displayed.filter(function (s) {
|
|
437
|
+
return s.title.toLowerCase().includes(term);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (props.dateRange) {
|
|
441
|
+
var from = props.dateRange.from;
|
|
442
|
+
var to = props.dateRange.to;
|
|
443
|
+
if (from !== undefined || to !== undefined) {
|
|
444
|
+
displayed = displayed.filter(function (s) {
|
|
445
|
+
if (from !== undefined && s.updatedAt < from) return false;
|
|
446
|
+
if (to !== undefined && s.updatedAt > to) return false;
|
|
447
|
+
return true;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
430
451
|
return groupByTime(displayed);
|
|
431
|
-
}, [sessions, props.filter]);
|
|
452
|
+
}, [sessions, props.filter, props.dateRange]);
|
|
432
453
|
|
|
433
454
|
if (!props.projectSlug) {
|
|
434
455
|
return (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArrowLeft, Palette, FileText, Terminal, Plug, Puzzle, Network, Settings, ScrollText, Shield, Brain, MonitorCog, Bell } from "lucide-react";
|
|
1
|
+
import { ArrowLeft, Palette, FileText, Terminal, Plug, Puzzle, Network, Settings, ScrollText, Shield, Brain, MonitorCog, Bell, Wallet } from "lucide-react";
|
|
2
2
|
import { useSidebar } from "../../hooks/useSidebar";
|
|
3
3
|
import type { SettingsSection, ProjectSettingsSection } from "../../stores/sidebar";
|
|
4
4
|
|
|
@@ -14,6 +14,7 @@ var SETTINGS_NAV = [
|
|
|
14
14
|
{ id: "appearance" as SettingsSection, label: "Appearance", icon: <Palette size={14} /> },
|
|
15
15
|
{ id: "notifications" as SettingsSection, label: "Notifications", icon: <Bell size={14} /> },
|
|
16
16
|
{ id: "claude" as SettingsSection, label: "Claude Settings", icon: <FileText size={14} /> },
|
|
17
|
+
{ id: "budget" as SettingsSection, label: "Budget", icon: <Wallet size={14} /> },
|
|
17
18
|
{ id: "environment" as SettingsSection, label: "Environment", icon: <Terminal size={14} /> },
|
|
18
19
|
{ id: "editor" as SettingsSection, label: "Editor", icon: <MonitorCog size={14} /> },
|
|
19
20
|
],
|