@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.
Files changed (64) hide show
  1. package/.github/workflows/ci.yml +51 -2
  2. package/bun.lock +9 -0
  3. package/client/src/components/analytics/QuickStats.tsx +3 -3
  4. package/client/src/components/analytics/charts/NodeFleetOverview.tsx +1 -1
  5. package/client/src/components/chat/ChatView.tsx +114 -6
  6. package/client/src/components/chat/Message.tsx +41 -6
  7. package/client/src/components/chat/PromptQuestion.tsx +4 -4
  8. package/client/src/components/chat/TodoCard.tsx +2 -2
  9. package/client/src/components/dashboard/DashboardView.tsx +2 -0
  10. package/client/src/components/project-settings/ProjectEnvironment.tsx +1 -1
  11. package/client/src/components/project-settings/ProjectRules.tsx +3 -3
  12. package/client/src/components/project-settings/ProjectSkills.tsx +2 -2
  13. package/client/src/components/settings/BudgetSettings.tsx +161 -0
  14. package/client/src/components/settings/Environment.tsx +1 -1
  15. package/client/src/components/settings/SettingsView.tsx +3 -0
  16. package/client/src/components/sidebar/SessionList.tsx +33 -12
  17. package/client/src/components/sidebar/SettingsSidebar.tsx +2 -1
  18. package/client/src/components/sidebar/Sidebar.tsx +152 -2
  19. package/client/src/components/sidebar/UserIsland.tsx +76 -37
  20. package/client/src/components/ui/IconPicker.tsx +9 -36
  21. package/client/src/components/workspace/BookmarksView.tsx +156 -0
  22. package/client/src/components/workspace/TabBar.tsx +34 -5
  23. package/client/src/components/workspace/WorkspaceView.tsx +29 -6
  24. package/client/src/hooks/useBookmarks.ts +57 -0
  25. package/client/src/hooks/useProjects.ts +1 -1
  26. package/client/src/hooks/useSession.ts +38 -1
  27. package/client/src/hooks/useTimeTick.ts +35 -0
  28. package/client/src/hooks/useVoiceRecorder.ts +17 -3
  29. package/client/src/hooks/useWorkspace.ts +10 -1
  30. package/client/src/stores/bookmarks.ts +45 -0
  31. package/client/src/stores/session.ts +24 -0
  32. package/client/src/stores/sidebar.ts +2 -2
  33. package/client/src/stores/workspace.ts +114 -3
  34. package/client/src/vite-env.d.ts +6 -0
  35. package/client/tsconfig.json +4 -0
  36. package/package.json +2 -1
  37. package/playwright.config.ts +19 -0
  38. package/server/src/analytics/engine.ts +43 -9
  39. package/server/src/daemon.ts +3 -0
  40. package/server/src/handlers/bookmarks.ts +50 -0
  41. package/server/src/handlers/chat.ts +64 -0
  42. package/server/src/handlers/fs.ts +1 -1
  43. package/server/src/handlers/memory.ts +1 -1
  44. package/server/src/handlers/mesh.ts +1 -1
  45. package/server/src/handlers/project-settings.ts +2 -2
  46. package/server/src/handlers/session.ts +2 -2
  47. package/server/src/handlers/settings.ts +5 -2
  48. package/server/src/handlers/skills.ts +1 -1
  49. package/server/src/project/bookmarks.ts +83 -0
  50. package/server/src/project/context-breakdown.ts +1 -1
  51. package/server/src/project/registry.ts +5 -5
  52. package/server/src/project/sdk-bridge.ts +15 -3
  53. package/server/src/project/session.ts +1 -1
  54. package/server/tsconfig.json +4 -0
  55. package/shared/src/messages.ts +53 -2
  56. package/shared/src/models.ts +14 -0
  57. package/shared/src/project-settings.ts +0 -1
  58. package/shared/tsconfig.json +4 -0
  59. package/tests/accessibility.spec.ts +77 -0
  60. package/tests/keyboard-shortcuts.spec.ts +74 -0
  61. package/tests/message-actions.spec.ts +112 -0
  62. package/tests/onboarding.spec.ts +72 -0
  63. package/tests/session-flow.spec.ts +117 -0
  64. 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
+ }
@@ -34,7 +34,7 @@ export function Environment() {
34
34
  }
35
35
 
36
36
  var rows = Object.entries(env).map(function ([k, v]) {
37
- return { id: genId(), key: k, value: v };
37
+ return { id: genId(), key: k, value: String(v) };
38
38
  });
39
39
  setEntries(rows);
40
40
  if (!save.saving) {
@@ -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 = props.filter
426
- ? sessions.filter(function (s) {
427
- return s.title.toLowerCase().includes(props.filter!.toLowerCase());
428
- })
429
- : sessions;
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
- <button
26
- onClick={props.onClick}
27
- 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"
28
- aria-label="Node info"
29
- >
30
- <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">
31
- {initial}
32
- </div>
33
- <div className="flex-1 min-w-0 text-left">
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="text-[10px] text-base-content/30 font-mono">
38
- {"v" + pkg.version}
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
- </div>
41
- </button>
79
+ </button>
42
80
 
43
- <div className="flex items-center gap-0.5 flex-shrink-0">
44
- {canInstall && (
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="Install Lattice"
47
- onClick={install}
48
- className="btn btn-ghost btn-xs btn-square text-primary/60 hover:text-primary transition-colors"
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
- <Download size={14} />
96
+ {mode === "dark" ? <Sun size={14} /> : <Moon size={14} />}
51
97
  </button>
52
- )}
53
- <button
54
- aria-label={mode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
55
- onClick={function (e) { e.stopPropagation(); toggleMode(); }}
56
- className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-base-content transition-colors"
57
- >
58
- {mode === "dark" ? <Sun size={14} /> : <Moon size={14} />}
59
- </button>
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
  );