@cryptiklemur/lattice 1.12.0 → 1.14.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/client/src/components/analytics/ChartCard.tsx +3 -0
- package/client/src/components/analytics/QuickStats.tsx +5 -3
- package/client/src/components/analytics/chartTokens.ts +182 -0
- package/client/src/components/analytics/charts/ActivityCalendar.tsx +3 -1
- package/client/src/components/analytics/charts/CacheEfficiencyChart.tsx +8 -14
- package/client/src/components/analytics/charts/ContextUtilizationChart.tsx +6 -20
- package/client/src/components/analytics/charts/CostAreaChart.tsx +17 -23
- package/client/src/components/analytics/charts/CostDistributionChart.tsx +8 -14
- package/client/src/components/analytics/charts/CostDonutChart.tsx +5 -17
- package/client/src/components/analytics/charts/CumulativeCostChart.tsx +8 -14
- package/client/src/components/analytics/charts/DailySummaryCards.tsx +9 -7
- package/client/src/components/analytics/charts/HourlyHeatmap.tsx +3 -2
- package/client/src/components/analytics/charts/PermissionBreakdown.tsx +5 -8
- package/client/src/components/analytics/charts/ProjectRadar.tsx +6 -6
- package/client/src/components/analytics/charts/ResponseTimeScatter.tsx +7 -20
- package/client/src/components/analytics/charts/SessionBubbleChart.tsx +7 -24
- package/client/src/components/analytics/charts/SessionComplexityList.tsx +2 -7
- package/client/src/components/analytics/charts/SessionTimeline.tsx +3 -11
- package/client/src/components/analytics/charts/TokenFlowChart.tsx +14 -20
- package/client/src/components/analytics/charts/TokenSankeyChart.tsx +16 -14
- package/client/src/components/analytics/charts/ToolSunburst.tsx +3 -9
- package/client/src/components/analytics/charts/ToolTreemap.tsx +6 -6
- package/client/src/components/chat/ChatInput.tsx +55 -1
- package/client/src/components/chat/ChatView.tsx +58 -37
- package/client/src/components/chat/Message.tsx +170 -17
- package/client/src/components/chat/ToolGroup.tsx +1 -1
- package/client/src/components/dashboard/DashboardView.tsx +30 -6
- package/client/src/components/project-settings/ProjectMemory.tsx +18 -2
- package/client/src/components/project-settings/ProjectSettingsView.tsx +2 -2
- package/client/src/components/settings/SettingsView.tsx +2 -2
- package/client/src/components/settings/skill-shared.tsx +10 -2
- package/client/src/components/sidebar/AddProjectModal.tsx +10 -1
- package/client/src/components/sidebar/NodeSettingsModal.tsx +10 -1
- package/client/src/components/sidebar/ProjectRail.tsx +9 -1
- package/client/src/components/sidebar/SessionList.tsx +205 -20
- package/client/src/components/sidebar/Sidebar.tsx +2 -2
- package/client/src/components/sidebar/UserMenu.tsx +5 -1
- package/client/src/components/ui/IconPicker.tsx +2 -2
- package/client/src/components/ui/PopupMenu.tsx +25 -5
- package/client/src/components/ui/Toast.tsx +1 -1
- package/client/src/components/workspace/TaskEditModal.tsx +16 -6
- package/client/src/hooks/useSession.ts +1 -0
- package/client/src/hooks/useSwipeDrawer.ts +28 -4
- package/client/src/hooks/useWebSocket.ts +3 -0
- package/client/src/stores/session.ts +10 -0
- package/client/src/styles/global.css +62 -2
- package/client/src/utils/formatSessionTitle.ts +17 -0
- package/package.json +1 -1
- package/server/src/handlers/session.ts +19 -1
- package/server/src/project/session.ts +83 -1
- package/shared/src/messages.ts +21 -2
- package/shared/src/models.ts +9 -0
|
@@ -273,13 +273,22 @@ export function AddProjectModal({ isOpen, onClose }: AddProjectModalProps) {
|
|
|
273
273
|
} as any);
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
useEffect(function () {
|
|
277
|
+
if (!isOpen) return;
|
|
278
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
279
|
+
if (e.key === "Escape" && !dropdownOpen) onClose();
|
|
280
|
+
}
|
|
281
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
282
|
+
return function () { document.removeEventListener("keydown", handleKeyDown); };
|
|
283
|
+
}, [isOpen, dropdownOpen, onClose]);
|
|
284
|
+
|
|
276
285
|
if (!isOpen) return null;
|
|
277
286
|
|
|
278
287
|
var filtered = getFilteredEntries();
|
|
279
288
|
var validation = getValidationMessage();
|
|
280
289
|
|
|
281
290
|
return (
|
|
282
|
-
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
|
291
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Add Project">
|
|
283
292
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
284
293
|
<div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-3xl mx-4 overflow-hidden">
|
|
285
294
|
<div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
|
|
@@ -79,12 +79,21 @@ export function NodeSettingsModal({ isOpen, onClose }: NodeSettingsModalProps) {
|
|
|
79
79
|
copyTimeout.current = setTimeout(function () { setCopied(false); }, 2000);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
useEffect(function () {
|
|
83
|
+
if (!isOpen) return;
|
|
84
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
85
|
+
if (e.key === "Escape") onClose();
|
|
86
|
+
}
|
|
87
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
88
|
+
return function () { document.removeEventListener("keydown", handleKeyDown); };
|
|
89
|
+
}, [isOpen, onClose]);
|
|
90
|
+
|
|
82
91
|
if (!isOpen) return null;
|
|
83
92
|
|
|
84
93
|
var inputClass = "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]";
|
|
85
94
|
|
|
86
95
|
return (
|
|
87
|
-
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
|
96
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Node Settings">
|
|
88
97
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
89
98
|
<div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
|
|
90
99
|
<div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
|
|
@@ -180,7 +180,15 @@ export function ProjectRail(props: ProjectRailProps) {
|
|
|
180
180
|
);
|
|
181
181
|
|
|
182
182
|
function handleContextMenu(e: React.MouseEvent, slug: string) {
|
|
183
|
-
|
|
183
|
+
var menuWidth = 160;
|
|
184
|
+
var menuHeight = 100;
|
|
185
|
+
var cx = e.clientX;
|
|
186
|
+
var cy = e.clientY;
|
|
187
|
+
if (cx + menuWidth > window.innerWidth - 8) cx = window.innerWidth - menuWidth - 8;
|
|
188
|
+
if (cy + menuHeight > window.innerHeight - 8) cy = window.innerHeight - menuHeight - 8;
|
|
189
|
+
if (cx < 8) cx = 8;
|
|
190
|
+
if (cy < 8) cy = 8;
|
|
191
|
+
setContextMenu({ visible: true, x: cx, y: cy, slug: slug });
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
return (
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, useMemo } from "react";
|
|
2
|
-
import
|
|
1
|
+
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Clock, MessageSquare, Cpu, DollarSign } from "lucide-react";
|
|
4
|
+
import type { SessionSummary, SessionPreview, SessionListMessage, SessionCreatedMessage, SessionPreviewMessage } from "@lattice/shared";
|
|
3
5
|
import type { ServerMessage } from "@lattice/shared";
|
|
4
6
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
5
7
|
import { markSessionHasUpdates, sessionHasUpdates, markSessionRead } from "../../stores/session";
|
|
8
|
+
import { formatSessionTitle } from "../../utils/formatSessionTitle";
|
|
9
|
+
|
|
10
|
+
var PAGE_SIZE = 40;
|
|
6
11
|
|
|
7
12
|
interface SessionGroup {
|
|
8
13
|
label: string;
|
|
@@ -83,6 +88,19 @@ function formatDate(ts: number): string {
|
|
|
83
88
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
function formatDuration(ms: number): string {
|
|
92
|
+
if (ms < 60000) return Math.round(ms / 1000) + "s";
|
|
93
|
+
if (ms < 3600000) return Math.round(ms / 60000) + "m";
|
|
94
|
+
var hours = Math.floor(ms / 3600000);
|
|
95
|
+
var mins = Math.round((ms % 3600000) / 60000);
|
|
96
|
+
return hours + "h " + mins + "m";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatCost(cost: number): string {
|
|
100
|
+
if (cost < 0.01) return "<$0.01";
|
|
101
|
+
return "$" + cost.toFixed(2);
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
function SessionSkeleton() {
|
|
87
105
|
return (
|
|
88
106
|
<div className="flex flex-col gap-2 px-4 py-3">
|
|
@@ -93,17 +111,84 @@ function SessionSkeleton() {
|
|
|
93
111
|
);
|
|
94
112
|
}
|
|
95
113
|
|
|
114
|
+
function PreviewPopover(props: { preview: SessionPreview | null; anchorRect: DOMRect | null }) {
|
|
115
|
+
if (!props.anchorRect) return null;
|
|
116
|
+
|
|
117
|
+
var top = props.anchorRect.top;
|
|
118
|
+
var left = props.anchorRect.right + 8;
|
|
119
|
+
|
|
120
|
+
var fitsRight = left + 280 < window.innerWidth;
|
|
121
|
+
if (!fitsRight) {
|
|
122
|
+
left = props.anchorRect.left - 288;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
var fitsBelow = top + 160 < window.innerHeight;
|
|
126
|
+
if (!fitsBelow) {
|
|
127
|
+
top = Math.max(8, props.anchorRect.bottom - 160);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
var content = (
|
|
131
|
+
<div
|
|
132
|
+
className="fixed z-[9999] w-[270px] bg-base-300 border border-base-content/15 rounded-lg shadow-xl p-3 pointer-events-none"
|
|
133
|
+
style={{ top, left }}
|
|
134
|
+
>
|
|
135
|
+
{props.preview ? (
|
|
136
|
+
<>
|
|
137
|
+
<div className="text-[11px] text-base-content/50 leading-relaxed line-clamp-2 mb-2.5 italic">
|
|
138
|
+
{props.preview.lastMessage || "No messages"}
|
|
139
|
+
</div>
|
|
140
|
+
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
|
141
|
+
<div className="flex items-center gap-1.5">
|
|
142
|
+
<DollarSign className="!size-3 text-base-content/30 shrink-0" />
|
|
143
|
+
<span className="text-[11px] text-base-content/60">{formatCost(props.preview.cost)}</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex items-center gap-1.5">
|
|
146
|
+
<Clock className="!size-3 text-base-content/30 shrink-0" />
|
|
147
|
+
<span className="text-[11px] text-base-content/60">{formatDuration(props.preview.durationMs)}</span>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="flex items-center gap-1.5">
|
|
150
|
+
<MessageSquare className="!size-3 text-base-content/30 shrink-0" />
|
|
151
|
+
<span className="text-[11px] text-base-content/60">{props.preview.messageCount} msgs</span>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="flex items-center gap-1.5">
|
|
154
|
+
<Cpu className="!size-3 text-base-content/30 shrink-0" />
|
|
155
|
+
<span className="text-[11px] text-base-content/60">{props.preview.model || "unknown"}</span>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</>
|
|
159
|
+
) : (
|
|
160
|
+
<div className="flex items-center gap-2">
|
|
161
|
+
<span className="w-3 h-3 border-2 border-base-content/20 border-t-primary/50 rounded-full animate-spin" />
|
|
162
|
+
<span className="text-[11px] text-base-content/40">Loading...</span>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return createPortal(content, document.body);
|
|
169
|
+
}
|
|
170
|
+
|
|
96
171
|
export function SessionList(props: SessionListProps) {
|
|
97
172
|
var ws = useWebSocket();
|
|
98
173
|
var [sessions, setSessions] = useState<SessionSummary[]>([]);
|
|
99
174
|
var [loading, setLoading] = useState<boolean>(false);
|
|
175
|
+
var [loadingMore, setLoadingMore] = useState<boolean>(false);
|
|
176
|
+
var [totalCount, setTotalCount] = useState<number>(0);
|
|
100
177
|
var [renameId, setRenameId] = useState<string | null>(null);
|
|
101
178
|
var [renameValue, setRenameValue] = useState<string>("");
|
|
102
179
|
var [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
|
|
103
180
|
var [unreadTick, setUnreadTick] = useState<number>(0);
|
|
181
|
+
var [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
182
|
+
var [hoveredRect, setHoveredRect] = useState<DOMRect | null>(null);
|
|
183
|
+
var [previews, setPreviews] = useState<Map<string, SessionPreview>>(new Map());
|
|
104
184
|
var renameInputRef = useRef<HTMLInputElement | null>(null);
|
|
105
185
|
var handleRef = useRef<(msg: ServerMessage) => void>(function () {});
|
|
106
186
|
var activeSessionIdRef = useRef<string | null>(props.activeSessionId);
|
|
187
|
+
var hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
188
|
+
var sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
189
|
+
var scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
190
|
+
var offsetRef = useRef<number>(0);
|
|
191
|
+
var hasMoreRef = useRef<boolean>(true);
|
|
107
192
|
activeSessionIdRef.current = props.activeSessionId;
|
|
108
193
|
|
|
109
194
|
useEffect(function () {
|
|
@@ -111,22 +196,43 @@ export function SessionList(props: SessionListProps) {
|
|
|
111
196
|
if (msg.type === "session:list") {
|
|
112
197
|
var listMsg = msg as SessionListMessage;
|
|
113
198
|
if (listMsg.projectSlug === props.projectSlug) {
|
|
114
|
-
var
|
|
115
|
-
var
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
199
|
+
var incoming = listMsg.sessions.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; });
|
|
200
|
+
var listOffset = listMsg.offset || 0;
|
|
201
|
+
var listTotal = listMsg.totalCount || incoming.length;
|
|
202
|
+
|
|
203
|
+
if (listOffset > 0) {
|
|
204
|
+
setSessions(function (prev) {
|
|
205
|
+
var existingIds = new Set(prev.map(function (s) { return s.id; }));
|
|
206
|
+
var newSessions = incoming.filter(function (s) { return !existingIds.has(s.id); });
|
|
207
|
+
return prev.concat(newSessions);
|
|
208
|
+
});
|
|
209
|
+
setLoadingMore(false);
|
|
210
|
+
} else {
|
|
211
|
+
var hadChanges = false;
|
|
212
|
+
for (var i = 0; i < incoming.length; i++) {
|
|
213
|
+
var s = incoming[i];
|
|
214
|
+
var prev = knownUpdatedAt.get(s.id);
|
|
215
|
+
if (prev !== undefined && s.updatedAt > prev && s.id !== activeSessionIdRef.current) {
|
|
216
|
+
markSessionHasUpdates(s.id);
|
|
217
|
+
hadChanges = true;
|
|
218
|
+
}
|
|
219
|
+
knownUpdatedAt.set(s.id, s.updatedAt);
|
|
220
|
+
}
|
|
221
|
+
setSessions(function (existing) {
|
|
222
|
+
if (existing.length <= PAGE_SIZE) return incoming;
|
|
223
|
+
var incomingIds = new Set(incoming.map(function (s) { return s.id; }));
|
|
224
|
+
var kept = existing.filter(function (s) { return !incomingIds.has(s.id); });
|
|
225
|
+
return incoming.concat(kept).sort(function (a, b) { return b.updatedAt - a.updatedAt; });
|
|
226
|
+
});
|
|
227
|
+
setLoading(false);
|
|
228
|
+
if (hadChanges) {
|
|
229
|
+
setUnreadTick(function (t) { return t + 1; });
|
|
122
230
|
}
|
|
123
|
-
knownUpdatedAt.set(s.id, s.updatedAt);
|
|
124
|
-
}
|
|
125
|
-
setSessions(sorted);
|
|
126
|
-
setLoading(false);
|
|
127
|
-
if (hadChanges) {
|
|
128
|
-
setUnreadTick(function (t) { return t + 1; });
|
|
129
231
|
}
|
|
232
|
+
|
|
233
|
+
setTotalCount(listTotal);
|
|
234
|
+
offsetRef.current = listOffset + incoming.length;
|
|
235
|
+
hasMoreRef.current = listOffset + incoming.length < listTotal;
|
|
130
236
|
}
|
|
131
237
|
} else if (msg.type === "session:created") {
|
|
132
238
|
var createdMsg = msg as SessionCreatedMessage;
|
|
@@ -135,8 +241,16 @@ export function SessionList(props: SessionListProps) {
|
|
|
135
241
|
setSessions(function (prev2) {
|
|
136
242
|
return [createdMsg.session, ...prev2];
|
|
137
243
|
});
|
|
244
|
+
setTotalCount(function (t) { return t + 1; });
|
|
138
245
|
props.onSessionActivate(createdMsg.session);
|
|
139
246
|
}
|
|
247
|
+
} else if (msg.type === "session:preview") {
|
|
248
|
+
var previewMsg = msg as SessionPreviewMessage;
|
|
249
|
+
setPreviews(function (prev3) {
|
|
250
|
+
var next = new Map(prev3);
|
|
251
|
+
next.set(previewMsg.sessionId, previewMsg.preview);
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
140
254
|
}
|
|
141
255
|
};
|
|
142
256
|
});
|
|
@@ -147,9 +261,11 @@ export function SessionList(props: SessionListProps) {
|
|
|
147
261
|
}
|
|
148
262
|
ws.subscribe("session:list", handler);
|
|
149
263
|
ws.subscribe("session:created", handler);
|
|
264
|
+
ws.subscribe("session:preview", handler);
|
|
150
265
|
return function () {
|
|
151
266
|
ws.unsubscribe("session:list", handler);
|
|
152
267
|
ws.unsubscribe("session:created", handler);
|
|
268
|
+
ws.unsubscribe("session:preview", handler);
|
|
153
269
|
};
|
|
154
270
|
}, [ws]);
|
|
155
271
|
|
|
@@ -160,16 +276,43 @@ export function SessionList(props: SessionListProps) {
|
|
|
160
276
|
if (props.projectSlug && ws.status === "connected") {
|
|
161
277
|
setSessions([]);
|
|
162
278
|
setLoading(true);
|
|
163
|
-
|
|
279
|
+
offsetRef.current = 0;
|
|
280
|
+
hasMoreRef.current = true;
|
|
281
|
+
sendRef.current({ type: "session:list_request", projectSlug: props.projectSlug, offset: 0, limit: PAGE_SIZE });
|
|
164
282
|
var interval = setInterval(function () {
|
|
165
283
|
if (props.projectSlug) {
|
|
166
|
-
sendRef.current({ type: "session:list_request", projectSlug: props.projectSlug });
|
|
284
|
+
sendRef.current({ type: "session:list_request", projectSlug: props.projectSlug, offset: 0, limit: PAGE_SIZE });
|
|
167
285
|
}
|
|
168
286
|
}, 10000);
|
|
169
287
|
return function () { clearInterval(interval); };
|
|
170
288
|
}
|
|
171
289
|
}, [props.projectSlug, ws.status]);
|
|
172
290
|
|
|
291
|
+
var loadMore = useCallback(function () {
|
|
292
|
+
if (!props.projectSlug || loadingMore || !hasMoreRef.current) return;
|
|
293
|
+
setLoadingMore(true);
|
|
294
|
+
sendRef.current({
|
|
295
|
+
type: "session:list_request",
|
|
296
|
+
projectSlug: props.projectSlug,
|
|
297
|
+
offset: offsetRef.current,
|
|
298
|
+
limit: PAGE_SIZE,
|
|
299
|
+
});
|
|
300
|
+
}, [props.projectSlug, loadingMore]);
|
|
301
|
+
|
|
302
|
+
useEffect(function () {
|
|
303
|
+
var sentinel = sentinelRef.current;
|
|
304
|
+
if (!sentinel) return;
|
|
305
|
+
|
|
306
|
+
var observer = new IntersectionObserver(function (entries) {
|
|
307
|
+
if (entries[0].isIntersecting) {
|
|
308
|
+
loadMore();
|
|
309
|
+
}
|
|
310
|
+
}, { root: scrollContainerRef.current, rootMargin: "100px" });
|
|
311
|
+
|
|
312
|
+
observer.observe(sentinel);
|
|
313
|
+
return function () { observer.disconnect(); };
|
|
314
|
+
}, [loadMore]);
|
|
315
|
+
|
|
173
316
|
useEffect(function () {
|
|
174
317
|
if (renameId && renameInputRef.current) {
|
|
175
318
|
renameInputRef.current.focus();
|
|
@@ -198,6 +341,7 @@ export function SessionList(props: SessionListProps) {
|
|
|
198
341
|
markSessionRead(session.id, 0);
|
|
199
342
|
setUnreadTick(function (t) { return t + 1; });
|
|
200
343
|
}
|
|
344
|
+
setHoveredId(null);
|
|
201
345
|
props.onSessionActivate(session);
|
|
202
346
|
}
|
|
203
347
|
|
|
@@ -246,11 +390,33 @@ export function SessionList(props: SessionListProps) {
|
|
|
246
390
|
setSessions(function (prev) {
|
|
247
391
|
return prev.filter(function (s) { return s.id !== session.id; });
|
|
248
392
|
});
|
|
393
|
+
setTotalCount(function (t) { return Math.max(0, t - 1); });
|
|
249
394
|
if (props.activeSessionId === session.id && props.onSessionDeactivate) {
|
|
250
395
|
props.onSessionDeactivate();
|
|
251
396
|
}
|
|
252
397
|
}
|
|
253
398
|
|
|
399
|
+
function handleMouseEnter(session: SessionSummary, e: React.PointerEvent | React.MouseEvent) {
|
|
400
|
+
var rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
401
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
402
|
+
hoverTimerRef.current = setTimeout(function () {
|
|
403
|
+
setHoveredId(session.id);
|
|
404
|
+
setHoveredRect(rect);
|
|
405
|
+
if (!previews.has(session.id) && props.projectSlug) {
|
|
406
|
+
ws.send({ type: "session:preview_request", projectSlug: props.projectSlug, sessionId: session.id });
|
|
407
|
+
}
|
|
408
|
+
}, 300);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function handleMouseLeave() {
|
|
412
|
+
if (hoverTimerRef.current) {
|
|
413
|
+
clearTimeout(hoverTimerRef.current);
|
|
414
|
+
hoverTimerRef.current = null;
|
|
415
|
+
}
|
|
416
|
+
setHoveredId(null);
|
|
417
|
+
setHoveredRect(null);
|
|
418
|
+
}
|
|
419
|
+
|
|
254
420
|
var grouped = useMemo(function () {
|
|
255
421
|
var displayed = props.filter
|
|
256
422
|
? sessions.filter(function (s) {
|
|
@@ -278,9 +444,11 @@ export function SessionList(props: SessionListProps) {
|
|
|
278
444
|
);
|
|
279
445
|
}
|
|
280
446
|
|
|
447
|
+
var activePreview = hoveredId ? (previews.get(hoveredId) || null) : null;
|
|
448
|
+
|
|
281
449
|
return (
|
|
282
450
|
<div className="flex flex-col flex-1 overflow-hidden min-h-0">
|
|
283
|
-
<div className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-hidden py-0.5 pb-16">
|
|
451
|
+
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-hidden py-0.5 pb-16">
|
|
284
452
|
{grouped.length === 0 ? (
|
|
285
453
|
<div className="px-3 py-2 text-sm text-base-content/40 italic">
|
|
286
454
|
{props.filter ? "No matches" : "No sessions yet"}
|
|
@@ -305,6 +473,8 @@ export function SessionList(props: SessionListProps) {
|
|
|
305
473
|
aria-current={isActive ? "true" : undefined}
|
|
306
474
|
onClick={function () { handleActivate(session); }}
|
|
307
475
|
onContextMenu={function (e) { handleContextMenu(e, session); }}
|
|
476
|
+
onPointerEnter={function (e) { handleMouseEnter(session, e); }}
|
|
477
|
+
onPointerLeave={handleMouseLeave}
|
|
308
478
|
className={
|
|
309
479
|
"flex flex-row items-start gap-2 px-2.5 py-[5px] mx-1 rounded w-[calc(100%-8px)] min-w-0 overflow-hidden cursor-pointer select-none transition-colors duration-[120ms] text-left focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:outline-none " +
|
|
310
480
|
(isActive ? "bg-primary/20 text-base-content font-medium" : "hover:bg-base-300/50")
|
|
@@ -331,7 +501,7 @@ export function SessionList(props: SessionListProps) {
|
|
|
331
501
|
(isActive ? "" : isUnread ? "text-base-content font-semibold" : "text-base-content/55")
|
|
332
502
|
}
|
|
333
503
|
>
|
|
334
|
-
{session.title}
|
|
504
|
+
{formatSessionTitle(session.title)}
|
|
335
505
|
</span>
|
|
336
506
|
)}
|
|
337
507
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
@@ -356,8 +526,23 @@ export function SessionList(props: SessionListProps) {
|
|
|
356
526
|
);
|
|
357
527
|
})
|
|
358
528
|
)}
|
|
529
|
+
|
|
530
|
+
{hasMoreRef.current && (
|
|
531
|
+
<div ref={sentinelRef} className="py-3 flex justify-center">
|
|
532
|
+
{loadingMore && (
|
|
533
|
+
<div className="flex items-center gap-2 text-[11px] text-base-content/30">
|
|
534
|
+
<span className="w-3 h-3 border-2 border-base-content/20 border-t-primary/50 rounded-full animate-spin" />
|
|
535
|
+
Loading...
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
359
540
|
</div>
|
|
360
541
|
|
|
542
|
+
{hoveredId && (
|
|
543
|
+
<PreviewPopover preview={activePreview} anchorRect={hoveredRect} />
|
|
544
|
+
)}
|
|
545
|
+
|
|
361
546
|
{contextMenu !== null && (
|
|
362
547
|
<div
|
|
363
548
|
role="menu"
|
|
@@ -22,9 +22,9 @@ import { SettingsSidebar } from "./SettingsSidebar";
|
|
|
22
22
|
function SectionLabel({ label, actions }: { label: string; actions?: React.ReactNode }) {
|
|
23
23
|
return (
|
|
24
24
|
<div className="px-4 pt-4 pb-2 flex items-center justify-between flex-shrink-0 select-none">
|
|
25
|
-
<
|
|
25
|
+
<h2 className="text-xs font-bold tracking-wider uppercase text-base-content/40">
|
|
26
26
|
{label}
|
|
27
|
-
</
|
|
27
|
+
</h2>
|
|
28
28
|
{actions && (
|
|
29
29
|
<div className="flex items-center gap-0.5">
|
|
30
30
|
{actions}
|
|
@@ -47,7 +47,11 @@ export function UserMenu(props: UserMenuProps) {
|
|
|
47
47
|
if (props.anchorRef.current) {
|
|
48
48
|
var rect = props.anchorRef.current.getBoundingClientRect();
|
|
49
49
|
style.bottom = window.innerHeight - rect.top + 4 + "px";
|
|
50
|
-
|
|
50
|
+
var leftPos = rect.left;
|
|
51
|
+
var menuW = 180;
|
|
52
|
+
if (leftPos + menuW > window.innerWidth - 8) leftPos = window.innerWidth - menuW - 8;
|
|
53
|
+
if (leftPos < 8) leftPos = 8;
|
|
54
|
+
style.left = leftPos + "px";
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
function handleRestart() {
|
|
@@ -49,7 +49,7 @@ function renderPreview(value?: ProjectIcon) {
|
|
|
49
49
|
|
|
50
50
|
if (value.type === "image") {
|
|
51
51
|
return (
|
|
52
|
-
<img src={value.path} alt="icon" className="w-8 h-8 rounded-lg object-cover border border-base-content/15" />
|
|
52
|
+
<img src={value.path} alt="icon" className="w-8 h-8 rounded-lg object-cover border border-base-content/15" loading="lazy" />
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -200,7 +200,7 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|
|
200
200
|
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
201
|
/>
|
|
202
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" />
|
|
203
|
+
<img src={value.path} alt="preview" className="w-16 h-16 rounded-xl object-cover border border-base-content/15" loading="lazy" />
|
|
204
204
|
)}
|
|
205
205
|
</div>
|
|
206
206
|
)}
|
|
@@ -52,15 +52,35 @@ export function PopupMenu(props: PopupMenuProps) {
|
|
|
52
52
|
if (props.anchorRef.current) {
|
|
53
53
|
var rect = props.anchorRef.current.getBoundingClientRect();
|
|
54
54
|
var pos = props.position ?? "above";
|
|
55
|
+
var menuWidth = 180;
|
|
56
|
+
var menuHeight = 200;
|
|
57
|
+
|
|
55
58
|
if (pos === "above") {
|
|
56
59
|
style.bottom = window.innerHeight - rect.top + 4 + "px";
|
|
57
|
-
|
|
60
|
+
var leftAbove = rect.left;
|
|
61
|
+
if (leftAbove + menuWidth > window.innerWidth - 8) leftAbove = window.innerWidth - menuWidth - 8;
|
|
62
|
+
if (leftAbove < 8) leftAbove = 8;
|
|
63
|
+
style.left = leftAbove + "px";
|
|
58
64
|
} else if (pos === "below") {
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
var topBelow = rect.bottom + 4;
|
|
66
|
+
if (topBelow + menuHeight > window.innerHeight - 8) {
|
|
67
|
+
style.bottom = window.innerHeight - rect.top + 4 + "px";
|
|
68
|
+
} else {
|
|
69
|
+
style.top = topBelow + "px";
|
|
70
|
+
}
|
|
71
|
+
var leftBelow = rect.left;
|
|
72
|
+
if (leftBelow + menuWidth > window.innerWidth - 8) leftBelow = window.innerWidth - menuWidth - 8;
|
|
73
|
+
if (leftBelow < 8) leftBelow = 8;
|
|
74
|
+
style.left = leftBelow + "px";
|
|
61
75
|
} else if (pos === "right") {
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
var topRight = rect.top;
|
|
77
|
+
if (topRight + menuHeight > window.innerHeight - 8) topRight = window.innerHeight - menuHeight - 8;
|
|
78
|
+
if (topRight < 8) topRight = 8;
|
|
79
|
+
style.top = topRight + "px";
|
|
80
|
+
var leftRight = rect.right + 4;
|
|
81
|
+
if (leftRight + menuWidth > window.innerWidth - 8) leftRight = rect.left - menuWidth - 4;
|
|
82
|
+
if (leftRight < 8) leftRight = 8;
|
|
83
|
+
style.left = leftRight + "px";
|
|
64
84
|
}
|
|
65
85
|
}
|
|
66
86
|
|
|
@@ -36,7 +36,7 @@ export function Toast(props: ToastProps) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
return (
|
|
39
|
-
<div className="fixed top-3 right-3 z-[9999] flex flex-col gap-2 max-w-[340px]">
|
|
39
|
+
<div className="fixed top-3 right-3 z-[9999] flex flex-col gap-2 max-w-[340px]" role="status" aria-live="polite" aria-atomic="false">
|
|
40
40
|
{props.items.map(function (item) {
|
|
41
41
|
var Icon = ICON_MAP[item.type];
|
|
42
42
|
return (
|
|
@@ -42,14 +42,19 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
42
42
|
<div
|
|
43
43
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
44
44
|
onClick={handleBackdrop}
|
|
45
|
+
role="dialog"
|
|
46
|
+
aria-modal="true"
|
|
47
|
+
aria-label={task ? "Edit Task" : "New Scheduled Task"}
|
|
48
|
+
onKeyDown={function (e) { if (e.key === "Escape") onClose(); }}
|
|
45
49
|
>
|
|
46
50
|
<div className="bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4">
|
|
47
51
|
<div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
|
|
48
|
-
<
|
|
52
|
+
<h2 className="text-[14px] font-semibold text-base-content">
|
|
49
53
|
{task ? "Edit Task" : "New Scheduled Task"}
|
|
50
|
-
</
|
|
54
|
+
</h2>
|
|
51
55
|
<button
|
|
52
56
|
onClick={onClose}
|
|
57
|
+
aria-label="Close"
|
|
53
58
|
className="btn btn-ghost btn-xs btn-square text-base-content/50"
|
|
54
59
|
>
|
|
55
60
|
<X size={14} />
|
|
@@ -58,8 +63,9 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
58
63
|
|
|
59
64
|
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
|
60
65
|
<div className="space-y-1.5">
|
|
61
|
-
<label className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Name</label>
|
|
66
|
+
<label htmlFor="task-name" className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Name</label>
|
|
62
67
|
<input
|
|
68
|
+
id="task-name"
|
|
63
69
|
type="text"
|
|
64
70
|
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]"
|
|
65
71
|
placeholder="Daily standup summary"
|
|
@@ -70,8 +76,9 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
70
76
|
</div>
|
|
71
77
|
|
|
72
78
|
<div className="space-y-1.5">
|
|
73
|
-
<label className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Prompt</label>
|
|
79
|
+
<label htmlFor="task-prompt" className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Prompt</label>
|
|
74
80
|
<textarea
|
|
81
|
+
id="task-prompt"
|
|
75
82
|
className="w-full px-3 py-2.5 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] min-h-[96px] resize-y leading-relaxed focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
|
|
76
83
|
placeholder="Summarize yesterday's work and create a plan for today..."
|
|
77
84
|
value={prompt}
|
|
@@ -80,16 +87,19 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
80
87
|
</div>
|
|
81
88
|
|
|
82
89
|
<div className="space-y-1.5">
|
|
83
|
-
<label className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Cron Expression</label>
|
|
90
|
+
<label htmlFor="task-cron" className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Cron Expression</label>
|
|
84
91
|
<input
|
|
92
|
+
id="task-cron"
|
|
85
93
|
type="text"
|
|
86
94
|
className={`w-full h-9 px-3 bg-base-300 border rounded-xl text-base-content text-[13px] font-mono focus:border-primary focus-visible:outline-none transition-colors duration-[120ms] ${cron.trim() && !cronValid ? "border-error" : "border-base-content/15"}`}
|
|
87
95
|
placeholder="0 9 * * 1-5"
|
|
88
96
|
value={cron}
|
|
89
97
|
onChange={function (e) { setCron(e.target.value); }}
|
|
98
|
+
aria-invalid={cron.trim() && !cronValid ? "true" : undefined}
|
|
99
|
+
aria-describedby={cron.trim() ? "cron-preview" : undefined}
|
|
90
100
|
/>
|
|
91
101
|
{cron.trim() && (
|
|
92
|
-
<p className={`text-[11px] mt-1 ${cronValid ? "text-primary/80" : "text-error"}`}>
|
|
102
|
+
<p id="cron-preview" className={`text-[11px] mt-1 ${cronValid ? "text-primary/80" : "text-error"}`}>
|
|
93
103
|
{cronPreview}
|
|
94
104
|
</p>
|
|
95
105
|
)}
|
|
@@ -415,6 +415,7 @@ export function useSession(): UseSessionReturn {
|
|
|
415
415
|
messageQueue: state.messageQueue,
|
|
416
416
|
isBusy: state.isBusy,
|
|
417
417
|
isPlanMode: state.isPlanMode,
|
|
418
|
+
pendingPrefill: state.pendingPrefill,
|
|
418
419
|
enqueueMessage,
|
|
419
420
|
removeQueuedMessage,
|
|
420
421
|
updateQueuedMessage,
|
|
@@ -123,12 +123,35 @@ export function useSwipeDrawer(
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
var cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
127
|
+
|
|
128
|
+
function cancelPendingCleanup() {
|
|
129
|
+
if (cleanupTimer !== null) {
|
|
130
|
+
clearTimeout(cleanupTimer);
|
|
131
|
+
cleanupTimer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
126
135
|
function cleanupAfterAnimation() {
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
// Cancel any pending cleanup from a previous swipe to avoid races
|
|
137
|
+
cancelPendingCleanup();
|
|
138
|
+
|
|
139
|
+
// Listen for the transition to actually finish, with a fallback timer
|
|
140
|
+
if (panel) {
|
|
141
|
+
var handled = false;
|
|
142
|
+
function onEnd() {
|
|
143
|
+
if (handled) return;
|
|
144
|
+
handled = true;
|
|
145
|
+
if (panel) panel.removeEventListener("transitionend", onEnd);
|
|
146
|
+
cancelPendingCleanup();
|
|
147
|
+
clearDragStyles();
|
|
148
|
+
}
|
|
149
|
+
panel.addEventListener("transitionend", onEnd, { once: true });
|
|
150
|
+
// Fallback in case transitionend never fires (e.g. display:none)
|
|
151
|
+
cleanupTimer = setTimeout(onEnd, 350);
|
|
152
|
+
} else {
|
|
130
153
|
clearDragStyles();
|
|
131
|
-
}
|
|
154
|
+
}
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
function onTouchStart(e: TouchEvent) {
|
|
@@ -266,6 +289,7 @@ export function useSwipeDrawer(
|
|
|
266
289
|
document.addEventListener("touchcancel", onTouchCancel, { passive: true });
|
|
267
290
|
|
|
268
291
|
return function () {
|
|
292
|
+
cancelPendingCleanup();
|
|
269
293
|
document.removeEventListener("touchstart", onTouchStart);
|
|
270
294
|
document.removeEventListener("touchmove", onTouchMove);
|
|
271
295
|
document.removeEventListener("touchend", onTouchEnd);
|
|
@@ -21,6 +21,9 @@ export function useWebSocket(): WebSocketContextValue {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function getWebSocketUrl(): string {
|
|
24
|
+
if (import.meta.env.DEV) {
|
|
25
|
+
return "ws://" + window.location.hostname + ":7654/ws";
|
|
26
|
+
}
|
|
24
27
|
var protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
25
28
|
return protocol + "//" + window.location.host + "/ws";
|
|
26
29
|
}
|