@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
|
@@ -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
|
);
|
|
@@ -2,7 +2,7 @@ import { useState, useMemo } from "react";
|
|
|
2
2
|
import { icons } from "lucide-react";
|
|
3
3
|
import type { ProjectIcon } from "@lattice/shared";
|
|
4
4
|
|
|
5
|
-
type Tab = "lucide" | "
|
|
5
|
+
type Tab = "lucide" | "text" | "upload";
|
|
6
6
|
|
|
7
7
|
interface IconPickerProps {
|
|
8
8
|
value?: ProjectIcon;
|
|
@@ -28,14 +28,6 @@ function renderPreview(value?: ProjectIcon) {
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
if (value.type === "emoji") {
|
|
32
|
-
return (
|
|
33
|
-
<div className="w-8 h-8 rounded-lg bg-base-300 border border-base-content/15 flex items-center justify-center text-[18px]">
|
|
34
|
-
{value.value}
|
|
35
|
-
</div>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
31
|
if (value.type === "text") {
|
|
40
32
|
return (
|
|
41
33
|
<div
|
|
@@ -57,11 +49,11 @@ function renderPreview(value?: ProjectIcon) {
|
|
|
57
49
|
}
|
|
58
50
|
|
|
59
51
|
export function IconPicker({ value, onChange }: IconPickerProps) {
|
|
60
|
-
var
|
|
52
|
+
var normalizedValue = value && (value as { type: string }).type === "emoji" ? undefined : value;
|
|
53
|
+
var [tab, setTab] = useState<Tab>(normalizedValue?.type === "text" ? "text" : normalizedValue?.type === "image" ? "upload" : "lucide");
|
|
61
54
|
var [search, setSearch] = useState("");
|
|
62
|
-
var [
|
|
63
|
-
var [
|
|
64
|
-
var [textColor, setTextColor] = useState(value?.type === "text" ? (value.color || "#ffffff") : "#ffffff");
|
|
55
|
+
var [textValue, setTextValue] = useState(normalizedValue?.type === "text" ? normalizedValue.value : "");
|
|
56
|
+
var [textColor, setTextColor] = useState(normalizedValue?.type === "text" ? (normalizedValue.color || "#ffffff") : "#ffffff");
|
|
65
57
|
|
|
66
58
|
var iconNames = useMemo(function () {
|
|
67
59
|
var allNames = Object.keys(icons);
|
|
@@ -74,7 +66,6 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|
|
74
66
|
|
|
75
67
|
var tabs: { id: Tab; label: string }[] = [
|
|
76
68
|
{ id: "lucide", label: "Lucide" },
|
|
77
|
-
{ id: "emoji", label: "Emoji" },
|
|
78
69
|
{ id: "text", label: "Text" },
|
|
79
70
|
{ id: "upload", label: "Upload" },
|
|
80
71
|
];
|
|
@@ -93,7 +84,7 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|
|
93
84
|
return (
|
|
94
85
|
<div className="space-y-3">
|
|
95
86
|
<div className="flex items-center gap-3">
|
|
96
|
-
{renderPreview(
|
|
87
|
+
{renderPreview(normalizedValue)}
|
|
97
88
|
<span className="text-[11px] text-base-content/40">Current icon</span>
|
|
98
89
|
</div>
|
|
99
90
|
|
|
@@ -124,7 +115,7 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|
|
124
115
|
<div className="grid grid-cols-10 gap-1 max-h-48 overflow-y-auto">
|
|
125
116
|
{iconNames.map(function (name) {
|
|
126
117
|
var Icon = icons[name as keyof typeof icons];
|
|
127
|
-
var selected =
|
|
118
|
+
var selected = normalizedValue?.type === "lucide" && normalizedValue.name === name;
|
|
128
119
|
return (
|
|
129
120
|
<button
|
|
130
121
|
key={name}
|
|
@@ -144,24 +135,6 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|
|
144
135
|
</div>
|
|
145
136
|
)}
|
|
146
137
|
|
|
147
|
-
{tab === "emoji" && (
|
|
148
|
-
<div>
|
|
149
|
-
<input
|
|
150
|
-
type="text"
|
|
151
|
-
value={emojiValue}
|
|
152
|
-
maxLength={2}
|
|
153
|
-
onChange={function (e) {
|
|
154
|
-
setEmojiValue(e.target.value);
|
|
155
|
-
if (e.target.value) {
|
|
156
|
-
onChange({ type: "emoji", value: e.target.value });
|
|
157
|
-
}
|
|
158
|
-
}}
|
|
159
|
-
placeholder="Enter emoji"
|
|
160
|
-
className="w-full h-9 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
|
|
161
|
-
/>
|
|
162
|
-
</div>
|
|
163
|
-
)}
|
|
164
|
-
|
|
165
138
|
{tab === "text" && (
|
|
166
139
|
<div className="flex gap-2">
|
|
167
140
|
<input
|
|
@@ -199,8 +172,8 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|
|
199
172
|
onChange={handleFileChange}
|
|
200
173
|
className="w-full text-[12px] text-base-content/60 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-[12px] file:bg-base-300 file:text-base-content/60 file:cursor-pointer"
|
|
201
174
|
/>
|
|
202
|
-
{
|
|
203
|
-
<img src={
|
|
175
|
+
{normalizedValue?.type === "image" && (
|
|
176
|
+
<img src={normalizedValue.path} alt="preview" className="w-16 h-16 rounded-xl object-cover border border-base-content/15" loading="lazy" />
|
|
204
177
|
)}
|
|
205
178
|
</div>
|
|
206
179
|
)}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface ShortcutEntry {
|
|
5
|
+
keys: string[];
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ShortcutCategory {
|
|
10
|
+
name: string;
|
|
11
|
+
shortcuts: ShortcutEntry[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
var isMac = typeof navigator !== "undefined" && navigator.platform.indexOf("Mac") !== -1;
|
|
15
|
+
var modKey = isMac ? "\u2318" : "Ctrl";
|
|
16
|
+
|
|
17
|
+
var categories: ShortcutCategory[] = [
|
|
18
|
+
{
|
|
19
|
+
name: "Chat",
|
|
20
|
+
shortcuts: [
|
|
21
|
+
{ keys: ["Enter"], description: "Send message" },
|
|
22
|
+
{ keys: ["Shift", "Enter"], description: "New line" },
|
|
23
|
+
{ keys: ["\u2191"], description: "Previous input history" },
|
|
24
|
+
{ keys: ["\u2193"], description: "Next input history" },
|
|
25
|
+
{ keys: ["/"], description: "Slash commands" },
|
|
26
|
+
{ keys: ["Escape"], description: "Close autocomplete" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "Navigation",
|
|
31
|
+
shortcuts: [
|
|
32
|
+
{ keys: [modKey, "K"], description: "Command palette" },
|
|
33
|
+
{ keys: ["?"], description: "Keyboard shortcuts" },
|
|
34
|
+
{ keys: ["Escape"], description: "Close modal / exit settings" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "Session",
|
|
39
|
+
shortcuts: [
|
|
40
|
+
{ keys: ["/clear"], description: "New session" },
|
|
41
|
+
{ keys: ["/copy"], description: "Copy last response" },
|
|
42
|
+
{ keys: ["/export"], description: "Export conversation" },
|
|
43
|
+
{ keys: ["/rename"], description: "Rename session" },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function Kbd(props: { children: string }) {
|
|
49
|
+
return (
|
|
50
|
+
<kbd className="bg-base-300 border border-base-content/20 rounded px-1.5 py-0.5 text-[11px] font-mono text-base-content/70 leading-none inline-flex items-center justify-center min-w-[22px]">
|
|
51
|
+
{props.children}
|
|
52
|
+
</kbd>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function KeyboardShortcuts() {
|
|
57
|
+
var [open, setOpen] = useState(false);
|
|
58
|
+
|
|
59
|
+
useEffect(function () {
|
|
60
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
61
|
+
if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
62
|
+
var tag = (e.target as HTMLElement).tagName;
|
|
63
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
setOpen(true);
|
|
66
|
+
}
|
|
67
|
+
if (e.key === "Escape" && open) {
|
|
68
|
+
setOpen(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
72
|
+
return function () { document.removeEventListener("keydown", handleKeyDown); };
|
|
73
|
+
}, [open]);
|
|
74
|
+
|
|
75
|
+
if (!open) return null;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="fixed inset-0 z-[9998] flex items-center justify-center" onClick={function () { setOpen(false); }}>
|
|
79
|
+
<div className="absolute inset-0 bg-black/50" />
|
|
80
|
+
<div
|
|
81
|
+
className="relative w-full max-w-[540px] mx-4 bg-base-200 border border-base-content/15 rounded-2xl shadow-xl overflow-hidden"
|
|
82
|
+
onClick={function (e) { e.stopPropagation(); }}
|
|
83
|
+
>
|
|
84
|
+
<div className="flex items-center justify-between px-5 py-3.5 border-b border-base-content/10">
|
|
85
|
+
<h2 className="text-[14px] font-mono font-bold text-base-content tracking-tight">Keyboard Shortcuts</h2>
|
|
86
|
+
<button
|
|
87
|
+
onClick={function () { setOpen(false); }}
|
|
88
|
+
className="w-6 h-6 rounded flex items-center justify-center text-base-content/30 hover:text-base-content/60 transition-colors"
|
|
89
|
+
>
|
|
90
|
+
<X size={14} />
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="px-5 py-4 max-h-[70vh] overflow-y-auto">
|
|
94
|
+
<div className="flex flex-col gap-5">
|
|
95
|
+
{categories.map(function (cat) {
|
|
96
|
+
return (
|
|
97
|
+
<div key={cat.name}>
|
|
98
|
+
<div className="text-[9px] uppercase tracking-widest text-base-content/30 font-mono font-bold mb-2">{cat.name}</div>
|
|
99
|
+
<div className="flex flex-col gap-1">
|
|
100
|
+
{cat.shortcuts.map(function (shortcut, i) {
|
|
101
|
+
return (
|
|
102
|
+
<div key={i} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-base-content/5 transition-colors">
|
|
103
|
+
<span className="text-[13px] text-base-content/60">{shortcut.description}</span>
|
|
104
|
+
<div className="flex items-center gap-1 flex-shrink-0 ml-4">
|
|
105
|
+
{shortcut.keys.map(function (key, ki) {
|
|
106
|
+
return (
|
|
107
|
+
<span key={ki} className="flex items-center gap-1">
|
|
108
|
+
{ki > 0 && <span className="text-[10px] text-base-content/20">+</span>}
|
|
109
|
+
<Kbd>{key}</Kbd>
|
|
110
|
+
</span>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="px-5 py-2.5 border-t border-base-content/10 flex justify-end">
|
|
124
|
+
<span className="text-[10px] font-mono text-base-content/20">Press <Kbd>Esc</Kbd> to close</span>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { X, Info, AlertTriangle, AlertCircle } from "lucide-react";
|
|
3
3
|
|
|
4
|
+
export interface ToastOptions {
|
|
5
|
+
persistent?: boolean;
|
|
6
|
+
duration?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
4
9
|
export interface ToastItem {
|
|
5
10
|
id: string;
|
|
6
11
|
message: string;
|
|
7
12
|
type: "info" | "error" | "warning";
|
|
13
|
+
persistent?: boolean;
|
|
14
|
+
duration?: number;
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
interface ToastProps {
|
|
@@ -63,11 +70,20 @@ export function Toast(props: ToastProps) {
|
|
|
63
70
|
|
|
64
71
|
var toastListeners: Array<(item: ToastItem) => void> = [];
|
|
65
72
|
|
|
66
|
-
export function showToast(message: string, type: ToastItem["type"] = "info"): void {
|
|
73
|
+
export function showToast(message: string, type: ToastItem["type"] = "info", options?: ToastOptions): void {
|
|
74
|
+
var persistent = options?.persistent;
|
|
75
|
+
var duration = options?.duration;
|
|
76
|
+
|
|
77
|
+
if (type === "error" && persistent === undefined && duration === undefined) {
|
|
78
|
+
persistent = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
var item: ToastItem = {
|
|
68
82
|
id: Math.random().toString(36).slice(2),
|
|
69
83
|
message,
|
|
70
84
|
type,
|
|
85
|
+
persistent,
|
|
86
|
+
duration,
|
|
71
87
|
};
|
|
72
88
|
toastListeners.forEach(function (listener) {
|
|
73
89
|
listener(item);
|
|
@@ -90,13 +106,17 @@ export function useToastState(): { items: ToastItem[]; dismiss: (id: string) =>
|
|
|
90
106
|
setItems(function (prev) {
|
|
91
107
|
return [...prev, item];
|
|
92
108
|
});
|
|
109
|
+
|
|
110
|
+
if (item.persistent) return;
|
|
111
|
+
|
|
112
|
+
var timeout = item.duration ?? 5000;
|
|
93
113
|
setTimeout(function () {
|
|
94
114
|
setItems(function (prev) {
|
|
95
115
|
return prev.filter(function (i) {
|
|
96
116
|
return i.id !== item.id;
|
|
97
117
|
});
|
|
98
118
|
});
|
|
99
|
-
},
|
|
119
|
+
}, timeout);
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
toastListeners.push(listener);
|