@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.
Files changed (81) hide show
  1. package/.github/workflows/ci.yml +121 -0
  2. package/bun.lock +14 -1
  3. package/client/src/App.tsx +2 -0
  4. package/client/src/components/analytics/ChartCard.tsx +6 -10
  5. package/client/src/components/analytics/QuickStats.tsx +3 -3
  6. package/client/src/components/analytics/charts/NodeFleetOverview.tsx +1 -1
  7. package/client/src/components/chat/ChatView.tsx +119 -7
  8. package/client/src/components/chat/Message.tsx +41 -6
  9. package/client/src/components/chat/PromptQuestion.tsx +4 -4
  10. package/client/src/components/chat/TodoCard.tsx +2 -2
  11. package/client/src/components/chat/ToolResultRenderer.tsx +6 -2
  12. package/client/src/components/dashboard/DashboardView.tsx +2 -0
  13. package/client/src/components/mesh/PairingDialog.tsx +6 -17
  14. package/client/src/components/project-settings/ProjectEnvironment.tsx +1 -1
  15. package/client/src/components/project-settings/ProjectMemory.tsx +10 -19
  16. package/client/src/components/project-settings/ProjectRules.tsx +3 -3
  17. package/client/src/components/project-settings/ProjectSkills.tsx +2 -2
  18. package/client/src/components/settings/BudgetSettings.tsx +161 -0
  19. package/client/src/components/settings/Environment.tsx +1 -1
  20. package/client/src/components/settings/SettingsView.tsx +3 -0
  21. package/client/src/components/sidebar/AddProjectModal.tsx +7 -11
  22. package/client/src/components/sidebar/NodeSettingsModal.tsx +6 -11
  23. package/client/src/components/sidebar/ProjectRail.tsx +11 -1
  24. package/client/src/components/sidebar/SessionList.tsx +33 -12
  25. package/client/src/components/sidebar/SettingsSidebar.tsx +2 -1
  26. package/client/src/components/sidebar/Sidebar.tsx +152 -2
  27. package/client/src/components/sidebar/UserIsland.tsx +76 -37
  28. package/client/src/components/ui/IconPicker.tsx +9 -36
  29. package/client/src/components/ui/KeyboardShortcuts.tsx +129 -0
  30. package/client/src/components/ui/Toast.tsx +22 -2
  31. package/client/src/components/workspace/BookmarksView.tsx +156 -0
  32. package/client/src/components/workspace/TabBar.tsx +34 -5
  33. package/client/src/components/workspace/TaskEditModal.tsx +7 -2
  34. package/client/src/components/workspace/WorkspaceView.tsx +29 -6
  35. package/client/src/hooks/useBookmarks.ts +57 -0
  36. package/client/src/hooks/useFocusTrap.ts +72 -0
  37. package/client/src/hooks/useProjects.ts +1 -1
  38. package/client/src/hooks/useSession.ts +38 -1
  39. package/client/src/hooks/useTimeTick.ts +35 -0
  40. package/client/src/hooks/useVoiceRecorder.ts +17 -3
  41. package/client/src/hooks/useWorkspace.ts +10 -1
  42. package/client/src/router.tsx +6 -11
  43. package/client/src/stores/bookmarks.ts +45 -0
  44. package/client/src/stores/session.ts +24 -0
  45. package/client/src/stores/sidebar.ts +2 -2
  46. package/client/src/stores/workspace.ts +114 -3
  47. package/client/src/vite-env.d.ts +6 -0
  48. package/client/tsconfig.json +4 -0
  49. package/package.json +2 -1
  50. package/playwright.config.ts +19 -0
  51. package/server/package.json +2 -0
  52. package/server/src/analytics/engine.ts +43 -9
  53. package/server/src/daemon.ts +11 -7
  54. package/server/src/handlers/bookmarks.ts +50 -0
  55. package/server/src/handlers/chat.ts +64 -0
  56. package/server/src/handlers/fs.ts +1 -1
  57. package/server/src/handlers/memory.ts +1 -1
  58. package/server/src/handlers/mesh.ts +1 -1
  59. package/server/src/handlers/project-settings.ts +2 -2
  60. package/server/src/handlers/session.ts +12 -11
  61. package/server/src/handlers/settings.ts +5 -2
  62. package/server/src/handlers/skills.ts +1 -1
  63. package/server/src/logger.ts +12 -0
  64. package/server/src/mesh/connector.ts +7 -6
  65. package/server/src/project/bookmarks.ts +83 -0
  66. package/server/src/project/context-breakdown.ts +1 -1
  67. package/server/src/project/registry.ts +5 -5
  68. package/server/src/project/sdk-bridge.ts +77 -6
  69. package/server/src/project/session.ts +6 -5
  70. package/server/src/ws/router.ts +5 -4
  71. package/server/tsconfig.json +4 -0
  72. package/shared/src/messages.ts +53 -2
  73. package/shared/src/models.ts +17 -1
  74. package/shared/src/project-settings.ts +0 -1
  75. package/shared/tsconfig.json +4 -0
  76. package/tests/accessibility.spec.ts +77 -0
  77. package/tests/keyboard-shortcuts.spec.ts +74 -0
  78. package/tests/message-actions.spec.ts +112 -0
  79. package/tests/onboarding.spec.ts +72 -0
  80. package/tests/session-flow.spec.ts +117 -0
  81. 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
- <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
  );
@@ -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" | "emoji" | "text" | "upload";
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 [tab, setTab] = useState<Tab>(value?.type === "emoji" ? "emoji" : value?.type === "text" ? "text" : value?.type === "image" ? "upload" : "lucide");
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 [emojiValue, setEmojiValue] = useState(value?.type === "emoji" ? value.value : "");
63
- var [textValue, setTextValue] = useState(value?.type === "text" ? value.value : "");
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(value)}
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 = value?.type === "lucide" && value.name === name;
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
- {value?.type === "image" && (
203
- <img src={value.path} alt="preview" className="w-16 h-16 rounded-xl object-cover border border-base-content/15" loading="lazy" />
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
- }, 5000);
119
+ }, timeout);
100
120
  }
101
121
 
102
122
  toastListeners.push(listener);