@cryptiklemur/lattice 1.46.6 → 1.47.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/chat/ChatView.tsx +7 -72
- package/client/src/components/chat/ElicitationCard.tsx +235 -0
- package/client/src/components/chat/Message.tsx +16 -0
- package/client/src/components/settings/BudgetSettings.tsx +6 -2
- package/client/src/components/sidebar/UserIsland.tsx +141 -14
- package/client/src/components/ui/SaveFooter.tsx +28 -3
- package/client/src/hooks/useSession.ts +89 -42
- package/client/src/hooks/useWebSocket.ts +1 -1
- package/client/src/stores/session.ts +64 -16
- package/package.json +1 -1
- package/server/src/daemon.ts +25 -8
- package/server/src/handlers/chat.ts +12 -1
- package/server/src/handlers/session.ts +1 -15
- package/server/src/handlers/settings.ts +3 -0
- package/server/src/index.ts +3 -0
- package/server/src/project/sdk-bridge.ts +119 -166
- package/server/src/project/session.ts +36 -7
- package/server/src/project/warmup.ts +232 -0
- package/shared/src/messages.ts +49 -11
- package/shared/src/models.ts +7 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
|
2
2
|
import { Terminal, Info, ArrowDown, Pencil, Copy, Check, Menu, AlertTriangle, Zap, Square, X, Bookmark } from "lucide-react";
|
|
3
3
|
import { LatticeLogomark } from "../ui/LatticeLogomark";
|
|
4
|
-
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
5
4
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
6
5
|
import { useSession } from "../../hooks/useSession";
|
|
7
6
|
import { useProjects } from "../../hooks/useProjects";
|
|
@@ -23,7 +22,7 @@ import { useBookmarks } from "../../hooks/useBookmarks";
|
|
|
23
22
|
import { formatSessionTitle } from "../../utils/formatSessionTitle";
|
|
24
23
|
|
|
25
24
|
export function ChatView({ sessionId: tabSessionId, projectSlug: tabProjectSlug }: { sessionId?: string; projectSlug?: string } = {}) {
|
|
26
|
-
var { messages, isProcessing, sendMessage, activeSessionId, activeSessionTitle, currentStatus, contextUsage, contextBreakdown, lastResponseCost, lastResponseDuration, historyLoading, historyHasMore, loadMoreHistory, wasInterrupted, promptSuggestion, failedInput, clearFailedInput, messageQueue, enqueueMessage, removeQueuedMessage, updateQueuedMessage,
|
|
25
|
+
var { messages, isProcessing, sendMessage, activeSessionId, activeSessionTitle, currentStatus, contextUsage, contextBreakdown, lastResponseCost, lastResponseDuration, historyLoading, historyHasMore, loadMoreHistory, wasInterrupted, promptSuggestion, failedInput, clearFailedInput, messageQueue, enqueueMessage, removeQueuedMessage, updateQueuedMessage, isPlanMode, pendingPrefill, activateSession, budgetStatus, budgetExceeded, sendBudgetOverride, dismissBudgetExceeded } = useSession();
|
|
27
26
|
var { activeProject } = useProjects();
|
|
28
27
|
var { toggleDrawer } = useSidebar();
|
|
29
28
|
|
|
@@ -48,11 +47,7 @@ export function ChatView({ sessionId: tabSessionId, projectSlug: tabProjectSlug
|
|
|
48
47
|
var [selectedModel, setSelectedModel] = useState<string>("default");
|
|
49
48
|
var [selectedEffort, setSelectedEffort] = useState<string>("medium");
|
|
50
49
|
var [showInfo, setShowInfo] = useState<boolean>(false);
|
|
51
|
-
var [confirmStopExternal, setConfirmStopExternal] = useState<boolean>(false);
|
|
52
50
|
var [prefillText, setPrefillText] = useState<string | null>(null);
|
|
53
|
-
var stopExternalModalRef = useRef<HTMLDivElement>(null);
|
|
54
|
-
var closeStopExternal = useCallback(function () { setConfirmStopExternal(false); }, []);
|
|
55
|
-
useFocusTrap(stopExternalModalRef, closeStopExternal, confirmStopExternal);
|
|
56
51
|
|
|
57
52
|
useEffect(function () {
|
|
58
53
|
if (pendingPrefill && !historyLoading) {
|
|
@@ -967,70 +962,14 @@ export function ChatView({ sessionId: tabSessionId, projectSlug: tabProjectSlug
|
|
|
967
962
|
</div>
|
|
968
963
|
)}
|
|
969
964
|
|
|
970
|
-
{
|
|
971
|
-
<div className="flex items-center gap-2 px-3 sm:px-5 py-2 bg-info/10 border-t border-info/20">
|
|
972
|
-
<Terminal size={13} className="text-info flex-shrink-0" />
|
|
973
|
-
<span className="text-[12px] text-info flex-1">
|
|
974
|
-
{busyOwner === "cli"
|
|
975
|
-
? "This session is controlled by Claude Code CLI — input is disabled"
|
|
976
|
-
: "This session is in use by another Lattice client — input is disabled"}
|
|
977
|
-
</span>
|
|
978
|
-
<button
|
|
979
|
-
onClick={function () { setConfirmStopExternal(true); }}
|
|
980
|
-
className="btn btn-ghost btn-xs text-error/70 hover:text-error gap-1 flex-shrink-0"
|
|
981
|
-
>
|
|
982
|
-
<Square size={10} className="fill-current" />
|
|
983
|
-
End Process
|
|
984
|
-
</button>
|
|
985
|
-
</div>
|
|
986
|
-
)}
|
|
987
|
-
|
|
988
|
-
{confirmStopExternal && (
|
|
989
|
-
<div ref={stopExternalModalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="End External Process">
|
|
990
|
-
<div className="absolute inset-0 bg-black/50" onClick={function () { setConfirmStopExternal(false); }} />
|
|
991
|
-
<div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden">
|
|
992
|
-
<div className="px-5 py-4 border-b border-base-content/15">
|
|
993
|
-
<h2 className="text-[15px] font-mono font-bold text-base-content">End External Process</h2>
|
|
994
|
-
</div>
|
|
995
|
-
<div className="px-5 py-4">
|
|
996
|
-
<p className="text-[13px] text-base-content/60 leading-relaxed">
|
|
997
|
-
This will send a graceful stop signal (SIGINT) to the Claude Code CLI process controlling this session. The process will finish its current operation and exit.
|
|
998
|
-
</p>
|
|
999
|
-
<p className="text-[13px] text-warning mt-2 leading-relaxed">
|
|
1000
|
-
Any in-progress work may be interrupted.
|
|
1001
|
-
</p>
|
|
1002
|
-
</div>
|
|
1003
|
-
<div className="px-5 py-3 border-t border-base-content/15 flex justify-end gap-2">
|
|
1004
|
-
<button
|
|
1005
|
-
onClick={function () { setConfirmStopExternal(false); }}
|
|
1006
|
-
className="btn btn-ghost btn-sm text-[12px]"
|
|
1007
|
-
>
|
|
1008
|
-
Cancel
|
|
1009
|
-
</button>
|
|
1010
|
-
<button
|
|
1011
|
-
onClick={function () {
|
|
1012
|
-
if (activeSessionId) {
|
|
1013
|
-
ws.send({ type: "session:stop_external", sessionId: activeSessionId } as any);
|
|
1014
|
-
}
|
|
1015
|
-
setConfirmStopExternal(false);
|
|
1016
|
-
}}
|
|
1017
|
-
className="btn btn-error btn-sm text-[12px]"
|
|
1018
|
-
>
|
|
1019
|
-
End Process
|
|
1020
|
-
</button>
|
|
1021
|
-
</div>
|
|
1022
|
-
</div>
|
|
1023
|
-
</div>
|
|
1024
|
-
)}
|
|
1025
|
-
|
|
1026
|
-
{wasInterrupted && !isProcessing && !isBusy && (
|
|
965
|
+
{wasInterrupted && !isProcessing && (
|
|
1027
966
|
<div className="flex items-center gap-2 px-3 sm:px-5 py-2 bg-warning/10 border-t border-warning/20">
|
|
1028
967
|
<AlertTriangle size={13} className="text-warning flex-shrink-0" />
|
|
1029
968
|
<span className="text-[12px] text-warning">Session was interrupted — send a message to continue</span>
|
|
1030
969
|
</div>
|
|
1031
970
|
)}
|
|
1032
971
|
|
|
1033
|
-
{promptSuggestion && !isProcessing &&
|
|
972
|
+
{promptSuggestion && !isProcessing && (
|
|
1034
973
|
<div className="flex-shrink-0 px-2 sm:px-4 py-2">
|
|
1035
974
|
<div className="flex items-center gap-1.5 max-w-full">
|
|
1036
975
|
<button
|
|
@@ -1079,15 +1018,11 @@ export function ChatView({ sessionId: tabSessionId, projectSlug: tabProjectSlug
|
|
|
1079
1018
|
<ChatInput
|
|
1080
1019
|
sessionId={activeSessionId}
|
|
1081
1020
|
onSend={handleSend}
|
|
1082
|
-
disabled={!activeSessionId || !online ||
|
|
1021
|
+
disabled={!activeSessionId || !online || (budgetStatus !== null && budgetStatus.enforcement === "hard-block" && budgetStatus.dailySpend >= budgetStatus.dailyLimit)}
|
|
1083
1022
|
disabledPlaceholder={
|
|
1084
|
-
|
|
1085
|
-
? (
|
|
1086
|
-
|
|
1087
|
-
: "Session in use by another Lattice client")
|
|
1088
|
-
: (budgetStatus !== null && budgetStatus.enforcement === "hard-block" && budgetStatus.dailySpend >= budgetStatus.dailyLimit)
|
|
1089
|
-
? "Daily budget exceeded ($" + budgetStatus.dailySpend.toFixed(2) + " / $" + budgetStatus.dailyLimit.toFixed(2) + ")"
|
|
1090
|
-
: undefined
|
|
1023
|
+
(budgetStatus !== null && budgetStatus.enforcement === "hard-block" && budgetStatus.dailySpend >= budgetStatus.dailyLimit)
|
|
1024
|
+
? "Daily budget exceeded ($" + budgetStatus.dailySpend.toFixed(2) + " / $" + budgetStatus.dailyLimit.toFixed(2) + ")"
|
|
1025
|
+
: undefined
|
|
1091
1026
|
}
|
|
1092
1027
|
failedInput={failedInput}
|
|
1093
1028
|
onFailedInputConsumed={clearFailedInput}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Globe, FormInput, Check, X, ExternalLink } from "lucide-react";
|
|
3
|
+
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
4
|
+
|
|
5
|
+
interface ElicitationCardProps {
|
|
6
|
+
requestId: string;
|
|
7
|
+
serverName: string;
|
|
8
|
+
message: string;
|
|
9
|
+
mode: "form" | "url";
|
|
10
|
+
url?: string | null;
|
|
11
|
+
requestedSchema?: Record<string, unknown> | null;
|
|
12
|
+
resolved?: boolean;
|
|
13
|
+
resolvedAction?: "accept" | "decline";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SchemaProperty {
|
|
17
|
+
type?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
enum?: string[];
|
|
20
|
+
default?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderFormField(
|
|
24
|
+
key: string,
|
|
25
|
+
prop: SchemaProperty,
|
|
26
|
+
value: unknown,
|
|
27
|
+
onChange: (key: string, val: unknown) => void,
|
|
28
|
+
required: boolean,
|
|
29
|
+
) {
|
|
30
|
+
var label = key.replace(/_/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
|
31
|
+
|
|
32
|
+
if (prop.type === "boolean") {
|
|
33
|
+
return (
|
|
34
|
+
<label key={key} className="flex items-center gap-2.5 py-1.5 cursor-pointer">
|
|
35
|
+
<input
|
|
36
|
+
type="checkbox"
|
|
37
|
+
checked={!!value}
|
|
38
|
+
onChange={function (e) { onChange(key, e.target.checked); }}
|
|
39
|
+
className="checkbox checkbox-xs checkbox-primary"
|
|
40
|
+
/>
|
|
41
|
+
<span className="text-[12px] text-base-content/70">{label}</span>
|
|
42
|
+
{prop.description && (
|
|
43
|
+
<span className="text-[10px] text-base-content/30" title={prop.description}>?</span>
|
|
44
|
+
)}
|
|
45
|
+
</label>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (prop.enum && prop.enum.length > 0) {
|
|
50
|
+
return (
|
|
51
|
+
<div key={key} className="flex flex-col gap-1">
|
|
52
|
+
<label className="text-[10px] font-mono uppercase tracking-wider text-base-content/35">
|
|
53
|
+
{label}{required && <span className="text-error/50 ml-0.5">*</span>}
|
|
54
|
+
</label>
|
|
55
|
+
<select
|
|
56
|
+
value={String(value || "")}
|
|
57
|
+
onChange={function (e) { onChange(key, e.target.value); }}
|
|
58
|
+
className="select select-xs select-bordered bg-base-content/[0.03] text-[12px] text-base-content/70"
|
|
59
|
+
>
|
|
60
|
+
<option value="">Select...</option>
|
|
61
|
+
{prop.enum.map(function (opt) {
|
|
62
|
+
return <option key={opt} value={opt}>{opt}</option>;
|
|
63
|
+
})}
|
|
64
|
+
</select>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (prop.type === "number" || prop.type === "integer") {
|
|
70
|
+
return (
|
|
71
|
+
<div key={key} className="flex flex-col gap-1">
|
|
72
|
+
<label className="text-[10px] font-mono uppercase tracking-wider text-base-content/35">
|
|
73
|
+
{label}{required && <span className="text-error/50 ml-0.5">*</span>}
|
|
74
|
+
</label>
|
|
75
|
+
<input
|
|
76
|
+
type="number"
|
|
77
|
+
value={value != null ? String(value) : ""}
|
|
78
|
+
placeholder={prop.description || ""}
|
|
79
|
+
onChange={function (e) { onChange(key, e.target.value ? Number(e.target.value) : ""); }}
|
|
80
|
+
className="input input-xs input-bordered bg-base-content/[0.03] text-[12px] text-base-content/70 placeholder:text-base-content/20"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div key={key} className="flex flex-col gap-1">
|
|
88
|
+
<label className="text-[10px] font-mono uppercase tracking-wider text-base-content/35">
|
|
89
|
+
{label}{required && <span className="text-error/50 ml-0.5">*</span>}
|
|
90
|
+
</label>
|
|
91
|
+
<input
|
|
92
|
+
type="text"
|
|
93
|
+
value={String(value || "")}
|
|
94
|
+
placeholder={prop.description || ""}
|
|
95
|
+
onChange={function (e) { onChange(key, e.target.value); }}
|
|
96
|
+
className="input input-xs input-bordered bg-base-content/[0.03] text-[12px] text-base-content/70 placeholder:text-base-content/20"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function ElicitationCard(props: ElicitationCardProps) {
|
|
103
|
+
var { send } = useWebSocket();
|
|
104
|
+
var [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
105
|
+
var [submitted, setSubmitted] = useState(props.resolved || false);
|
|
106
|
+
var [action, setAction] = useState<"accept" | "decline" | null>(props.resolvedAction || null);
|
|
107
|
+
|
|
108
|
+
function handleFieldChange(key: string, value: unknown) {
|
|
109
|
+
setFormData(function (prev) {
|
|
110
|
+
var next = { ...prev };
|
|
111
|
+
next[key] = value;
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleSubmit(submitAction: "accept" | "decline") {
|
|
117
|
+
setSubmitted(true);
|
|
118
|
+
setAction(submitAction);
|
|
119
|
+
send({
|
|
120
|
+
type: "chat:elicitation_response",
|
|
121
|
+
requestId: props.requestId,
|
|
122
|
+
action: submitAction,
|
|
123
|
+
content: submitAction === "accept" ? formData : undefined,
|
|
124
|
+
} as any);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (submitted) {
|
|
128
|
+
var isAccepted = action === "accept";
|
|
129
|
+
return (
|
|
130
|
+
<div className="ml-14 mr-5 py-0.5 max-w-[95%] sm:max-w-[75%]">
|
|
131
|
+
<div className={"rounded-lg text-[12px] border px-2.5 py-1.5 flex items-center gap-2 " + (isAccepted ? "border-success/15 bg-success/3" : "border-error/15 bg-error/3")}>
|
|
132
|
+
{isAccepted ? <Check size={12} className="text-success" /> : <X size={12} className="text-error" />}
|
|
133
|
+
<span className="text-base-content/35">{isAccepted ? "Submitted" : "Declined"}</span>
|
|
134
|
+
<Globe size={11} className="text-base-content/20" />
|
|
135
|
+
<span className="text-[11px] text-base-content/40">{props.serverName}</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
var schema = props.requestedSchema;
|
|
142
|
+
var properties: Record<string, SchemaProperty> = {};
|
|
143
|
+
var requiredFields: string[] = [];
|
|
144
|
+
if (schema && typeof schema === "object") {
|
|
145
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
146
|
+
properties = schema.properties as Record<string, SchemaProperty>;
|
|
147
|
+
}
|
|
148
|
+
if (Array.isArray(schema.required)) {
|
|
149
|
+
requiredFields = schema.required as string[];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
var hasFormFields = Object.keys(properties).length > 0;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className="ml-14 mr-5 py-1 max-w-[95%] sm:max-w-[75%]">
|
|
156
|
+
<div className="border border-info/25 bg-info/5 rounded-lg overflow-hidden">
|
|
157
|
+
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b border-base-content/5 bg-base-content/[0.02]">
|
|
158
|
+
{props.mode === "url"
|
|
159
|
+
? <Globe size={14} className="text-info/50 flex-shrink-0" />
|
|
160
|
+
: <FormInput size={14} className="text-info/50 flex-shrink-0" />
|
|
161
|
+
}
|
|
162
|
+
<span className="text-[10px] font-mono font-bold uppercase tracking-widest text-info/40">
|
|
163
|
+
{props.serverName}
|
|
164
|
+
</span>
|
|
165
|
+
<span className="flex-1" />
|
|
166
|
+
<span className="text-[9px] font-mono text-base-content/20">{props.mode} input</span>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div className="px-3.5 py-3">
|
|
170
|
+
{props.message && (
|
|
171
|
+
<div className="text-[13px] text-base-content/75 mb-3 leading-relaxed">{props.message}</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{props.mode === "url" && props.url && (
|
|
175
|
+
<div className="flex flex-col gap-2">
|
|
176
|
+
<a
|
|
177
|
+
href={props.url}
|
|
178
|
+
target="_blank"
|
|
179
|
+
rel="noopener noreferrer"
|
|
180
|
+
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-base-content/[0.04] border border-base-content/8 text-[12px] text-info/70 hover:bg-base-content/[0.07] hover:border-info/20 transition-colors"
|
|
181
|
+
>
|
|
182
|
+
<ExternalLink size={12} />
|
|
183
|
+
<span className="truncate flex-1">{props.url}</span>
|
|
184
|
+
</a>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{props.mode === "form" && hasFormFields && (
|
|
189
|
+
<div className="flex flex-col gap-2.5">
|
|
190
|
+
{Object.entries(properties).map(function ([key, prop]) {
|
|
191
|
+
return renderFormField(
|
|
192
|
+
key,
|
|
193
|
+
prop,
|
|
194
|
+
formData[key],
|
|
195
|
+
handleFieldChange,
|
|
196
|
+
requiredFields.includes(key),
|
|
197
|
+
);
|
|
198
|
+
})}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{props.mode === "form" && !hasFormFields && (
|
|
203
|
+
<div className="flex flex-col gap-1.5">
|
|
204
|
+
<label className="text-[10px] font-mono uppercase tracking-wider text-base-content/35">Response</label>
|
|
205
|
+
<textarea
|
|
206
|
+
rows={3}
|
|
207
|
+
value={String(formData._raw || "")}
|
|
208
|
+
onChange={function (e) { handleFieldChange("_raw", e.target.value); }}
|
|
209
|
+
placeholder="Enter your response..."
|
|
210
|
+
className="textarea textarea-bordered textarea-xs bg-base-content/[0.03] text-[12px] text-base-content/70 placeholder:text-base-content/20"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
<div className="flex items-center gap-2 mt-3">
|
|
216
|
+
<button
|
|
217
|
+
onClick={function () { handleSubmit("accept"); }}
|
|
218
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-success/15 text-success text-[11px] font-medium hover:bg-success/25 transition-colors cursor-pointer"
|
|
219
|
+
>
|
|
220
|
+
<Check size={11} />
|
|
221
|
+
{props.mode === "url" ? "Done" : "Submit"}
|
|
222
|
+
</button>
|
|
223
|
+
<button
|
|
224
|
+
onClick={function () { handleSubmit("decline"); }}
|
|
225
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-base-content/5 text-base-content/40 text-[11px] font-medium hover:bg-base-content/10 hover:text-base-content/60 transition-colors cursor-pointer"
|
|
226
|
+
>
|
|
227
|
+
<X size={11} />
|
|
228
|
+
Decline
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -11,6 +11,7 @@ import { ToolResultRenderer } from "./ToolResultRenderer";
|
|
|
11
11
|
import { formatToolSummary } from "./toolSummary";
|
|
12
12
|
import { PromptQuestion } from "./PromptQuestion";
|
|
13
13
|
import { TodoCard } from "./TodoCard";
|
|
14
|
+
import { ElicitationCard } from "./ElicitationCard";
|
|
14
15
|
|
|
15
16
|
function TableWrapper(props: React.HTMLAttributes<HTMLTableElement>) {
|
|
16
17
|
var wrapperRef = useRef<HTMLDivElement>(null);
|
|
@@ -567,5 +568,20 @@ export var Message = memo(function Message(props: MessageProps) {
|
|
|
567
568
|
return <TodoCard message={msg} />;
|
|
568
569
|
}
|
|
569
570
|
|
|
571
|
+
if (msg.type === "elicitation") {
|
|
572
|
+
return (
|
|
573
|
+
<ElicitationCard
|
|
574
|
+
requestId={msg.toolId || ""}
|
|
575
|
+
serverName={msg.elicitationServerName || "MCP Server"}
|
|
576
|
+
message={msg.elicitationMessage || ""}
|
|
577
|
+
mode={msg.elicitationMode || "form"}
|
|
578
|
+
url={msg.elicitationUrl}
|
|
579
|
+
requestedSchema={msg.elicitationSchema}
|
|
580
|
+
resolved={msg.elicitationStatus !== "pending"}
|
|
581
|
+
resolvedAction={msg.elicitationStatus === "accepted" ? "accept" : msg.elicitationStatus === "declined" ? "decline" : undefined}
|
|
582
|
+
/>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
570
586
|
return null;
|
|
571
587
|
});
|
|
@@ -3,7 +3,7 @@ import { useStore } from "@tanstack/react-store";
|
|
|
3
3
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
4
4
|
import { useSaveState } from "../../hooks/useSaveState";
|
|
5
5
|
import { SaveFooter } from "../ui/SaveFooter";
|
|
6
|
-
import { getSessionStore } from "../../stores/session";
|
|
6
|
+
import { getSessionStore, setBudgetStatus } from "../../stores/session";
|
|
7
7
|
import type { ServerMessage, SettingsDataMessage, SettingsUpdateMessage } from "@lattice/shared";
|
|
8
8
|
|
|
9
9
|
var ENFORCEMENT_OPTIONS = [
|
|
@@ -28,6 +28,9 @@ export function BudgetSettings() {
|
|
|
28
28
|
|
|
29
29
|
if (save.savingRef.current) {
|
|
30
30
|
save.confirmSave();
|
|
31
|
+
if (!cfg.costBudget) {
|
|
32
|
+
setBudgetStatus(null);
|
|
33
|
+
}
|
|
31
34
|
} else {
|
|
32
35
|
if (cfg.costBudget) {
|
|
33
36
|
setEnabled(true);
|
|
@@ -37,6 +40,7 @@ export function BudgetSettings() {
|
|
|
37
40
|
setEnabled(false);
|
|
38
41
|
setDailyLimit(10);
|
|
39
42
|
setEnforcement("warning");
|
|
43
|
+
setBudgetStatus(null);
|
|
40
44
|
}
|
|
41
45
|
save.resetFromServer();
|
|
42
46
|
}
|
|
@@ -55,7 +59,7 @@ export function BudgetSettings() {
|
|
|
55
59
|
var updateMsg: SettingsUpdateMessage = {
|
|
56
60
|
type: "settings:update",
|
|
57
61
|
settings: {
|
|
58
|
-
costBudget: enabled ? { dailyLimit: dailyLimit, enforcement: enforcement as "warning" | "soft-block" | "hard-block" } :
|
|
62
|
+
costBudget: enabled ? { dailyLimit: dailyLimit, enforcement: enforcement as "warning" | "soft-block" | "hard-block" } : null,
|
|
59
63
|
} as SettingsUpdateMessage["settings"],
|
|
60
64
|
};
|
|
61
65
|
send(updateMsg);
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
2
|
-
import { Sun, Moon, Settings, Download, ArrowUpCircle } from "lucide-react";
|
|
1
|
+
import { useState, useEffect, useMemo } from "react";
|
|
2
|
+
import { Sun, Moon, Settings, Download, ArrowUpCircle, Clock } from "lucide-react";
|
|
3
3
|
import { useStore } from "@tanstack/react-store";
|
|
4
4
|
import { useTheme } from "../../hooks/useTheme";
|
|
5
5
|
import { useSidebar } from "../../hooks/useSidebar";
|
|
6
6
|
import { useInstallPrompt } from "../../hooks/useInstallPrompt";
|
|
7
7
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
8
|
-
import { getSessionStore } from "../../stores/session";
|
|
8
|
+
import { getSessionStore, loadCachedRateLimits } from "../../stores/session";
|
|
9
|
+
import type { RateLimitEntry } from "../../stores/session";
|
|
9
10
|
import pkg from "../../../package.json";
|
|
10
11
|
import type { ServerMessage } from "@lattice/shared";
|
|
11
12
|
|
|
@@ -14,17 +15,59 @@ interface UserIslandProps {
|
|
|
14
15
|
onClick: () => void;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function formatResetTime(epochSeconds: number): string {
|
|
19
|
+
var now = Date.now();
|
|
20
|
+
var diff = (epochSeconds * 1000) - now;
|
|
21
|
+
if (diff <= 0) return "now";
|
|
22
|
+
var hours = Math.floor(diff / 3600000);
|
|
23
|
+
var minutes = Math.floor((diff % 3600000) / 60000);
|
|
24
|
+
if (hours > 24) {
|
|
25
|
+
var days = Math.floor(hours / 24);
|
|
26
|
+
return days + "d " + (hours % 24) + "h";
|
|
27
|
+
}
|
|
28
|
+
if (hours > 0) return hours + "h " + minutes + "m";
|
|
29
|
+
if (minutes === 0) return "<1m";
|
|
30
|
+
return minutes + "m";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getRateLimitLabel(type: string): string {
|
|
34
|
+
if (type === "five_hour") return "5-hour";
|
|
35
|
+
if (type === "seven_day") return "7-day";
|
|
36
|
+
if (type === "seven_day_opus") return "7-day Opus";
|
|
37
|
+
if (type === "seven_day_sonnet") return "7-day Sonnet";
|
|
38
|
+
if (type === "overage") return "Overage";
|
|
39
|
+
return type;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getBarColor(entry: RateLimitEntry): string {
|
|
43
|
+
if (entry.status === "rejected") return "bg-error";
|
|
44
|
+
if (entry.status === "allowed_warning") return "bg-warning";
|
|
45
|
+
var util = entry.utilization ?? 0;
|
|
46
|
+
if (util >= 0.9) return "bg-error";
|
|
47
|
+
if (util >= 0.7) return "bg-warning";
|
|
48
|
+
return "bg-primary/60";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getStatusColor(entry: RateLimitEntry): string {
|
|
52
|
+
if (entry.status === "rejected") return "text-error";
|
|
53
|
+
if (entry.status === "allowed_warning") return "text-warning";
|
|
54
|
+
return "text-base-content/70";
|
|
55
|
+
}
|
|
56
|
+
|
|
17
57
|
export function UserIsland(props: UserIslandProps) {
|
|
18
58
|
var { mode, toggleMode } = useTheme();
|
|
19
59
|
var sidebar = useSidebar();
|
|
20
60
|
var { canInstall, install } = useInstallPrompt();
|
|
21
61
|
var budgetStatus = useStore(getSessionStore(), function (s) { return s.budgetStatus; });
|
|
62
|
+
var rateLimits = useStore(getSessionStore(), function (s) { return s.rateLimits; });
|
|
22
63
|
var [showTooltip, setShowTooltip] = useState(false);
|
|
23
64
|
var ws = useWebSocket();
|
|
24
65
|
var [updateAvailable, setUpdateAvailable] = useState(false);
|
|
25
66
|
var [latestVersion, setLatestVersion] = useState<string | null>(null);
|
|
26
67
|
var [currentVersion, setCurrentVersion] = useState(pkg.version);
|
|
27
68
|
|
|
69
|
+
useEffect(function () { loadCachedRateLimits(); }, []);
|
|
70
|
+
|
|
28
71
|
useEffect(function () {
|
|
29
72
|
function handleUpdateStatus(msg: ServerMessage) {
|
|
30
73
|
if (msg.type !== "update:status") return;
|
|
@@ -40,34 +83,117 @@ export function UserIsland(props: UserIslandProps) {
|
|
|
40
83
|
return function () { ws.unsubscribe("update:status", handleUpdateStatus); };
|
|
41
84
|
}, []);
|
|
42
85
|
|
|
86
|
+
var entries = useMemo(function () {
|
|
87
|
+
var all = Object.values(rateLimits);
|
|
88
|
+
all.sort(function (a, b) {
|
|
89
|
+
var order: Record<string, number> = { five_hour: 0, seven_day: 1, seven_day_opus: 2, seven_day_sonnet: 3, overage: 4 };
|
|
90
|
+
return (order[a.rateLimitType] ?? 9) - (order[b.rateLimitType] ?? 9);
|
|
91
|
+
});
|
|
92
|
+
return all;
|
|
93
|
+
}, [rateLimits]);
|
|
94
|
+
|
|
95
|
+
var primaryEntry = entries.length > 0 ? entries[0] : null;
|
|
96
|
+
|
|
43
97
|
var initial = props.nodeName.charAt(0).toUpperCase();
|
|
44
98
|
|
|
45
99
|
var budgetBar = null;
|
|
46
100
|
if (budgetStatus && budgetStatus.dailyLimit > 0) {
|
|
47
101
|
var pct = Math.min((budgetStatus.dailySpend / budgetStatus.dailyLimit) * 100, 100);
|
|
48
102
|
var remaining = Math.max(budgetStatus.dailyLimit - budgetStatus.dailySpend, 0);
|
|
49
|
-
var barColor = pct >= 100
|
|
50
|
-
? "bg-error"
|
|
51
|
-
: pct >= 80
|
|
52
|
-
? "bg-warning"
|
|
53
|
-
: "bg-primary";
|
|
103
|
+
var barColor = pct >= 100 ? "bg-error" : pct >= 80 ? "bg-warning" : "bg-primary";
|
|
54
104
|
|
|
55
105
|
budgetBar = (
|
|
106
|
+
<div className="px-3 pt-2 pb-0">
|
|
107
|
+
<div className="flex items-center justify-between mb-0.5">
|
|
108
|
+
<span className="text-[9px] font-mono uppercase tracking-wider text-base-content/25">Budget</span>
|
|
109
|
+
<span className="text-[9px] font-mono tabular-nums text-base-content/30">${remaining.toFixed(2)} left</span>
|
|
110
|
+
</div>
|
|
111
|
+
<div className="h-1 rounded-full bg-base-content/8 overflow-hidden">
|
|
112
|
+
<div
|
|
113
|
+
className={"h-full rounded-full transition-all duration-300 " + barColor}
|
|
114
|
+
style={{ width: pct + "%" }}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
var usageBar = null;
|
|
122
|
+
if (primaryEntry) {
|
|
123
|
+
var utilPct = Math.min(100, Math.round((primaryEntry.utilization ?? 0) * 100));
|
|
124
|
+
|
|
125
|
+
usageBar = (
|
|
56
126
|
<div
|
|
57
127
|
className="px-3 pt-2 pb-0 relative"
|
|
58
128
|
onMouseEnter={function () { setShowTooltip(true); }}
|
|
59
129
|
onMouseLeave={function () { setShowTooltip(false); }}
|
|
60
130
|
>
|
|
61
|
-
<div className="
|
|
131
|
+
<div className="flex items-center justify-between mb-0.5">
|
|
132
|
+
<span className="text-[9px] font-mono uppercase tracking-wider text-base-content/25">
|
|
133
|
+
{getRateLimitLabel(primaryEntry.rateLimitType)} usage
|
|
134
|
+
</span>
|
|
135
|
+
<span className={"text-[9px] font-mono tabular-nums " + getStatusColor(primaryEntry)}>
|
|
136
|
+
{utilPct}%
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="h-1 rounded-full bg-base-content/8 overflow-hidden">
|
|
62
140
|
<div
|
|
63
|
-
className={"h-full rounded-full transition-all duration-300 " +
|
|
64
|
-
style={{ width:
|
|
141
|
+
className={"h-full rounded-full transition-all duration-300 " + getBarColor(primaryEntry)}
|
|
142
|
+
style={{ width: utilPct + "%" }}
|
|
65
143
|
/>
|
|
66
144
|
</div>
|
|
145
|
+
{primaryEntry.resetsAt && (
|
|
146
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
147
|
+
<Clock size={7} className="text-base-content/20" />
|
|
148
|
+
<span className="text-[8px] font-mono text-base-content/20">
|
|
149
|
+
resets in {formatResetTime(primaryEntry.resetsAt)}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
67
154
|
{showTooltip && (
|
|
68
|
-
<div className="absolute bottom-full left-
|
|
69
|
-
<div
|
|
70
|
-
<div className="
|
|
155
|
+
<div className="absolute bottom-full left-2 right-2 mb-2 p-2.5 bg-base-100 border border-base-content/10 rounded-lg shadow-xl z-[9999] text-[11px] font-mono">
|
|
156
|
+
<div className="text-[10px] uppercase tracking-widest text-base-content/30 font-bold mb-2">Usage Limits</div>
|
|
157
|
+
<div className="flex flex-col gap-2.5">
|
|
158
|
+
{entries.map(function (entry) {
|
|
159
|
+
var pctVal = Math.min(100, Math.round((entry.utilization ?? 0) * 100));
|
|
160
|
+
return (
|
|
161
|
+
<div key={entry.rateLimitType}>
|
|
162
|
+
<div className="flex items-center justify-between mb-0.5">
|
|
163
|
+
<span className="text-base-content/50">{getRateLimitLabel(entry.rateLimitType)}</span>
|
|
164
|
+
<span className={"tabular-nums " + getStatusColor(entry)}>
|
|
165
|
+
{pctVal}%
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="h-1.5 rounded-full bg-base-content/8 overflow-hidden">
|
|
169
|
+
<div
|
|
170
|
+
className={"h-full rounded-full transition-all duration-300 " + getBarColor(entry)}
|
|
171
|
+
style={{ width: pctVal + "%" }}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
{entry.resetsAt && (
|
|
175
|
+
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-base-content/30">
|
|
176
|
+
<Clock size={8} />
|
|
177
|
+
resets in {formatResetTime(entry.resetsAt)}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
|
|
184
|
+
{entries.some(function (e) { return e.isUsingOverage; }) && (
|
|
185
|
+
<div className="border-t border-base-content/8 pt-2">
|
|
186
|
+
<div className="flex items-center gap-1.5 text-[10px] text-warning/70">
|
|
187
|
+
<span>Overage active</span>
|
|
188
|
+
{(function () {
|
|
189
|
+
var oe = entries.find(function (e) { return e.isUsingOverage && e.overageResetsAt; });
|
|
190
|
+
if (oe && oe.overageResetsAt) return <span className="text-base-content/30">resets in {formatResetTime(oe.overageResetsAt)}</span>;
|
|
191
|
+
return null;
|
|
192
|
+
})()}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
71
197
|
</div>
|
|
72
198
|
)}
|
|
73
199
|
</div>
|
|
@@ -79,6 +205,7 @@ export function UserIsland(props: UserIslandProps) {
|
|
|
79
205
|
role="group"
|
|
80
206
|
aria-label="User controls"
|
|
81
207
|
>
|
|
208
|
+
{usageBar}
|
|
82
209
|
{budgetBar}
|
|
83
210
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
84
211
|
<button
|