@cryptiklemur/lattice 1.46.7 → 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.
@@ -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, isBusy, busyOwner, isPlanMode, pendingPrefill, activateSession, budgetStatus, budgetExceeded, sendBudgetOverride, dismissBudgetExceeded } = useSession();
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
- {isBusy && !isProcessing && (
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 && !isBusy && (
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 || isBusy || (budgetStatus !== null && budgetStatus.enforcement === "hard-block" && budgetStatus.dailySpend >= budgetStatus.dailyLimit)}
1021
+ disabled={!activeSessionId || !online || (budgetStatus !== null && budgetStatus.enforcement === "hard-block" && budgetStatus.dailySpend >= budgetStatus.dailyLimit)}
1083
1022
  disabledPlaceholder={
1084
- isBusy
1085
- ? (busyOwner === "cli"
1086
- ? "Controlled by Claude Code CLI"
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" } : undefined,
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="h-1.5 rounded-full bg-base-content/8 overflow-hidden">
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 " + barColor}
64
- style={{ width: pct + "%" }}
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-3 right-3 mb-1.5 px-2.5 py-1.5 bg-base-100 border border-base-content/10 rounded-lg shadow-lg z-50 text-[11px] font-mono text-base-content/70 whitespace-nowrap">
69
- <div>Daily spend: ${budgetStatus.dailySpend.toFixed(2)} / ${budgetStatus.dailyLimit.toFixed(2)}</div>
70
- <div className="text-base-content/40">Remaining: ${remaining.toFixed(2)}</div>
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