@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.
Files changed (52) hide show
  1. package/client/src/components/analytics/ChartCard.tsx +3 -0
  2. package/client/src/components/analytics/QuickStats.tsx +5 -3
  3. package/client/src/components/analytics/chartTokens.ts +182 -0
  4. package/client/src/components/analytics/charts/ActivityCalendar.tsx +3 -1
  5. package/client/src/components/analytics/charts/CacheEfficiencyChart.tsx +8 -14
  6. package/client/src/components/analytics/charts/ContextUtilizationChart.tsx +6 -20
  7. package/client/src/components/analytics/charts/CostAreaChart.tsx +17 -23
  8. package/client/src/components/analytics/charts/CostDistributionChart.tsx +8 -14
  9. package/client/src/components/analytics/charts/CostDonutChart.tsx +5 -17
  10. package/client/src/components/analytics/charts/CumulativeCostChart.tsx +8 -14
  11. package/client/src/components/analytics/charts/DailySummaryCards.tsx +9 -7
  12. package/client/src/components/analytics/charts/HourlyHeatmap.tsx +3 -2
  13. package/client/src/components/analytics/charts/PermissionBreakdown.tsx +5 -8
  14. package/client/src/components/analytics/charts/ProjectRadar.tsx +6 -6
  15. package/client/src/components/analytics/charts/ResponseTimeScatter.tsx +7 -20
  16. package/client/src/components/analytics/charts/SessionBubbleChart.tsx +7 -24
  17. package/client/src/components/analytics/charts/SessionComplexityList.tsx +2 -7
  18. package/client/src/components/analytics/charts/SessionTimeline.tsx +3 -11
  19. package/client/src/components/analytics/charts/TokenFlowChart.tsx +14 -20
  20. package/client/src/components/analytics/charts/TokenSankeyChart.tsx +16 -14
  21. package/client/src/components/analytics/charts/ToolSunburst.tsx +3 -9
  22. package/client/src/components/analytics/charts/ToolTreemap.tsx +6 -6
  23. package/client/src/components/chat/ChatInput.tsx +55 -1
  24. package/client/src/components/chat/ChatView.tsx +58 -37
  25. package/client/src/components/chat/Message.tsx +170 -17
  26. package/client/src/components/chat/ToolGroup.tsx +1 -1
  27. package/client/src/components/dashboard/DashboardView.tsx +30 -6
  28. package/client/src/components/project-settings/ProjectMemory.tsx +18 -2
  29. package/client/src/components/project-settings/ProjectSettingsView.tsx +2 -2
  30. package/client/src/components/settings/SettingsView.tsx +2 -2
  31. package/client/src/components/settings/skill-shared.tsx +10 -2
  32. package/client/src/components/sidebar/AddProjectModal.tsx +10 -1
  33. package/client/src/components/sidebar/NodeSettingsModal.tsx +10 -1
  34. package/client/src/components/sidebar/ProjectRail.tsx +9 -1
  35. package/client/src/components/sidebar/SessionList.tsx +205 -20
  36. package/client/src/components/sidebar/Sidebar.tsx +2 -2
  37. package/client/src/components/sidebar/UserMenu.tsx +5 -1
  38. package/client/src/components/ui/IconPicker.tsx +2 -2
  39. package/client/src/components/ui/PopupMenu.tsx +25 -5
  40. package/client/src/components/ui/Toast.tsx +1 -1
  41. package/client/src/components/workspace/TaskEditModal.tsx +16 -6
  42. package/client/src/hooks/useSession.ts +1 -0
  43. package/client/src/hooks/useSwipeDrawer.ts +28 -4
  44. package/client/src/hooks/useWebSocket.ts +3 -0
  45. package/client/src/stores/session.ts +10 -0
  46. package/client/src/styles/global.css +62 -2
  47. package/client/src/utils/formatSessionTitle.ts +17 -0
  48. package/package.json +1 -1
  49. package/server/src/handlers/session.ts +19 -1
  50. package/server/src/project/session.ts +83 -1
  51. package/shared/src/messages.ts +21 -2
  52. 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
- setContextMenu({ visible: true, x: e.clientX, y: e.clientY, slug: slug });
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 type { SessionSummary, SessionListMessage, SessionCreatedMessage } from "@lattice/shared";
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 sorted = listMsg.sessions.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; });
115
- var hadChanges = false;
116
- for (var i = 0; i < sorted.length; i++) {
117
- var s = sorted[i];
118
- var prev = knownUpdatedAt.get(s.id);
119
- if (prev !== undefined && s.updatedAt > prev && s.id !== activeSessionIdRef.current) {
120
- markSessionHasUpdates(s.id);
121
- hadChanges = true;
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
- sendRef.current({ type: "session:list_request", projectSlug: props.projectSlug });
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
- <span className="text-xs font-bold tracking-wider uppercase text-base-content/40">
25
+ <h2 className="text-xs font-bold tracking-wider uppercase text-base-content/40">
26
26
  {label}
27
- </span>
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
- style.left = rect.left + "px";
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
- style.left = rect.left + "px";
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
- style.top = rect.bottom + 4 + "px";
60
- style.left = rect.left + "px";
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
- style.top = rect.top + "px";
63
- style.left = rect.right + 4 + "px";
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
- <span className="text-[14px] font-semibold text-base-content">
52
+ <h2 className="text-[14px] font-semibold text-base-content">
49
53
  {task ? "Edit Task" : "New Scheduled Task"}
50
- </span>
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
- // After the CSS transition finishes, remove inline styles
128
- // so the checkbox-driven DaisyUI styles take over again
129
- setTimeout(function () {
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
- }, 280);
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
  }