@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
@@ -0,0 +1,156 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { Bookmark, MessageSquare, ExternalLink } from "lucide-react";
3
+ import { useBookmarks } from "../../hooks/useBookmarks";
4
+ import { useSession } from "../../hooks/useSession";
5
+ import { useProjects } from "../../hooks/useProjects";
6
+ import { openTab } from "../../stores/workspace";
7
+ import type { MessageBookmark } from "@lattice/shared";
8
+
9
+ function relativeTime(ts: number): string {
10
+ var diff = Date.now() - ts;
11
+ var seconds = Math.floor(diff / 1000);
12
+ if (seconds < 60) return seconds + "s ago";
13
+ var minutes = Math.floor(seconds / 60);
14
+ if (minutes < 60) return minutes + "m ago";
15
+ var hours = Math.floor(minutes / 60);
16
+ if (hours < 24) return hours + "h ago";
17
+ var days = Math.floor(hours / 24);
18
+ return days + "d ago";
19
+ }
20
+
21
+ interface GroupedBookmarks {
22
+ projectSlug: string;
23
+ sessions: Array<{
24
+ sessionId: string;
25
+ bookmarks: MessageBookmark[];
26
+ }>;
27
+ }
28
+
29
+ export function BookmarksView() {
30
+ var { allBookmarks, requestAllBookmarks } = useBookmarks();
31
+ var { activateSession } = useSession();
32
+ var { projects } = useProjects();
33
+
34
+ useEffect(function () {
35
+ requestAllBookmarks();
36
+ }, []);
37
+
38
+ var grouped = useMemo(function () {
39
+ var projectMap = new Map<string, Map<string, MessageBookmark[]>>();
40
+ for (var i = 0; i < allBookmarks.length; i++) {
41
+ var bm = allBookmarks[i];
42
+ if (!projectMap.has(bm.projectSlug)) {
43
+ projectMap.set(bm.projectSlug, new Map());
44
+ }
45
+ var sessionMap = projectMap.get(bm.projectSlug)!;
46
+ if (!sessionMap.has(bm.sessionId)) {
47
+ sessionMap.set(bm.sessionId, []);
48
+ }
49
+ sessionMap.get(bm.sessionId)!.push(bm);
50
+ }
51
+ var result: GroupedBookmarks[] = [];
52
+ projectMap.forEach(function (sessionMap, projectSlug) {
53
+ var sessions: GroupedBookmarks["sessions"] = [];
54
+ sessionMap.forEach(function (bookmarks, sessionId) {
55
+ sessions.push({ sessionId, bookmarks });
56
+ });
57
+ sessions.sort(function (a, b) {
58
+ return b.bookmarks[0].createdAt - a.bookmarks[0].createdAt;
59
+ });
60
+ result.push({ projectSlug, sessions });
61
+ });
62
+ result.sort(function (a, b) {
63
+ var aLatest = a.sessions[0]?.bookmarks[0]?.createdAt ?? 0;
64
+ var bLatest = b.sessions[0]?.bookmarks[0]?.createdAt ?? 0;
65
+ return bLatest - aLatest;
66
+ });
67
+ return result;
68
+ }, [allBookmarks]);
69
+
70
+ function getProjectTitle(slug: string): string {
71
+ for (var i = 0; i < projects.length; i++) {
72
+ if (projects[i].slug === slug) return projects[i].title;
73
+ }
74
+ return slug;
75
+ }
76
+
77
+ function handleBookmarkClick(bm: MessageBookmark) {
78
+ activateSession(bm.projectSlug, bm.sessionId);
79
+ openTab("chat");
80
+ setTimeout(function () {
81
+ var el = document.getElementById("msg-" + bm.messageUuid);
82
+ if (el) {
83
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
84
+ el.classList.add("ring-2", "ring-warning/40");
85
+ setTimeout(function () { el!.classList.remove("ring-2", "ring-warning/40"); }, 2000);
86
+ }
87
+ }, 500);
88
+ }
89
+
90
+ return (
91
+ <div className="flex flex-col h-full w-full overflow-hidden">
92
+ <div className="flex items-center gap-2 px-4 py-3 border-b border-base-content/10 bg-base-100 flex-shrink-0">
93
+ <Bookmark size={16} className="text-warning" />
94
+ <span className="text-sm font-semibold text-base-content">Bookmarks</span>
95
+ <span className="text-[10px] font-mono text-base-content/30 ml-auto">{allBookmarks.length} total</span>
96
+ </div>
97
+ <div className="flex-1 overflow-y-auto p-4">
98
+ {allBookmarks.length === 0 ? (
99
+ <div className="flex flex-col items-center justify-center h-full text-base-content/30 gap-3">
100
+ <Bookmark size={32} className="text-base-content/15" />
101
+ <div className="text-[13px] font-mono">No bookmarks yet</div>
102
+ <div className="text-[11px] text-base-content/20 max-w-[260px] text-center">
103
+ Bookmark messages in chat to quickly find them later.
104
+ </div>
105
+ </div>
106
+ ) : (
107
+ <div className="flex flex-col gap-4">
108
+ {grouped.map(function (group) {
109
+ return (
110
+ <div key={group.projectSlug}>
111
+ <div className="text-[10px] uppercase tracking-widest text-base-content/40 font-mono font-bold mb-2">
112
+ {getProjectTitle(group.projectSlug)}
113
+ </div>
114
+ <div className="flex flex-col gap-2">
115
+ {group.sessions.map(function (session) {
116
+ return (
117
+ <div key={session.sessionId} className="bg-base-200/50 border border-base-content/8 rounded-lg overflow-hidden">
118
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-base-content/5">
119
+ <MessageSquare size={11} className="text-base-content/30" />
120
+ <span className="text-[11px] font-mono text-base-content/40 truncate">{session.sessionId.slice(0, 12)}...</span>
121
+ </div>
122
+ <div className="flex flex-col">
123
+ {session.bookmarks.map(function (bm) {
124
+ return (
125
+ <button
126
+ key={bm.id}
127
+ type="button"
128
+ onClick={function () { handleBookmarkClick(bm); }}
129
+ className="flex items-start gap-2 px-3 py-2 hover:bg-base-content/5 transition-colors text-left w-full"
130
+ >
131
+ <Bookmark size={10} className="text-warning/60 mt-0.5 flex-shrink-0" />
132
+ <div className="min-w-0 flex-1">
133
+ <div className="text-[12px] text-base-content/70 line-clamp-2">{bm.messageText}</div>
134
+ <div className="flex items-center gap-2 mt-0.5">
135
+ <span className="text-[9px] text-base-content/25 font-mono">{bm.messageType}</span>
136
+ <span className="text-[9px] text-base-content/20 font-mono">{relativeTime(bm.createdAt)}</span>
137
+ </div>
138
+ </div>
139
+ <ExternalLink size={10} className="text-base-content/20 mt-1 flex-shrink-0" />
140
+ </button>
141
+ );
142
+ })}
143
+ </div>
144
+ </div>
145
+ );
146
+ })}
147
+ </div>
148
+ </div>
149
+ );
150
+ })}
151
+ </div>
152
+ )}
153
+ </div>
154
+ </div>
155
+ );
156
+ }
@@ -1,7 +1,9 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
- import { X, Columns2, Rows2, MessageSquare, FolderOpen, TerminalSquare, StickyNote, Calendar } from "lucide-react";
2
+ import { X, Columns2, Rows2, MessageSquare, FolderOpen, TerminalSquare, StickyNote, Calendar, Bookmark } from "lucide-react";
3
3
  import { useWorkspace } from "../../hooks/useWorkspace";
4
+ import { useSession } from "../../hooks/useSession";
4
5
  import type { Tab, TabType } from "../../stores/workspace";
6
+ import { formatSessionTitle } from "../../utils/formatSessionTitle";
5
7
 
6
8
  interface TabBarProps {
7
9
  paneId?: string;
@@ -20,10 +22,12 @@ var TAB_ICONS: Record<TabType, typeof MessageSquare> = {
20
22
  terminal: TerminalSquare,
21
23
  notes: StickyNote,
22
24
  tasks: Calendar,
25
+ bookmarks: Bookmark,
23
26
  };
24
27
 
25
28
  export function TabBar({ paneId, isActivePane }: TabBarProps) {
26
29
  var workspace = useWorkspace();
30
+ var session = useSession();
27
31
  var [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
28
32
  var menuRef = useRef<HTMLDivElement>(null);
29
33
 
@@ -65,7 +69,17 @@ export function TabBar({ paneId, isActivePane }: TabBarProps) {
65
69
  activeTabId = workspace.activeTabId;
66
70
  }
67
71
 
68
- var shouldShow = paneTabs.length > 1 || (paneTabs[0]?.closeable ?? false);
72
+ var shouldShow = paneTabs.length > 1 || paneTabs.some(function (t) { return t.closeable; });
73
+
74
+ function getTabLabel(tab: Tab): string {
75
+ if (tab.type === "chat" && tab.sessionId) {
76
+ if (tab.sessionId === session.activeSessionId && session.activeSessionTitle) {
77
+ return formatSessionTitle(session.activeSessionTitle) || tab.label;
78
+ }
79
+ return formatSessionTitle(tab.label) || "Session";
80
+ }
81
+ return tab.label;
82
+ }
69
83
 
70
84
  function handleTabClick(tabId: string) {
71
85
  if (paneId) {
@@ -73,6 +87,12 @@ export function TabBar({ paneId, isActivePane }: TabBarProps) {
73
87
  } else {
74
88
  workspace.setActiveTab(tabId);
75
89
  }
90
+ var tab = workspace.tabs.find(function (t) { return t.id === tabId; });
91
+ if (tab && tab.type === "chat" && tab.sessionId && tab.projectSlug) {
92
+ if (session.activeSessionId !== tab.sessionId) {
93
+ session.activateSession(tab.projectSlug, tab.sessionId);
94
+ }
95
+ }
76
96
  }
77
97
 
78
98
  function handleCloseTab(tabId: string) {
@@ -101,6 +121,13 @@ export function TabBar({ paneId, isActivePane }: TabBarProps) {
101
121
  setContextMenu(null);
102
122
  }
103
123
 
124
+ function handleMiddleClick(e: React.MouseEvent, tab: Tab) {
125
+ if (e.button === 1 && tab.closeable) {
126
+ e.preventDefault();
127
+ handleCloseTab(tab.id);
128
+ }
129
+ }
130
+
104
131
  return (
105
132
  <>
106
133
  <div
@@ -119,6 +146,7 @@ export function TabBar({ paneId, isActivePane }: TabBarProps) {
119
146
  {paneTabs.map(function (tab) {
120
147
  var isActive = tab.id === activeTabId;
121
148
  var Icon = TAB_ICONS[tab.type] || MessageSquare;
149
+ var label = getTabLabel(tab);
122
150
  return (
123
151
  <div
124
152
  key={tab.id}
@@ -126,6 +154,7 @@ export function TabBar({ paneId, isActivePane }: TabBarProps) {
126
154
  tabIndex={0}
127
155
  aria-selected={isActive}
128
156
  onClick={function () { handleTabClick(tab.id); }}
157
+ onMouseDown={function (e) { handleMiddleClick(e, tab); }}
129
158
  onKeyDown={function (e) {
130
159
  if (e.key === "Enter" || e.key === " ") {
131
160
  e.preventDefault();
@@ -134,17 +163,17 @@ export function TabBar({ paneId, isActivePane }: TabBarProps) {
134
163
  }}
135
164
  onContextMenu={function (e) { handleContextMenu(e, tab.id); }}
136
165
  className={
137
- "flex items-center gap-2 px-4 py-2.5 text-[13px] font-mono border-r border-base-content/10 transition-colors whitespace-nowrap flex-shrink-0 outline-none cursor-pointer select-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-inset " +
166
+ "flex items-center gap-2 px-4 py-2.5 text-[13px] font-mono border-r border-base-content/10 transition-colors whitespace-nowrap flex-shrink-0 outline-none cursor-pointer select-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-inset max-w-[200px] " +
138
167
  (isActive
139
168
  ? "bg-base-100 text-base-content border-b-2 border-b-primary"
140
169
  : "text-base-content/40 hover:text-base-content/70 hover:bg-base-300/30")
141
170
  }
142
171
  >
143
172
  <Icon size={14} className={isActive ? "text-primary" : ""} />
144
- <span>{tab.label}</span>
173
+ <span className="truncate text-[12px]">{label}</span>
145
174
  {tab.closeable && (
146
175
  <button
147
- aria-label={"Close " + tab.label + " tab"}
176
+ aria-label={"Close " + label + " tab"}
148
177
  onClick={function (e) {
149
178
  e.stopPropagation();
150
179
  handleCloseTab(tab.id);
@@ -1,5 +1,6 @@
1
- import { useState } from "react";
1
+ import { useState, useRef, useCallback } from "react";
2
2
  import { X } from "lucide-react";
3
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
3
4
  import cronstrue from "cronstrue";
4
5
  import type { ScheduledTask } from "@lattice/shared";
5
6
 
@@ -25,6 +26,10 @@ export function TaskEditModal(props: TaskEditModalProps) {
25
26
  var [prompt, setPrompt] = useState(task ? task.prompt : "");
26
27
  var [cron, setCron] = useState(task ? task.cron : "0 9 * * 1-5");
27
28
 
29
+ var modalRef = useRef<HTMLDivElement>(null);
30
+ var stableOnClose = useCallback(function () { onClose(); }, [onClose]);
31
+ useFocusTrap(modalRef, stableOnClose);
32
+
28
33
  var cronPreview = getCronPreview(cron);
29
34
  var cronValid = cronPreview !== "Invalid cron expression" && cronPreview !== "";
30
35
 
@@ -40,12 +45,12 @@ export function TaskEditModal(props: TaskEditModalProps) {
40
45
 
41
46
  return (
42
47
  <div
48
+ ref={modalRef}
43
49
  className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
44
50
  onClick={handleBackdrop}
45
51
  role="dialog"
46
52
  aria-modal="true"
47
53
  aria-label={task ? "Edit Task" : "New Scheduled Task"}
48
- onKeyDown={function (e) { if (e.key === "Escape") onClose(); }}
49
54
  >
50
55
  <div className="bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4">
51
56
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
@@ -9,14 +9,15 @@ import { TerminalView } from "./TerminalView";
9
9
  import { FileBrowser } from "./FileBrowser";
10
10
  import { NotesView } from "./NotesView";
11
11
  import { ScheduledTasksView } from "./ScheduledTasksView";
12
+ import { BookmarksView } from "./BookmarksView";
12
13
  import type { Pane, Tab } from "../../stores/workspace";
13
14
 
14
- var TAB_COMPONENTS: Record<string, () => React.JSX.Element> = {
15
- chat: ChatView,
15
+ var NON_CHAT_COMPONENTS: Record<string, () => React.JSX.Element> = {
16
16
  files: FileBrowser,
17
17
  terminal: TerminalView,
18
18
  notes: NotesView,
19
19
  tasks: ScheduledTasksView,
20
+ bookmarks: BookmarksView,
20
21
  };
21
22
 
22
23
  function PaneContent({ pane, tabs, isActive, onFocus }: {
@@ -44,9 +45,20 @@ function PaneContent({ pane, tabs, isActive, onFocus }: {
44
45
  )}
45
46
  <div className="flex-1 min-h-0 relative">
46
47
  {paneTabs.map(function (tab) {
47
- var Component = TAB_COMPONENTS[tab.type];
48
- if (!Component) return null;
49
48
  var isTabActive = tab.id === pane.activeTabId;
49
+ if (tab.type === "chat") {
50
+ return (
51
+ <div
52
+ key={tab.id}
53
+ className="absolute inset-0"
54
+ style={{ display: isTabActive ? "flex" : "none", flexDirection: "column" }}
55
+ >
56
+ <ChatView sessionId={tab.sessionId} projectSlug={tab.projectSlug} />
57
+ </div>
58
+ );
59
+ }
60
+ var Component = NON_CHAT_COMPONENTS[tab.type];
61
+ if (!Component) return null;
50
62
  return (
51
63
  <div
52
64
  key={tab.id}
@@ -79,9 +91,20 @@ export function WorkspaceView() {
79
91
  )}
80
92
  <div className="flex-1 min-h-0 relative order-0 sm:order-none">
81
93
  {tabs.map(function (tab) {
82
- var Component = TAB_COMPONENTS[tab.type];
83
- if (!Component) return null;
84
94
  var isActive = singlePane ? tab.id === singlePane.activeTabId : tab.id === "chat";
95
+ if (tab.type === "chat") {
96
+ return (
97
+ <div
98
+ key={tab.id}
99
+ className="absolute inset-0"
100
+ style={{ display: isActive ? "flex" : "none", flexDirection: "column" }}
101
+ >
102
+ <ChatView sessionId={tab.sessionId} projectSlug={tab.projectSlug} />
103
+ </div>
104
+ );
105
+ }
106
+ var Component = NON_CHAT_COMPONENTS[tab.type];
107
+ if (!Component) return null;
85
108
  return (
86
109
  <div
87
110
  key={tab.id}
@@ -0,0 +1,57 @@
1
+ import { useEffect } from "react";
2
+ import { useStore } from "@tanstack/react-store";
3
+ import type { ServerMessage, BookmarkListResultMessage, MessageBookmark } from "@lattice/shared";
4
+ import { useWebSocket } from "./useWebSocket";
5
+ import { getBookmarkStore, setBookmarks, setAllBookmarks } from "../stores/bookmarks";
6
+ import { getSessionStore } from "../stores/session";
7
+ import type { BookmarkState } from "../stores/bookmarks";
8
+
9
+ export function useBookmarks(): BookmarkState & {
10
+ requestSessionBookmarks: () => void;
11
+ requestAllBookmarks: () => void;
12
+ } {
13
+ var store = getBookmarkStore();
14
+ var state = useStore(store, function (s) { return s; });
15
+ var { send, subscribe, unsubscribe } = useWebSocket();
16
+
17
+ useEffect(function () {
18
+ function handleBookmarkList(msg: ServerMessage) {
19
+ if (msg.type !== "bookmark:list_result") return;
20
+ var data = msg as BookmarkListResultMessage;
21
+ if (data.scope === "session") {
22
+ setBookmarks(data.bookmarks);
23
+ } else {
24
+ setAllBookmarks(data.bookmarks);
25
+ var sessionState = getSessionStore().state;
26
+ if (sessionState.activeSessionId) {
27
+ var sessionBookmarks = data.bookmarks.filter(function (b: MessageBookmark) {
28
+ return b.sessionId === sessionState.activeSessionId;
29
+ });
30
+ setBookmarks(sessionBookmarks);
31
+ }
32
+ }
33
+ }
34
+
35
+ subscribe("bookmark:list_result", handleBookmarkList);
36
+ return function () {
37
+ unsubscribe("bookmark:list_result", handleBookmarkList);
38
+ };
39
+ }, [subscribe, unsubscribe]);
40
+
41
+ function requestSessionBookmarks() {
42
+ var sessionState = getSessionStore().state;
43
+ if (!sessionState.activeSessionId || !sessionState.activeProjectSlug) return;
44
+ send({ type: "bookmark:list", projectSlug: sessionState.activeProjectSlug, sessionId: sessionState.activeSessionId });
45
+ }
46
+
47
+ function requestAllBookmarks() {
48
+ send({ type: "bookmark:list" });
49
+ }
50
+
51
+ return {
52
+ bookmarks: state.bookmarks,
53
+ allBookmarks: state.allBookmarks,
54
+ requestSessionBookmarks,
55
+ requestAllBookmarks,
56
+ };
57
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ var FOCUSABLE_SELECTOR = [
4
+ "a[href]",
5
+ "button:not([disabled])",
6
+ "input:not([disabled])",
7
+ "select:not([disabled])",
8
+ "textarea:not([disabled])",
9
+ "[tabindex]:not([tabindex='-1'])",
10
+ ].join(", ");
11
+
12
+ export function useFocusTrap(
13
+ containerRef: React.RefObject<HTMLElement | null>,
14
+ onClose: () => void,
15
+ active: boolean = true,
16
+ ): void {
17
+ var previouslyFocusedRef = useRef<HTMLElement | null>(null);
18
+
19
+ useEffect(function () {
20
+ if (!active) return;
21
+
22
+ var container = containerRef.current;
23
+ if (!container) return;
24
+
25
+ previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
26
+
27
+ var focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
28
+ if (focusable.length > 0) {
29
+ focusable[0].focus();
30
+ }
31
+
32
+ function handleKeyDown(e: KeyboardEvent) {
33
+ if (e.key === "Escape") {
34
+ onClose();
35
+ return;
36
+ }
37
+
38
+ if (e.key !== "Tab") return;
39
+
40
+ var currentContainer = containerRef.current;
41
+ if (!currentContainer) return;
42
+
43
+ var elements = currentContainer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
44
+ if (elements.length === 0) return;
45
+
46
+ var first = elements[0];
47
+ var last = elements[elements.length - 1];
48
+
49
+ if (e.shiftKey) {
50
+ if (document.activeElement === first) {
51
+ e.preventDefault();
52
+ last.focus();
53
+ }
54
+ } else {
55
+ if (document.activeElement === last) {
56
+ e.preventDefault();
57
+ first.focus();
58
+ }
59
+ }
60
+ }
61
+
62
+ var el = container;
63
+ el.addEventListener("keydown", handleKeyDown);
64
+
65
+ return function () {
66
+ el.removeEventListener("keydown", handleKeyDown);
67
+ if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === "function") {
68
+ previouslyFocusedRef.current.focus();
69
+ }
70
+ };
71
+ }, [containerRef, onClose, active]);
72
+ }
@@ -27,7 +27,7 @@ export function useProjects(): UseProjectsResult {
27
27
  var storeState = getSidebarStore().state;
28
28
  var currentSlug = storeState.activeProjectSlug;
29
29
  if (currentSlug !== null) {
30
- var found = list.find(function (p) { return p.slug === currentSlug; });
30
+ var found = list.find(function (p: typeof list[number]) { return p.slug === currentSlug; });
31
31
  if (!found && list.length > 0) {
32
32
  setActiveProjectSlug(list[0].slug);
33
33
  }
@@ -18,6 +18,7 @@ import type {
18
18
  } from "@lattice/shared";
19
19
  import { useWebSocket } from "./useWebSocket";
20
20
  import { setActiveSessionId as setSidebarSessionId } from "../stores/sidebar";
21
+ import { updateSessionTabTitle } from "../stores/workspace";
21
22
  import {
22
23
  getSessionStore,
23
24
  setSessionMessages,
@@ -52,8 +53,10 @@ import {
52
53
  addPromptQuestion,
53
54
  addTodoUpdate,
54
55
  setIsPlanMode,
56
+ setBudgetStatus,
57
+ setBudgetExceeded,
55
58
  } from "../stores/session";
56
- import type { SessionState } from "../stores/session";
59
+ import type { SessionState, BudgetStatus } from "../stores/session";
57
60
 
58
61
  var subscriptionsActive = 0;
59
62
  var activeStreamGeneration = 0;
@@ -73,6 +76,8 @@ export interface UseSessionReturn extends SessionState {
73
76
  removeQueuedMessage: (index: number) => void;
74
77
  updateQueuedMessage: (index: number, text: string) => void;
75
78
  clearMessageQueue: () => void;
79
+ sendBudgetOverride: () => void;
80
+ dismissBudgetExceeded: () => void;
76
81
  }
77
82
 
78
83
  export function useSession(): UseSessionReturn {
@@ -281,6 +286,9 @@ export function useSession(): UseSessionReturn {
281
286
  if (m.busy) {
282
287
  activeStreamGeneration = getStreamGeneration();
283
288
  }
289
+ if (m.title) {
290
+ updateSessionTabTitle(m.sessionId, m.title);
291
+ }
284
292
  getSessionStore().setState(function (state) {
285
293
  return {
286
294
  ...state,
@@ -358,6 +366,17 @@ export function useSession(): UseSessionReturn {
358
366
  setIsPlanMode(m.active);
359
367
  }
360
368
 
369
+ function handleBudgetStatus(msg: ServerMessage) {
370
+ var m = msg as { type: string; dailySpend: number; dailyLimit: number; enforcement: "warning" | "soft-block" | "hard-block" };
371
+ setBudgetStatus({ dailySpend: m.dailySpend, dailyLimit: m.dailyLimit, enforcement: m.enforcement });
372
+ }
373
+
374
+ function handleBudgetExceeded(msg: ServerMessage) {
375
+ setBudgetExceeded(true);
376
+ setIsProcessing(false);
377
+ setCurrentStatus(null);
378
+ }
379
+
361
380
  subscribe("chat:user_message", handleUserMessage);
362
381
  subscribe("chat:delta", handleDelta);
363
382
  subscribe("chat:tool_start", handleToolStart);
@@ -376,6 +395,8 @@ export function useSession(): UseSessionReturn {
376
395
  subscribe("chat:prompt_resolved", handlePromptResolved);
377
396
  subscribe("chat:todo_update", handleTodoUpdate);
378
397
  subscribe("chat:plan_mode", handlePlanMode);
398
+ subscribe("budget:status", handleBudgetStatus);
399
+ subscribe("budget:exceeded", handleBudgetExceeded);
379
400
 
380
401
  return function () {
381
402
  subscriptionsActive--;
@@ -397,6 +418,8 @@ export function useSession(): UseSessionReturn {
397
418
  unsubscribe("chat:prompt_resolved", handlePromptResolved);
398
419
  unsubscribe("chat:todo_update", handleTodoUpdate);
399
420
  unsubscribe("chat:plan_mode", handlePlanMode);
421
+ unsubscribe("budget:status", handleBudgetStatus);
422
+ unsubscribe("budget:exceeded", handleBudgetExceeded);
400
423
  };
401
424
  }, [subscribe, unsubscribe]);
402
425
 
@@ -424,9 +447,23 @@ export function useSession(): UseSessionReturn {
424
447
  isBusy: state.isBusy,
425
448
  isPlanMode: state.isPlanMode,
426
449
  pendingPrefill: state.pendingPrefill,
450
+ budgetStatus: state.budgetStatus,
451
+ budgetExceeded: state.budgetExceeded,
427
452
  enqueueMessage,
428
453
  removeQueuedMessage,
429
454
  updateQueuedMessage,
430
455
  clearMessageQueue,
456
+ sendBudgetOverride: function () {
457
+ setBudgetExceeded(false);
458
+ setIsProcessing(true);
459
+ sendRef.current({ type: "budget:override" } as never);
460
+ },
461
+ dismissBudgetExceeded: function () {
462
+ setBudgetExceeded(false);
463
+ if (lastSentText) {
464
+ setFailedInput(lastSentText);
465
+ lastSentText = null;
466
+ }
467
+ },
431
468
  };
432
469
  }
@@ -0,0 +1,35 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ var globalTick = 0;
4
+ var listeners = new Set<() => void>();
5
+ var interval: ReturnType<typeof setInterval> | null = null;
6
+
7
+ function startTicker() {
8
+ if (interval) return;
9
+ interval = setInterval(function () {
10
+ globalTick++;
11
+ listeners.forEach(function (cb) { cb(); });
12
+ }, 60000);
13
+ }
14
+
15
+ function stopTicker() {
16
+ if (listeners.size > 0) return;
17
+ if (interval) {
18
+ clearInterval(interval);
19
+ interval = null;
20
+ }
21
+ }
22
+
23
+ export function useTimeTick(): number {
24
+ var [tick, setTick] = useState(globalTick);
25
+ useEffect(function () {
26
+ function update() { setTick(globalTick); }
27
+ listeners.add(update);
28
+ startTicker();
29
+ return function () {
30
+ listeners.delete(update);
31
+ stopTicker();
32
+ };
33
+ }, []);
34
+ return tick;
35
+ }
@@ -1,5 +1,19 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
 
3
+ interface SpeechRecognitionLike extends EventTarget {
4
+ continuous: boolean;
5
+ interimResults: boolean;
6
+ lang: string;
7
+ start(): void;
8
+ stop(): void;
9
+ abort(): void;
10
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
11
+ onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
12
+ onend: (() => void) | null;
13
+ onspeechstart: (() => void) | null;
14
+ onspeechend: (() => void) | null;
15
+ }
16
+
3
17
  export interface UseVoiceRecorderReturn {
4
18
  isRecording: boolean;
5
19
  isSupported: boolean;
@@ -17,14 +31,14 @@ export function useVoiceRecorder(): UseVoiceRecorderReturn {
17
31
  var [elapsed, setElapsed] = useState(0);
18
32
  var [interimTranscript, setInterimTranscript] = useState("");
19
33
 
20
- var recognitionRef = useRef<SpeechRecognition | null>(null);
34
+ var recognitionRef = useRef<SpeechRecognitionLike | null>(null);
21
35
  var timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
22
36
  var finalTranscriptRef = useRef("");
23
37
  var startTimeRef = useRef(0);
24
38
 
25
39
  var SpeechRecognitionClass = typeof window !== "undefined"
26
- ? (window as unknown as { SpeechRecognition?: typeof SpeechRecognition; webkitSpeechRecognition?: typeof SpeechRecognition }).SpeechRecognition
27
- || (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition
40
+ ? (window as unknown as { SpeechRecognition?: new () => SpeechRecognitionLike; webkitSpeechRecognition?: new () => SpeechRecognitionLike }).SpeechRecognition
41
+ || (window as unknown as { webkitSpeechRecognition?: new () => SpeechRecognitionLike }).webkitSpeechRecognition
28
42
  : undefined;
29
43
 
30
44
  var isSupported = !!SpeechRecognitionClass;