@cryptiklemur/lattice 1.15.0 → 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 +51 -2
- package/bun.lock +9 -0
- 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 +114 -6
- 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/dashboard/DashboardView.tsx +2 -0
- package/client/src/components/project-settings/ProjectEnvironment.tsx +1 -1
- 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/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/workspace/BookmarksView.tsx +156 -0
- package/client/src/components/workspace/TabBar.tsx +34 -5
- package/client/src/components/workspace/WorkspaceView.tsx +29 -6
- package/client/src/hooks/useBookmarks.ts +57 -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/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/src/analytics/engine.ts +43 -9
- package/server/src/daemon.ts +3 -0
- 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 +2 -2
- package/server/src/handlers/settings.ts +5 -2
- package/server/src/handlers/skills.ts +1 -1
- 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 +15 -3
- package/server/src/project/session.ts +1 -1
- package/server/tsconfig.json +4 -0
- package/shared/src/messages.ts +53 -2
- package/shared/src/models.ts +14 -0
- 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
|
@@ -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 />;
|
|
@@ -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
|
],
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from "react";
|
|
2
|
-
import { Plus, ChevronDown, Search, LayoutDashboard, FolderOpen, TerminalSquare, StickyNote, Calendar, BarChart3 } from "lucide-react";
|
|
2
|
+
import { Plus, ChevronDown, Search, LayoutDashboard, FolderOpen, TerminalSquare, StickyNote, Calendar, BarChart3, Bookmark } from "lucide-react";
|
|
3
3
|
import { LatticeLogomark } from "../ui/LatticeLogomark";
|
|
4
4
|
import type { SessionSummary, ServerMessage, SettingsDataMessage } from "@lattice/shared";
|
|
5
|
+
import type { DateRange } from "./SessionList";
|
|
5
6
|
import { useProjects } from "../../hooks/useProjects";
|
|
6
7
|
import { useMesh } from "../../hooks/useMesh";
|
|
7
8
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
@@ -9,7 +10,7 @@ import { useSidebar } from "../../hooks/useSidebar";
|
|
|
9
10
|
import { useSession } from "../../hooks/useSession";
|
|
10
11
|
import { clearSession } from "../../stores/session";
|
|
11
12
|
import { useOnline } from "../../hooks/useOnline";
|
|
12
|
-
import { openTab } from "../../stores/workspace";
|
|
13
|
+
import { openTab, openSessionTab } from "../../stores/workspace";
|
|
13
14
|
import { getSidebarStore, goToAnalytics } from "../../stores/sidebar";
|
|
14
15
|
import { ProjectRail } from "./ProjectRail";
|
|
15
16
|
import { SessionList } from "./SessionList";
|
|
@@ -19,6 +20,146 @@ import { SearchFilter } from "./SearchFilter";
|
|
|
19
20
|
import { ProjectDropdown } from "./ProjectDropdown";
|
|
20
21
|
import { SettingsSidebar } from "./SettingsSidebar";
|
|
21
22
|
|
|
23
|
+
type DatePreset = "all" | "today" | "yesterday" | "week" | "month" | "custom";
|
|
24
|
+
|
|
25
|
+
var DATE_PRESET_LABELS: Record<DatePreset, string> = {
|
|
26
|
+
all: "All time",
|
|
27
|
+
today: "Today",
|
|
28
|
+
yesterday: "Yesterday",
|
|
29
|
+
week: "This week",
|
|
30
|
+
month: "This month",
|
|
31
|
+
custom: "Custom",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function computeDateRange(preset: DatePreset, customFrom?: string, customTo?: string): DateRange {
|
|
35
|
+
if (preset === "all") return {};
|
|
36
|
+
var now = new Date();
|
|
37
|
+
var todayStart = new Date(now);
|
|
38
|
+
todayStart.setHours(0, 0, 0, 0);
|
|
39
|
+
|
|
40
|
+
if (preset === "today") {
|
|
41
|
+
return { from: todayStart.getTime() };
|
|
42
|
+
}
|
|
43
|
+
if (preset === "yesterday") {
|
|
44
|
+
var yesterdayStart = new Date(todayStart);
|
|
45
|
+
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
|
|
46
|
+
return { from: yesterdayStart.getTime(), to: todayStart.getTime() };
|
|
47
|
+
}
|
|
48
|
+
if (preset === "week") {
|
|
49
|
+
var weekStart = new Date(todayStart);
|
|
50
|
+
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
|
51
|
+
return { from: weekStart.getTime() };
|
|
52
|
+
}
|
|
53
|
+
if (preset === "month") {
|
|
54
|
+
var monthStart = new Date(todayStart.getFullYear(), todayStart.getMonth(), 1);
|
|
55
|
+
return { from: monthStart.getTime() };
|
|
56
|
+
}
|
|
57
|
+
if (preset === "custom") {
|
|
58
|
+
var range: DateRange = {};
|
|
59
|
+
if (customFrom) {
|
|
60
|
+
range.from = new Date(customFrom + "T00:00:00").getTime();
|
|
61
|
+
}
|
|
62
|
+
if (customTo) {
|
|
63
|
+
var toDate = new Date(customTo + "T23:59:59");
|
|
64
|
+
range.to = toDate.getTime() + 999;
|
|
65
|
+
}
|
|
66
|
+
return range;
|
|
67
|
+
}
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function DateRangeDropdown({ dateRange, onChange }: { dateRange: DateRange; onChange: (r: DateRange, preset: DatePreset) => void }) {
|
|
72
|
+
var [open, setOpen] = useState(false);
|
|
73
|
+
var [preset, setPreset] = useState<DatePreset>("all");
|
|
74
|
+
var [customFrom, setCustomFrom] = useState("");
|
|
75
|
+
var [customTo, setCustomTo] = useState("");
|
|
76
|
+
var dropdownRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
|
|
78
|
+
useEffect(function () {
|
|
79
|
+
if (!open) return;
|
|
80
|
+
function dismiss(e: MouseEvent) {
|
|
81
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
82
|
+
setOpen(false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
document.addEventListener("mousedown", dismiss);
|
|
86
|
+
return function () { document.removeEventListener("mousedown", dismiss); };
|
|
87
|
+
}, [open]);
|
|
88
|
+
|
|
89
|
+
function handlePreset(p: DatePreset) {
|
|
90
|
+
setPreset(p);
|
|
91
|
+
if (p !== "custom") {
|
|
92
|
+
onChange(computeDateRange(p), p);
|
|
93
|
+
setOpen(false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function handleCustomApply() {
|
|
98
|
+
onChange(computeDateRange("custom", customFrom, customTo), "custom");
|
|
99
|
+
setOpen(false);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var hasFilter = preset !== "all";
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div ref={dropdownRef} className="relative">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={function () { setOpen(function (v) { return !v; }); }}
|
|
109
|
+
className={"btn btn-ghost btn-xs btn-square " + (hasFilter ? "text-primary" : "text-base-content/40 hover:text-base-content")}
|
|
110
|
+
aria-label="Filter by date"
|
|
111
|
+
title={DATE_PRESET_LABELS[preset]}
|
|
112
|
+
>
|
|
113
|
+
<Calendar size={13} />
|
|
114
|
+
</button>
|
|
115
|
+
{open && (
|
|
116
|
+
<div className="absolute top-full right-0 mt-1 z-[9999] bg-base-300 border border-base-content/15 rounded-lg shadow-xl min-w-[180px] py-1">
|
|
117
|
+
{(["all", "today", "yesterday", "week", "month", "custom"] as DatePreset[]).map(function (p) {
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
key={p}
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={function () { handlePreset(p); }}
|
|
123
|
+
className={
|
|
124
|
+
"block w-full text-left px-3 py-1.5 text-[12px] transition-colors duration-75 " +
|
|
125
|
+
(preset === p ? "text-primary bg-primary/10" : "text-base-content/70 hover:bg-base-content/5 hover:text-base-content")
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
{DATE_PRESET_LABELS[p]}
|
|
129
|
+
</button>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
{preset === "custom" && (
|
|
133
|
+
<div className="px-3 py-2 border-t border-base-content/10 flex flex-col gap-1.5">
|
|
134
|
+
<label className="text-[10px] text-base-content/40 uppercase tracking-wider">From</label>
|
|
135
|
+
<input
|
|
136
|
+
type="date"
|
|
137
|
+
value={customFrom}
|
|
138
|
+
onChange={function (e) { setCustomFrom(e.target.value); }}
|
|
139
|
+
className="h-7 px-2 bg-base-200 border border-base-content/15 rounded text-base-content text-[12px] focus:border-primary focus-visible:outline-none transition-colors [color-scheme:dark]"
|
|
140
|
+
/>
|
|
141
|
+
<label className="text-[10px] text-base-content/40 uppercase tracking-wider">To</label>
|
|
142
|
+
<input
|
|
143
|
+
type="date"
|
|
144
|
+
value={customTo}
|
|
145
|
+
onChange={function (e) { setCustomTo(e.target.value); }}
|
|
146
|
+
className="h-7 px-2 bg-base-200 border border-base-content/15 rounded text-base-content text-[12px] focus:border-primary focus-visible:outline-none transition-colors [color-scheme:dark]"
|
|
147
|
+
/>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={handleCustomApply}
|
|
151
|
+
className="btn btn-xs btn-primary mt-1"
|
|
152
|
+
>
|
|
153
|
+
Apply
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
22
163
|
function SectionLabel({ label, actions }: { label: string; actions?: React.ReactNode }) {
|
|
23
164
|
return (
|
|
24
165
|
<div className="px-4 pt-4 pb-2 flex items-center justify-between flex-shrink-0 select-none">
|
|
@@ -43,6 +184,7 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
|
|
|
43
184
|
var session = useSession();
|
|
44
185
|
var [sessionSearch, setSessionSearch] = useState<string>("");
|
|
45
186
|
var [sessionSearchOpen, setSessionSearchOpen] = useState<boolean>(false);
|
|
187
|
+
var [sessionDateRange, setSessionDateRange] = useState<DateRange>({});
|
|
46
188
|
var userIslandRef = useRef<HTMLElement | null>(null);
|
|
47
189
|
var projectHeaderRef = useRef<HTMLElement | null>(null);
|
|
48
190
|
|
|
@@ -72,6 +214,7 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
|
|
|
72
214
|
if (!sidebar.activeProjectSlug || !sidebar.activeSessionId) return;
|
|
73
215
|
if (!activeProject) return;
|
|
74
216
|
initialActivatedRef.current = true;
|
|
217
|
+
openSessionTab(sidebar.activeSessionId, sidebar.activeProjectSlug, "Session");
|
|
75
218
|
session.activateSession(sidebar.activeProjectSlug, sidebar.activeSessionId);
|
|
76
219
|
}, [sidebar.activeProjectSlug, sidebar.activeSessionId, activeProject]);
|
|
77
220
|
|
|
@@ -79,6 +222,7 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
|
|
|
79
222
|
|
|
80
223
|
function handleSessionActivate(s: SessionSummary) {
|
|
81
224
|
if (activeProject) {
|
|
225
|
+
openSessionTab(s.id, activeProject.slug, s.title);
|
|
82
226
|
session.activateSession(activeProject.slug, s.id);
|
|
83
227
|
}
|
|
84
228
|
sidebar.closeMenus();
|
|
@@ -154,6 +298,7 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
|
|
|
154
298
|
{ type: "terminal" as const, icon: TerminalSquare, label: "Terminal" },
|
|
155
299
|
{ type: "notes" as const, icon: StickyNote, label: "Notes" },
|
|
156
300
|
{ type: "tasks" as const, icon: Calendar, label: "Tasks" },
|
|
301
|
+
{ type: "bookmarks" as const, icon: Bookmark, label: "Bookmarks" },
|
|
157
302
|
].map(function (item) {
|
|
158
303
|
return (
|
|
159
304
|
<button
|
|
@@ -192,6 +337,10 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
|
|
|
192
337
|
<button onClick={function () { setSessionSearchOpen(function (v) { return !v; }); }} className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content" aria-label="Search sessions">
|
|
193
338
|
<Search size={13} />
|
|
194
339
|
</button>
|
|
340
|
+
<DateRangeDropdown
|
|
341
|
+
dateRange={sessionDateRange}
|
|
342
|
+
onChange={function (r) { setSessionDateRange(r); }}
|
|
343
|
+
/>
|
|
195
344
|
<button onClick={handleNewSession} disabled={!online} className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content" aria-label="New session">
|
|
196
345
|
<Plus size={13} />
|
|
197
346
|
</button>
|
|
@@ -212,6 +361,7 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
|
|
|
212
361
|
onSessionActivate={handleSessionActivate}
|
|
213
362
|
onSessionDeactivate={clearSession}
|
|
214
363
|
filter={sessionSearch}
|
|
364
|
+
dateRange={sessionDateRange}
|
|
215
365
|
/>
|
|
216
366
|
</>
|
|
217
367
|
)}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { Sun, Moon, Settings, Download } from "lucide-react";
|
|
3
|
+
import { useStore } from "@tanstack/react-store";
|
|
2
4
|
import { useTheme } from "../../hooks/useTheme";
|
|
3
5
|
import { useSidebar } from "../../hooks/useSidebar";
|
|
4
6
|
import { useInstallPrompt } from "../../hooks/useInstallPrompt";
|
|
7
|
+
import { getSessionStore } from "../../stores/session";
|
|
5
8
|
import pkg from "../../../package.json";
|
|
6
9
|
|
|
7
10
|
interface UserIslandProps {
|
|
@@ -13,57 +16,93 @@ export function UserIsland(props: UserIslandProps) {
|
|
|
13
16
|
var { mode, toggleMode } = useTheme();
|
|
14
17
|
var sidebar = useSidebar();
|
|
15
18
|
var { canInstall, install } = useInstallPrompt();
|
|
19
|
+
var budgetStatus = useStore(getSessionStore(), function (s) { return s.budgetStatus; });
|
|
20
|
+
var [showTooltip, setShowTooltip] = useState(false);
|
|
16
21
|
|
|
17
22
|
var initial = props.nodeName.charAt(0).toUpperCase();
|
|
18
23
|
|
|
24
|
+
var budgetBar = null;
|
|
25
|
+
if (budgetStatus && budgetStatus.dailyLimit > 0) {
|
|
26
|
+
var pct = Math.min((budgetStatus.dailySpend / budgetStatus.dailyLimit) * 100, 100);
|
|
27
|
+
var remaining = Math.max(budgetStatus.dailyLimit - budgetStatus.dailySpend, 0);
|
|
28
|
+
var barColor = pct >= 100
|
|
29
|
+
? "bg-error"
|
|
30
|
+
: pct >= 80
|
|
31
|
+
? "bg-warning"
|
|
32
|
+
: "bg-primary";
|
|
33
|
+
|
|
34
|
+
budgetBar = (
|
|
35
|
+
<div
|
|
36
|
+
className="px-3 pt-2 pb-0 relative"
|
|
37
|
+
onMouseEnter={function () { setShowTooltip(true); }}
|
|
38
|
+
onMouseLeave={function () { setShowTooltip(false); }}
|
|
39
|
+
>
|
|
40
|
+
<div className="h-1.5 rounded-full bg-base-content/8 overflow-hidden">
|
|
41
|
+
<div
|
|
42
|
+
className={"h-full rounded-full transition-all duration-300 " + barColor}
|
|
43
|
+
style={{ width: pct + "%" }}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
{showTooltip && (
|
|
47
|
+
<div className="absolute bottom-full left-3 right-3 mb-1.5 px-2.5 py-1.5 bg-base-100 border border-base-content/10 rounded-lg shadow-lg z-50 text-[11px] font-mono text-base-content/70 whitespace-nowrap">
|
|
48
|
+
<div>Daily spend: ${budgetStatus.dailySpend.toFixed(2)} / ${budgetStatus.dailyLimit.toFixed(2)}</div>
|
|
49
|
+
<div className="text-base-content/40">Remaining: ${remaining.toFixed(2)}</div>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
return (
|
|
20
57
|
<div
|
|
21
58
|
role="group"
|
|
22
59
|
aria-label="User controls"
|
|
23
|
-
className="flex items-center gap-2 px-3 py-2"
|
|
24
60
|
>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<div className="text-[13px] font-semibold text-base-content truncate">
|
|
35
|
-
{props.nodeName}
|
|
61
|
+
{budgetBar}
|
|
62
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
63
|
+
<button
|
|
64
|
+
onClick={props.onClick}
|
|
65
|
+
className="flex items-center gap-2 flex-1 min-w-0 rounded-lg px-1 py-1 -mx-1 hover:bg-base-content/5 transition-colors duration-[120ms] cursor-pointer"
|
|
66
|
+
aria-label="Node info"
|
|
67
|
+
>
|
|
68
|
+
<div className="w-7 h-7 rounded-full bg-primary text-primary-content text-[12px] font-bold flex items-center justify-center flex-shrink-0">
|
|
69
|
+
{initial}
|
|
36
70
|
</div>
|
|
37
|
-
<div className="
|
|
38
|
-
|
|
71
|
+
<div className="flex-1 min-w-0 text-left">
|
|
72
|
+
<div className="text-[13px] font-semibold text-base-content truncate">
|
|
73
|
+
{props.nodeName}
|
|
74
|
+
</div>
|
|
75
|
+
<div className="text-[10px] text-base-content/30 font-mono">
|
|
76
|
+
{"v" + pkg.version}
|
|
77
|
+
</div>
|
|
39
78
|
</div>
|
|
40
|
-
</
|
|
41
|
-
</button>
|
|
79
|
+
</button>
|
|
42
80
|
|
|
43
|
-
|
|
44
|
-
|
|
81
|
+
<div className="flex items-center gap-0.5 flex-shrink-0">
|
|
82
|
+
{canInstall && (
|
|
83
|
+
<button
|
|
84
|
+
aria-label="Install Lattice"
|
|
85
|
+
onClick={install}
|
|
86
|
+
className="btn btn-ghost btn-xs btn-square text-primary/60 hover:text-primary transition-colors"
|
|
87
|
+
>
|
|
88
|
+
<Download size={14} />
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
45
91
|
<button
|
|
46
|
-
aria-label="
|
|
47
|
-
onClick={
|
|
48
|
-
className="btn btn-ghost btn-xs btn-square text-
|
|
92
|
+
aria-label={mode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
|
93
|
+
onClick={function (e) { e.stopPropagation(); toggleMode(); }}
|
|
94
|
+
className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-base-content transition-colors"
|
|
49
95
|
>
|
|
50
|
-
<
|
|
96
|
+
{mode === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
|
51
97
|
</button>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</
|
|
60
|
-
<button
|
|
61
|
-
aria-label="Global settings"
|
|
62
|
-
onClick={function () { sidebar.openSettings("appearance"); }}
|
|
63
|
-
className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-base-content transition-colors"
|
|
64
|
-
>
|
|
65
|
-
<Settings size={14} />
|
|
66
|
-
</button>
|
|
98
|
+
<button
|
|
99
|
+
aria-label="Global settings"
|
|
100
|
+
onClick={function () { sidebar.openSettings("appearance"); }}
|
|
101
|
+
className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-base-content transition-colors"
|
|
102
|
+
>
|
|
103
|
+
<Settings size={14} />
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
67
106
|
</div>
|
|
68
107
|
</div>
|
|
69
108
|
);
|