@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
|
@@ -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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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}>{
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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">
|