@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
@@ -1,11 +1,11 @@
1
- import { useEffect, useRef, useCallback, useState } from "react";
1
+ import { useEffect, useRef, useCallback, useState, useMemo } from "react";
2
2
  import { Terminal, Info, ArrowDown, Pencil, Copy, Check, Menu, AlertTriangle, Zap, Square, X } from "lucide-react";
3
3
  import { LatticeLogomark } from "../ui/LatticeLogomark";
4
4
  import { useVirtualizer } from "@tanstack/react-virtual";
5
5
  import { useSession } from "../../hooks/useSession";
6
6
  import { useProjects } from "../../hooks/useProjects";
7
7
  import { useWebSocket } from "../../hooks/useWebSocket";
8
- import { setSessionTitle, setIsProcessing, setCurrentStatus, setWasInterrupted } from "../../stores/session";
8
+ import { setSessionTitle, setIsProcessing, setCurrentStatus, setWasInterrupted, setPendingPrefill } from "../../stores/session";
9
9
  import { openSettings, openProjectSettings } from "../../stores/sidebar";
10
10
  import { openTab } from "../../stores/workspace";
11
11
  import { builtinCommands } from "../../commands";
@@ -18,9 +18,10 @@ import { StatusBar } from "./StatusBar";
18
18
  import { useSidebar } from "../../hooks/useSidebar";
19
19
  import { useOnline } from "../../hooks/useOnline";
20
20
  import { useSpinnerVerb } from "../../hooks/useSpinnerVerb";
21
+ import { formatSessionTitle } from "../../utils/formatSessionTitle";
21
22
 
22
23
  export function ChatView() {
23
- var { messages, isProcessing, sendMessage, activeSessionId, activeSessionTitle, currentStatus, contextUsage, contextBreakdown, lastResponseCost, lastResponseDuration, historyLoading, wasInterrupted, promptSuggestion, failedInput, clearFailedInput, messageQueue, enqueueMessage, removeQueuedMessage, updateQueuedMessage, isBusy, isPlanMode } = useSession();
24
+ var { messages, isProcessing, sendMessage, activeSessionId, activeSessionTitle, currentStatus, contextUsage, contextBreakdown, lastResponseCost, lastResponseDuration, historyLoading, wasInterrupted, promptSuggestion, failedInput, clearFailedInput, messageQueue, enqueueMessage, removeQueuedMessage, updateQueuedMessage, isBusy, isPlanMode, pendingPrefill } = useSession();
24
25
  var { activeProject } = useProjects();
25
26
  var { toggleDrawer } = useSidebar();
26
27
  var online = useOnline();
@@ -37,6 +38,14 @@ export function ChatView() {
37
38
  var [showInfo, setShowInfo] = useState<boolean>(false);
38
39
  var [confirmStopExternal, setConfirmStopExternal] = useState<boolean>(false);
39
40
  var [prefillText, setPrefillText] = useState<string | null>(null);
41
+
42
+ useEffect(function () {
43
+ if (pendingPrefill && !historyLoading) {
44
+ setPrefillText(pendingPrefill);
45
+ setPendingPrefill(null);
46
+ }
47
+ }, [pendingPrefill, historyLoading]);
48
+
40
49
  var [copiedField, setCopiedField] = useState<string | null>(null);
41
50
  var [isRenaming, setIsRenaming] = useState<boolean>(false);
42
51
  var [renameValue, setRenameValue] = useState<string>("");
@@ -236,16 +245,28 @@ export function ChatView() {
236
245
  return "oklch(0.5 0.1 250)";
237
246
  }
238
247
 
239
- var contextPercent = 0;
240
- var contextFilled = 0;
241
- if (contextUsage && contextUsage.contextWindow > 0) {
242
- contextFilled = contextUsage.inputTokens + contextUsage.cacheReadTokens + contextUsage.cacheCreationTokens;
243
- contextPercent = Math.min(100, Math.round((contextFilled / contextUsage.contextWindow) * 100));
244
- }
245
-
246
- var autocompactPercent = contextBreakdown ? Math.round((contextBreakdown.autocompactAt / contextBreakdown.contextWindow) * 100) : 90;
247
- var isApproachingCompact = contextPercent >= autocompactPercent - 10;
248
- var isCritical = contextPercent >= autocompactPercent;
248
+ var contextInfo = useMemo(function () {
249
+ var percent = 0;
250
+ var filled = 0;
251
+ if (contextUsage && contextUsage.contextWindow > 0) {
252
+ filled = contextUsage.inputTokens + contextUsage.cacheReadTokens + contextUsage.cacheCreationTokens;
253
+ percent = Math.min(100, Math.round((filled / contextUsage.contextWindow) * 100));
254
+ }
255
+ var autocompact = contextBreakdown ? Math.round((contextBreakdown.autocompactAt / contextBreakdown.contextWindow) * 100) : 90;
256
+ return {
257
+ contextPercent: percent,
258
+ contextFilled: filled,
259
+ autocompactPercent: autocompact,
260
+ isApproachingCompact: percent >= autocompact - 10,
261
+ isCritical: percent >= autocompact,
262
+ };
263
+ }, [contextUsage, contextBreakdown]);
264
+
265
+ var contextPercent = contextInfo.contextPercent;
266
+ var contextFilled = contextInfo.contextFilled;
267
+ var autocompactPercent = contextInfo.autocompactPercent;
268
+ var isApproachingCompact = contextInfo.isApproachingCompact;
269
+ var isCritical = contextInfo.isCritical;
249
270
 
250
271
  var resumeCommand = activeSessionId && activeProject
251
272
  ? "cd " + activeProject.path + " && claude --resume " + activeSessionId
@@ -263,7 +284,10 @@ export function ChatView() {
263
284
  }
264
285
  return true;
265
286
  case "copy": {
266
- var lastAssistant = [...messages].reverse().find(function (m) { return m.type === "assistant"; });
287
+ var lastAssistant: typeof messages[0] | undefined;
288
+ for (var ci = messages.length - 1; ci >= 0; ci--) {
289
+ if (messages[ci].type === "assistant") { lastAssistant = messages[ci]; break; }
290
+ }
267
291
  if (lastAssistant?.text) {
268
292
  navigator.clipboard.writeText(lastAssistant.text);
269
293
  }
@@ -355,6 +379,14 @@ export function ChatView() {
355
379
 
356
380
  var virtualItems = virtualizer.getVirtualItems();
357
381
 
382
+ var lastAssistantIndex = useMemo(function () {
383
+ if (isProcessing || lastResponseCost == null) return -1;
384
+ for (var i = messages.length - 1; i >= 0; i--) {
385
+ if (messages[i].type === "assistant") return i;
386
+ }
387
+ return -1;
388
+ }, [messages, isProcessing, lastResponseCost]);
389
+
358
390
  return (
359
391
  <div className="flex flex-col h-full w-full bg-base-100 overflow-hidden relative">
360
392
  <div className="bg-base-100 border-b border-base-300 flex-shrink-0 px-2 sm:px-4">
@@ -379,7 +411,7 @@ export function ChatView() {
379
411
  ) : (
380
412
  <>
381
413
  <span className="text-sm font-semibold text-base-content truncate">
382
- {activeSessionTitle || (activeSessionId ? "Session" : "New Session")}
414
+ {formatSessionTitle(activeSessionTitle) || (activeSessionId ? "Session" : "New Session")}
383
415
  </span>
384
416
  {activeSessionId && (
385
417
  <button
@@ -442,7 +474,7 @@ export function ChatView() {
442
474
  {showContext && activeSessionId && (
443
475
  <div
444
476
  ref={contextPanelRef}
445
- className="absolute top-full right-0 mt-1 z-50 bg-base-300 border border-base-content/15 rounded-lg shadow-xl p-3 min-w-[300px] max-w-[340px]"
477
+ className="absolute top-full right-0 mt-1 z-50 bg-base-300 border border-base-content/15 rounded-lg shadow-xl p-3 w-[calc(100vw-24px)] sm:min-w-[300px] sm:w-auto max-w-[340px]"
446
478
  >
447
479
  <div className="flex flex-col gap-3">
448
480
  <div className="flex items-center justify-between">
@@ -555,7 +587,7 @@ export function ChatView() {
555
587
  {showInfo && activeSessionId && (
556
588
  <div
557
589
  ref={infoPanelRef}
558
- className="absolute top-full right-0 mt-1 z-50 bg-base-300 border border-base-content/15 rounded-lg shadow-xl p-3 min-w-[340px]"
590
+ className="absolute top-full right-0 mt-1 z-50 bg-base-300 border border-base-content/15 rounded-lg shadow-xl p-3 w-[calc(100vw-24px)] sm:min-w-[340px] sm:w-auto max-w-[380px]"
559
591
  >
560
592
  <div className="flex flex-col gap-2.5">
561
593
  <div>
@@ -623,7 +655,9 @@ export function ChatView() {
623
655
  <div
624
656
  ref={scrollParentRef}
625
657
  className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-lattice-grid"
626
- style={{ WebkitOverflowScrolling: "touch" }}
658
+ aria-live="polite"
659
+ aria-relevant="additions"
660
+ style={{ WebkitOverflowScrolling: "touch", touchAction: "pan-y" }}
627
661
  >
628
662
  {messages.length === 0 && historyLoading ? (
629
663
  <div className="flex items-center justify-center h-full">
@@ -680,22 +714,15 @@ export function ChatView() {
680
714
  if (groupSize >= 2) {
681
715
  if (idx === groupStart) {
682
716
  var groupTools = messages.slice(groupStart, groupEnd + 1);
683
- return <ToolGroup key={idx} tools={groupTools} />;
717
+ return <ToolGroup key={"tg-" + (groupTools[0].uuid || idx)} tools={groupTools} />;
684
718
  }
685
719
  return null;
686
720
  }
687
721
  }
688
- var isLastAssistant = false;
689
- if (msg.type === "assistant" && !isProcessing && lastResponseCost != null) {
690
- var foundLater = false;
691
- for (var li = idx + 1; li < messages.length; li++) {
692
- if (messages[li].type === "assistant") { foundLater = true; break; }
693
- }
694
- if (!foundLater) isLastAssistant = true;
695
- }
722
+ var isLastAssistant = idx === lastAssistantIndex;
696
723
  return (
697
724
  <Message
698
- key={idx}
725
+ key={msg.uuid || ("msg-" + idx)}
699
726
  message={msg}
700
727
  responseCost={isLastAssistant ? lastResponseCost : undefined}
701
728
  responseDuration={isLastAssistant ? lastResponseDuration : undefined}
@@ -753,14 +780,7 @@ export function ChatView() {
753
780
  }
754
781
  }
755
782
 
756
- var isLastAssistant = false;
757
- if (msg.type === "assistant" && !isProcessing && lastResponseCost != null) {
758
- var foundLater = false;
759
- for (var li = virtualItem.index + 1; li < messages.length; li++) {
760
- if (messages[li].type === "assistant") { foundLater = true; break; }
761
- }
762
- if (!foundLater) isLastAssistant = true;
763
- }
783
+ var isLastAssistant = virtualItem.index === lastAssistantIndex;
764
784
  return (
765
785
  <div
766
786
  key={virtualItem.key}
@@ -871,7 +891,7 @@ export function ChatView() {
871
891
  )}
872
892
 
873
893
  {confirmStopExternal && (
874
- <div className="fixed inset-0 z-[9999] flex items-center justify-center">
894
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="End External Process" onKeyDown={function (e) { if (e.key === "Escape") setConfirmStopExternal(false); }}>
875
895
  <div className="absolute inset-0 bg-black/50" onClick={function () { setConfirmStopExternal(false); }} />
876
896
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden">
877
897
  <div className="px-5 py-4 border-b border-base-content/15">
@@ -938,6 +958,7 @@ export function ChatView() {
938
958
 
939
959
  <div className="flex-shrink-0 border-t border-base-300 bg-base-200 px-2 sm:px-4 pb-3 pt-2">
940
960
  <ChatInput
961
+ sessionId={activeSessionId}
941
962
  onSend={handleSend}
942
963
  disabled={!activeSessionId || !online || isBusy}
943
964
  disabledPlaceholder={isBusy ? "Session in use by another client..." : undefined}
@@ -1,22 +1,47 @@
1
- import { useState, useRef, useEffect } from "react";
1
+ import { useState, useRef, useEffect, memo } from "react";
2
2
  import Markdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
- import { Wrench, TriangleAlert, ChevronDown, Check, X, Shield } from "lucide-react";
4
+ import { Wrench, TriangleAlert, ChevronDown, ChevronRight, Check, X, Shield, Zap, Link, Copy, SquarePlus } from "lucide-react";
5
5
  import type { HistoryMessage, ChatPermissionResponseMessage } from "@lattice/shared";
6
6
  import { useWebSocket } from "../../hooks/useWebSocket";
7
+ import { getSessionStore, setPendingPrefill } from "../../stores/session";
7
8
  import { ToolResultRenderer } from "./ToolResultRenderer";
8
9
  import { formatToolSummary } from "./toolSummary";
9
10
  import { PromptQuestion } from "./PromptQuestion";
10
11
  import { TodoCard } from "./TodoCard";
11
12
 
13
+ function TableWrapper(props: React.HTMLAttributes<HTMLTableElement>) {
14
+ var wrapperRef = useRef<HTMLDivElement>(null);
15
+
16
+ useEffect(function () {
17
+ var el = wrapperRef.current;
18
+ if (!el) return;
19
+ function check() {
20
+ if (!el) return;
21
+ var hasOverflow = el.scrollWidth > el.clientWidth + 1;
22
+ var atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 2;
23
+ el.classList.toggle("has-overflow", hasOverflow);
24
+ el.classList.toggle("scrolled-end", atEnd || !hasOverflow);
25
+ }
26
+ check();
27
+ el.addEventListener("scroll", check, { passive: true });
28
+ var ro = new ResizeObserver(check);
29
+ ro.observe(el);
30
+ return function () {
31
+ el!.removeEventListener("scroll", check);
32
+ ro.disconnect();
33
+ };
34
+ }, []);
35
+
36
+ return (
37
+ <div ref={wrapperRef} className="table-wrapper">
38
+ <table {...props} />
39
+ </div>
40
+ );
41
+ }
42
+
12
43
  var mdComponents = {
13
- table: function (props: React.HTMLAttributes<HTMLTableElement>) {
14
- return (
15
- <div className="table-wrapper">
16
- <table {...props} />
17
- </div>
18
- );
19
- },
44
+ table: TableWrapper,
20
45
  };
21
46
 
22
47
  interface MessageProps {
@@ -47,19 +72,140 @@ function formatTime(timestamp: number): string {
47
72
  return months[d.getMonth()] + " " + d.getDate() + ", " + time;
48
73
  }
49
74
 
75
+ function MessageAnchor(props: { id: string | undefined }) {
76
+ if (!props.id) return null;
77
+ function handleClick() {
78
+ var url = window.location.pathname + "#msg-" + props.id;
79
+ window.history.replaceState(null, "", url);
80
+ navigator.clipboard.writeText(window.location.origin + url);
81
+ }
82
+ return (
83
+ <button
84
+ type="button"
85
+ onClick={handleClick}
86
+ className="opacity-0 group-hover/msg:opacity-100 transition-opacity duration-150 text-base-content/20 hover:text-base-content/50 cursor-pointer p-0.5 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:outline-none rounded"
87
+ title="Copy link to message"
88
+ >
89
+ <Link size={11} />
90
+ </button>
91
+ );
92
+ }
93
+
94
+ function stripMarkdown(text: string): string {
95
+ return text
96
+ .replace(/```[\s\S]*?```/g, function (m) { return m.replace(/```\w*\n?/g, "").replace(/```$/g, ""); })
97
+ .replace(/`([^`]+)`/g, "$1")
98
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
99
+ .replace(/\*([^*]+)\*/g, "$1")
100
+ .replace(/^#{1,6}\s+/gm, "")
101
+ .replace(/^\s*[-*+]\s+/gm, "- ")
102
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
103
+ .replace(/^>\s+/gm, "")
104
+ .trim();
105
+ }
106
+
107
+ function MessageActions(props: { text: string; showNewSession?: boolean }) {
108
+ var [copied, setCopied] = useState(false);
109
+ var ws = useWebSocket();
110
+
111
+ function handleCopy(e: React.MouseEvent) {
112
+ var content = e.shiftKey ? stripMarkdown(props.text) : props.text;
113
+ navigator.clipboard.writeText(content);
114
+ setCopied(true);
115
+ setTimeout(function () { setCopied(false); }, 1500);
116
+ }
117
+
118
+ function handleNewSession() {
119
+ var state = getSessionStore().state;
120
+ if (!state.activeProjectSlug) return;
121
+ setPendingPrefill(props.text);
122
+ ws.send({ type: "session:create", projectSlug: state.activeProjectSlug });
123
+ }
124
+
125
+ var btnClass = "opacity-0 group-hover/msg:opacity-100 transition-opacity duration-150 text-base-content/20 hover:text-base-content/50 cursor-pointer p-0.5 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:outline-none rounded";
126
+
127
+ return (
128
+ <>
129
+ <button type="button" onClick={handleCopy} className={btnClass} title={copied ? "Copied!" : "Copy message (Shift+click for plain text)"}>
130
+ {copied ? <Check size={11} /> : <Copy size={11} />}
131
+ </button>
132
+ {props.showNewSession && (
133
+ <button type="button" onClick={handleNewSession} className={btnClass} title="Start new session with this message">
134
+ <SquarePlus size={11} />
135
+ </button>
136
+ )}
137
+ </>
138
+ );
139
+ }
140
+
141
+ function parseSkillInvocation(text: string): { skillName: string; content: string } | null {
142
+ var firstNewline = text.search(/\r?\n/);
143
+ if (firstNewline === -1) return null;
144
+ var firstLine = text.slice(0, firstNewline).trim();
145
+ if (firstLine.indexOf(":") === -1) return null;
146
+ if (!/\n---[\r\n]/.test(text)) return null;
147
+ return { skillName: firstLine, content: text.slice(firstNewline).replace(/^\r?\n+---[\r\n]+/, "").trim() };
148
+ }
149
+
150
+ function SkillMessage(props: { skillName: string; content: string; time: string | null; uuid?: string }) {
151
+ var [expanded, setExpanded] = useState(false);
152
+ return (
153
+ <div id={props.uuid ? "msg-" + props.uuid : undefined} className="chat chat-end px-5 py-1 group/msg">
154
+ <div className="chat-bubble chat-bubble-primary text-[13px] leading-relaxed break-words max-w-[95%] sm:max-w-[85%] shadow-sm">
155
+ <button
156
+ type="button"
157
+ aria-expanded={expanded}
158
+ onClick={function () { setExpanded(!expanded); }}
159
+ className="flex items-center gap-2 w-full text-left cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-content/30 rounded py-0.5"
160
+ >
161
+ <Zap size={13} className="text-primary-content/50 shrink-0" />
162
+ <span className="font-mono font-semibold text-primary-content text-[13px] tracking-tight">
163
+ /{props.skillName}
164
+ </span>
165
+ <ChevronRight
166
+ size={14}
167
+ className={"text-primary-content/30 ml-auto shrink-0 transition-transform duration-200 " + (expanded ? "rotate-90" : "")}
168
+ />
169
+ </button>
170
+ {expanded && (
171
+ <div className="mt-2 pt-2 border-t border-primary-content/10 relative">
172
+ <div className="max-h-[400px] overflow-y-auto skill-content-scroll prose prose-sm max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:text-primary-content prose-headings:text-[13px] prose-headings:mt-3 prose-headings:mb-1 prose-p:text-primary-content/80 prose-strong:text-primary-content prose-code:text-primary-content/70 prose-code:text-[11px] prose-pre:bg-primary/20 prose-a:text-primary-content/90 prose-a:underline text-[12px] leading-relaxed prose-li:text-primary-content/75 prose-li:text-[12px] prose-hr:border-primary-content/10">
173
+ <Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>{props.content}</Markdown>
174
+ </div>
175
+ </div>
176
+ )}
177
+ </div>
178
+ {props.time && (
179
+ <div className="chat-footer text-[10px] text-base-content/30 mt-0.5 flex items-center gap-1">
180
+ {props.time}
181
+ <MessageAnchor id={props.uuid} />
182
+ <MessageActions text={"/" + props.skillName} showNewSession />
183
+ </div>
184
+ )}
185
+ </div>
186
+ );
187
+ }
188
+
50
189
  function UserMessage(props: { message: HistoryMessage }) {
51
190
  var msg = props.message;
52
191
  var time = formatTime(msg.timestamp);
192
+ var text = msg.text || "";
193
+ var skill = parseSkillInvocation(text);
194
+ if (skill) {
195
+ return <SkillMessage skillName={skill.skillName} content={skill.content} time={time} uuid={msg.uuid} />;
196
+ }
53
197
  return (
54
- <div className="chat chat-end px-5 py-1">
198
+ <div id={msg.uuid ? "msg-" + msg.uuid : undefined} className="chat chat-end px-5 py-1 group/msg">
55
199
  <div className="chat-bubble chat-bubble-primary text-[13px] leading-relaxed break-words max-w-[95%] sm:max-w-[85%] shadow-sm">
56
200
  <div className="prose prose-sm max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:text-primary-content prose-p:text-primary-content prose-strong:text-primary-content prose-code:text-primary-content/80 prose-pre:bg-primary/20 prose-a:text-primary-content/90 prose-a:underline">
57
- <Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>{msg.text || ""}</Markdown>
201
+ <Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>{text}</Markdown>
58
202
  </div>
59
203
  </div>
60
204
  {time && (
61
- <div className="chat-footer text-[10px] text-base-content/30 mt-0.5">
205
+ <div className="chat-footer text-[10px] text-base-content/30 mt-0.5 flex items-center gap-1">
62
206
  {time}
207
+ <MessageAnchor id={msg.uuid} />
208
+ <MessageActions text={text} showNewSession />
63
209
  </div>
64
210
  )}
65
211
  </div>
@@ -84,7 +230,7 @@ function AssistantMessage(props: { message: HistoryMessage; responseCost?: numbe
84
230
  var msg = props.message;
85
231
  var time = formatTime(msg.timestamp);
86
232
  return (
87
- <div className="chat chat-start px-5 py-1">
233
+ <div id={msg.uuid ? "msg-" + msg.uuid : undefined} className="chat chat-start px-5 py-1 group/msg">
88
234
  <div className="chat-image">
89
235
  <div className="w-6 h-6 rounded-full bg-primary/15 border border-primary/20 flex items-center justify-center">
90
236
  <div className="w-2.5 h-2.5 rounded-full bg-primary" />
@@ -109,6 +255,8 @@ function AssistantMessage(props: { message: HistoryMessage; responseCost?: numbe
109
255
  {msg.outputTokens != null && msg.outputTokens > 0 && (
110
256
  <span className="text-base-content/15">{formatTokenCount(msg.outputTokens)} out</span>
111
257
  )}
258
+ <MessageAnchor id={msg.uuid} />
259
+ <MessageActions text={msg.text || ""} showNewSession />
112
260
  </div>
113
261
  )}
114
262
  </div>
@@ -130,7 +278,7 @@ function ToolMessage(props: { message: HistoryMessage }) {
130
278
  }
131
279
 
132
280
  return (
133
- <div className="ml-14 mr-5 py-0.5 max-w-[95%] sm:max-w-[75%]">
281
+ <div className="ml-14 mr-5 py-0.5 max-w-[95%] sm:max-w-[75%] group/msg">
134
282
  <div
135
283
  className={
136
284
  "rounded-lg overflow-hidden text-[12px] border transition-colors duration-100 " +
@@ -176,6 +324,11 @@ function ToolMessage(props: { message: HistoryMessage }) {
176
324
  </div>
177
325
  )}
178
326
  </div>
327
+ {hasResult && (
328
+ <div className="flex items-center gap-1 mt-0.5 px-0.5">
329
+ <MessageActions text={msg.content || ""} />
330
+ </div>
331
+ )}
179
332
  </div>
180
333
  );
181
334
  }
@@ -322,7 +475,7 @@ function PermissionMessage(props: { message: HistoryMessage }) {
322
475
  <span className="text-[10px] text-base-content/20 italic ml-auto">waiting for approval...</span>
323
476
 
324
477
  {showScopeMenu && (
325
- <div className={"absolute left-[88px] z-50 bg-base-300 border border-warning/20 rounded-lg shadow-xl p-1 text-[12px] font-mono min-w-[220px] " + (dropUp ? "bottom-full mb-1" : "top-full mt-1")}>
478
+ <div className={"absolute left-0 sm:left-[88px] z-50 bg-base-300 border border-warning/20 rounded-lg shadow-xl p-1 text-[12px] font-mono w-[calc(100vw-48px)] sm:w-auto sm:min-w-[220px] max-w-[280px] " + (dropUp ? "bottom-full mb-1" : "top-full mt-1")}>
326
479
  <button
327
480
  className="flex flex-col w-full px-2.5 py-1.5 rounded hover:bg-warning/10 text-left text-base-content/70 transition-colors"
328
481
  onClick={function () { setShowScopeMenu(false); respond(true, true, "session"); }}
@@ -352,7 +505,7 @@ function PermissionMessage(props: { message: HistoryMessage }) {
352
505
  );
353
506
  }
354
507
 
355
- export function Message(props: MessageProps) {
508
+ export var Message = memo(function Message(props: MessageProps) {
356
509
  var msg = props.message;
357
510
 
358
511
  if (msg.type === "user") {
@@ -380,4 +533,4 @@ export function Message(props: MessageProps) {
380
533
  }
381
534
 
382
535
  return null;
383
- }
536
+ });
@@ -119,7 +119,7 @@ export function ToolGroup(props: ToolGroupProps) {
119
119
  {expanded && (
120
120
  <div className="border-t border-base-content/8">
121
121
  {tools.map(function (tool, i) {
122
- return <ToolDetail key={tool.toolId || i} tool={tool} />;
122
+ return <ToolDetail key={tool.toolId || (tool.name + "-" + i)} tool={tool} />;
123
123
  })}
124
124
  </div>
125
125
  )}
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useMemo } from "react";
2
2
  import { useMesh } from "../../hooks/useMesh";
3
3
  import { useProjects } from "../../hooks/useProjects";
4
4
  import { useSidebar } from "../../hooks/useSidebar";
@@ -10,6 +10,7 @@ import {
10
10
  ChevronRight, Lock, Bug,
11
11
  } from "lucide-react";
12
12
  import type { ServerMessage, SessionSummary, LatticeConfig } from "@lattice/shared";
13
+ import { formatSessionTitle } from "../../utils/formatSessionTitle";
13
14
 
14
15
  function relativeTime(ts: number): string {
15
16
  var diff = Date.now() - ts;
@@ -31,7 +32,9 @@ export function DashboardView() {
31
32
  var [sessions, setSessions] = useState<SessionSummary[]>([]);
32
33
  var [localConfig, setLocalConfig] = useState<LatticeConfig | null>(null);
33
34
 
34
- var onlineNodes = nodes.filter(function (n) { return n.online; });
35
+ var onlineNodes = useMemo(function () {
36
+ return nodes.filter(function (n) { return n.online; });
37
+ }, [nodes]);
35
38
 
36
39
  useEffect(function () {
37
40
  function handleSessions(msg: ServerMessage) {
@@ -57,11 +60,32 @@ export function DashboardView() {
57
60
  };
58
61
  }, []);
59
62
 
63
+ var projectTitleMap = useMemo(function () {
64
+ var map = new Map<string, string>();
65
+ for (var i = 0; i < projects.length; i++) {
66
+ map.set(projects[i].slug, projects[i].title);
67
+ }
68
+ return map;
69
+ }, [projects]);
70
+
60
71
  function getProjectTitle(slug: string): string {
61
- var project = projects.find(function (p) { return p.slug === slug; });
62
- return project ? project.title : slug;
72
+ return projectTitleMap.get(slug) || slug;
63
73
  }
64
74
 
75
+ var sessionsByProject = useMemo(function () {
76
+ var map = new Map<string, SessionSummary[]>();
77
+ for (var i = 0; i < sessions.length; i++) {
78
+ var s = sessions[i];
79
+ var arr = map.get(s.projectSlug);
80
+ if (!arr) {
81
+ arr = [];
82
+ map.set(s.projectSlug, arr);
83
+ }
84
+ arr.push(s);
85
+ }
86
+ return map;
87
+ }, [sessions]);
88
+
65
89
  var totalSessions = sessions.length;
66
90
 
67
91
  return (
@@ -135,7 +159,7 @@ export function DashboardView() {
135
159
  className="flex items-center gap-3 px-3 py-2 rounded-xl border border-base-content/15 bg-base-200 hover:border-base-content/30 transition-colors duration-[120ms] cursor-pointer text-left focus-visible:ring-2 focus-visible:ring-primary"
136
160
  >
137
161
  <MessageSquare size={12} className="text-base-content/30 flex-shrink-0" />
138
- <span className="flex-1 text-[12px] text-base-content truncate">{s.title || "Untitled"}</span>
162
+ <span className="flex-1 text-[12px] text-base-content truncate">{formatSessionTitle(s.title) || "Untitled"}</span>
139
163
  <span className="px-1.5 py-0.5 rounded-md text-[10px] font-mono bg-base-content/8 text-base-content/40 flex-shrink-0">
140
164
  {getProjectTitle(s.projectSlug)}
141
165
  </span>
@@ -155,7 +179,7 @@ export function DashboardView() {
155
179
  <h2 className="text-[11px] font-semibold tracking-wider uppercase text-base-content/40 mb-3">Projects</h2>
156
180
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
157
181
  {projects.map(function (project) {
158
- var projectSessions = sessions.filter(function (s) { return s.projectSlug === project.slug; });
182
+ var projectSessions = sessionsByProject.get(project.slug) || [];
159
183
  return (
160
184
  <button
161
185
  key={project.slug}
@@ -121,8 +121,16 @@ function MemoryViewModal({
121
121
  var parsed = parseFrontmatter(content);
122
122
  var hasMeta = Object.keys(parsed.meta).length > 0;
123
123
 
124
+ useEffect(function () {
125
+ function handleKeyDown(e: KeyboardEvent) {
126
+ if (e.key === "Escape") onClose();
127
+ }
128
+ document.addEventListener("keydown", handleKeyDown);
129
+ return function () { document.removeEventListener("keydown", handleKeyDown); };
130
+ }, [onClose]);
131
+
124
132
  return (
125
- <div className="fixed inset-0 z-[9999] flex items-center justify-center">
133
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label={"Memory: " + (parsed.meta.name || memory.filename)}>
126
134
  <div className="absolute inset-0 bg-black/50" onClick={onClose} />
127
135
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden">
128
136
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">
@@ -208,8 +216,16 @@ function MemoryEditModal({
208
216
  onSave(filename, content);
209
217
  }
210
218
 
219
+ useEffect(function () {
220
+ function handleKeyDown(e: KeyboardEvent) {
221
+ if (e.key === "Escape") onClose();
222
+ }
223
+ document.addEventListener("keydown", handleKeyDown);
224
+ return function () { document.removeEventListener("keydown", handleKeyDown); };
225
+ }, [onClose]);
226
+
211
227
  return (
212
- <div className="fixed inset-0 z-[9999] flex items-center justify-center">
228
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label={isNew ? "New Memory" : "Edit Memory"}>
213
229
  <div className="absolute inset-0 bg-black/50" onClick={onClose} />
214
230
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col overflow-hidden">
215
231
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">
@@ -86,7 +86,7 @@ export function ProjectSettingsView() {
86
86
  var { settings, loading, error, updateSection } = useProjectSettings(activeProjectSlug);
87
87
 
88
88
  return (
89
- <div className="flex-1 overflow-auto px-4 sm:px-8 py-4 sm:py-6 max-w-3xl">
89
+ <section aria-label="Project Settings" className="flex-1 overflow-auto px-4 sm:px-8 py-4 sm:py-6 max-w-3xl">
90
90
  {config && (
91
91
  <div className="mb-6 flex items-center gap-3">
92
92
  <button
@@ -106,6 +106,6 @@ export function ProjectSettingsView() {
106
106
  <div className="text-[13px] text-error py-4">{error}</div>
107
107
  )}
108
108
  {!loading && !error && settings && renderSection(section, settings, updateSection, activeProjectSlug ?? undefined)}
109
- </div>
109
+ </section>
110
110
  );
111
111
  }
@@ -50,7 +50,7 @@ export function SettingsView() {
50
50
  var config = SECTION_CONFIG[section];
51
51
 
52
52
  return (
53
- <div className="flex-1 overflow-auto px-4 sm:px-8 py-4 sm:py-6 max-w-3xl">
53
+ <section aria-label="Settings" className="flex-1 overflow-auto px-4 sm:px-8 py-4 sm:py-6 max-w-3xl">
54
54
  {config && (
55
55
  <div className="mb-6 flex items-center gap-3">
56
56
  <button
@@ -64,6 +64,6 @@ export function SettingsView() {
64
64
  </div>
65
65
  )}
66
66
  {renderSection(section)}
67
- </div>
67
+ </section>
68
68
  );
69
69
  }
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { Trash2, RefreshCw, X, Loader2, FileText } from "lucide-react";
3
3
  import Markdown from "react-markdown";
4
4
  import remarkGfm from "remark-gfm";
@@ -126,8 +126,16 @@ export function SkillViewModal({ path, content, onClose }: { path: string; conte
126
126
  var parsed = parseFrontmatter(content);
127
127
  var hasMeta = Object.keys(parsed.meta).length > 0;
128
128
 
129
+ useEffect(function () {
130
+ function handleKeyDown(e: KeyboardEvent) {
131
+ if (e.key === "Escape") onClose();
132
+ }
133
+ document.addEventListener("keydown", handleKeyDown);
134
+ return function () { document.removeEventListener("keydown", handleKeyDown); };
135
+ }, [onClose]);
136
+
129
137
  return (
130
- <div className="fixed inset-0 z-50 flex items-center justify-center">
138
+ <div className="fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true" aria-label={"Skill: " + (parsed.meta.name || path)}>
131
139
  <div className="absolute inset-0 bg-black/50" onClick={onClose} />
132
140
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden">
133
141
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">