@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
|
@@ -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 || (
|
|
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>{
|
|
173
|
+
<span className="truncate text-[12px]">{label}</span>
|
|
145
174
|
{tab.closeable && (
|
|
146
175
|
<button
|
|
147
|
-
aria-label={"Close " +
|
|
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
|
|
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<
|
|
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?:
|
|
27
|
-
|| (window as unknown as { 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;
|