@castlekit/castle 0.3.0 → 0.3.1
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/LICENSE +21 -0
- package/install.sh +20 -1
- package/package.json +17 -2
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/chat/channels/route.ts +6 -3
- package/src/app/api/openclaw/chat/route.ts +17 -6
- package/src/app/api/openclaw/chat/search/route.ts +2 -1
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +179 -11
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/chat/[channelId]/page.tsx +115 -35
- package/src/app/globals.css +10 -0
- package/src/app/page.tsx +10 -8
- package/src/components/chat/chat-input.tsx +23 -5
- package/src/components/chat/message-bubble.tsx +29 -13
- package/src/components/chat/message-list.tsx +238 -80
- package/src/components/chat/session-stats-panel.tsx +391 -86
- package/src/components/providers/search-provider.tsx +33 -4
- package/src/lib/db/index.ts +12 -2
- package/src/lib/db/queries.ts +199 -72
- package/src/lib/db/schema.ts +4 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-chat.ts +219 -241
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +44 -57
- package/src/lib/hooks/use-search.ts +1 -0
- package/src/lib/hooks/use-session-stats.ts +4 -1
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +22 -6
- package/src/lib/db/__tests__/queries.test.ts +0 -318
- package/vitest.config.ts +0 -13
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import {
|
|
3
|
+
import { useState, useRef, useCallback, useLayoutEffect } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Brain,
|
|
6
|
+
Minimize2,
|
|
7
|
+
Loader2,
|
|
8
|
+
Eye,
|
|
9
|
+
FileText,
|
|
10
|
+
Wrench,
|
|
11
|
+
Sparkles,
|
|
12
|
+
FolderOpen,
|
|
13
|
+
Activity,
|
|
14
|
+
} from "lucide-react";
|
|
5
15
|
import { cn } from "@/lib/utils";
|
|
16
|
+
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
6
17
|
import type { SessionStatus } from "@/lib/types/chat";
|
|
7
18
|
|
|
8
19
|
interface SessionStatsPanelProps {
|
|
9
20
|
stats: SessionStatus | null;
|
|
10
21
|
isLoading?: boolean;
|
|
11
22
|
className?: string;
|
|
23
|
+
/** Whether a compaction is currently in progress */
|
|
24
|
+
isCompacting?: boolean;
|
|
25
|
+
/** Live compaction count observed via SSE (since mount) */
|
|
26
|
+
liveCompactionCount?: number;
|
|
12
27
|
}
|
|
13
28
|
|
|
14
|
-
/** Simple relative time helper
|
|
29
|
+
/** Simple relative time helper */
|
|
15
30
|
function timeAgo(timestamp: number): string {
|
|
16
31
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
17
32
|
if (seconds < 5) return "just now";
|
|
@@ -24,7 +39,7 @@ function timeAgo(timestamp: number): string {
|
|
|
24
39
|
return `${days}d ago`;
|
|
25
40
|
}
|
|
26
41
|
|
|
27
|
-
/** Get context bar color based on usage percentage */
|
|
42
|
+
/** Get context dot/bar color based on usage percentage */
|
|
28
43
|
function getContextColor(percentage: number): string {
|
|
29
44
|
if (percentage >= 90) return "bg-error";
|
|
30
45
|
if (percentage >= 80) return "bg-orange-500";
|
|
@@ -32,108 +47,398 @@ function getContextColor(percentage: number): string {
|
|
|
32
47
|
return "bg-success";
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
function getContextTextColor(percentage: number): string {
|
|
51
|
+
if (percentage >= 90) return "text-error/60 group-hover:text-error";
|
|
52
|
+
if (percentage >= 80) return "text-orange-500/55 group-hover:text-orange-500";
|
|
53
|
+
if (percentage >= 60) return "text-yellow-500/55 group-hover:text-yellow-500";
|
|
54
|
+
return "text-foreground-secondary/60 group-hover:text-foreground-secondary";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Returns a raw CSS color for inline styles */
|
|
58
|
+
function getContextColorValue(percentage: number): string {
|
|
59
|
+
if (percentage >= 90) return "#ef4444";
|
|
60
|
+
if (percentage >= 80) return "#f97316";
|
|
61
|
+
if (percentage >= 60) return "#eab308";
|
|
62
|
+
return "#22c55e";
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
function formatTokens(n: number): string {
|
|
36
|
-
if (n >= 1_000_000)
|
|
37
|
-
|
|
66
|
+
if (n >= 1_000_000) {
|
|
67
|
+
const v = n / 1_000_000;
|
|
68
|
+
return `${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}M`;
|
|
69
|
+
}
|
|
70
|
+
if (n >= 1_000) {
|
|
71
|
+
const v = n / 1_000;
|
|
72
|
+
return `${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}k`;
|
|
73
|
+
}
|
|
38
74
|
return String(n);
|
|
39
75
|
}
|
|
40
76
|
|
|
41
|
-
|
|
42
|
-
|
|
77
|
+
function formatChars(n: number): string {
|
|
78
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M chars`;
|
|
79
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k chars`;
|
|
80
|
+
return `${n} chars`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Compact stats indicator — sits between text input and send button
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export function SessionStatsIndicator({
|
|
88
|
+
stats,
|
|
89
|
+
isLoading,
|
|
90
|
+
isCompacting,
|
|
91
|
+
}: SessionStatsPanelProps) {
|
|
92
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
93
|
+
const [boxSize, setBoxSize] = useState({ w: 0, h: 0 });
|
|
94
|
+
const boxElRef = useRef<HTMLDivElement | null>(null);
|
|
95
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
96
|
+
|
|
97
|
+
const boxRef = useCallback((el: HTMLDivElement | null) => {
|
|
98
|
+
// Cleanup previous observer
|
|
99
|
+
if (roRef.current) {
|
|
100
|
+
roRef.current.disconnect();
|
|
101
|
+
roRef.current = null;
|
|
102
|
+
}
|
|
103
|
+
boxElRef.current = el;
|
|
104
|
+
if (el) {
|
|
105
|
+
setBoxSize({ w: el.offsetWidth, h: el.offsetHeight });
|
|
106
|
+
const ro = new ResizeObserver(() => {
|
|
107
|
+
setBoxSize({ w: el.offsetWidth, h: el.offsetHeight });
|
|
108
|
+
});
|
|
109
|
+
ro.observe(el);
|
|
110
|
+
roRef.current = ro;
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
43
113
|
|
|
44
|
-
|
|
114
|
+
// Cleanup on unmount
|
|
115
|
+
useLayoutEffect(() => {
|
|
116
|
+
return () => roRef.current?.disconnect();
|
|
117
|
+
}, []);
|
|
45
118
|
|
|
46
119
|
const percentage = stats?.context?.percentage ?? 0;
|
|
47
|
-
const
|
|
120
|
+
const textColor = getContextTextColor(percentage);
|
|
121
|
+
const progressColor = getContextColorValue(percentage);
|
|
122
|
+
const displayCompactions = stats?.compactions ?? 0;
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
// Build CCW path starting from a point on the top edge (offset from TL),
|
|
126
|
+
// then left to TL corner → down left → bottom → up right → TR → top edge → Z
|
|
127
|
+
const r = 6;
|
|
128
|
+
const s = 0.75; // inset = half stroke width
|
|
129
|
+
const { w, h } = boxSize;
|
|
130
|
+
// Start point: offset along the top edge so initial fill is visible there
|
|
131
|
+
const startX = w > 0 ? Math.min(r + s + 5, w * 0.2) : 0;
|
|
132
|
+
const pathD =
|
|
133
|
+
w > 0 && h > 0
|
|
134
|
+
? [
|
|
135
|
+
`M ${startX} ${s}`, // start: top edge, offset from left
|
|
136
|
+
`L ${r + s} ${s}`, // left along top to TL corner
|
|
137
|
+
`A ${r} ${r} 0 0 0 ${s} ${r + s}`, // TL corner arc (CCW)
|
|
138
|
+
`L ${s} ${h - r - s}`, // down left edge
|
|
139
|
+
`A ${r} ${r} 0 0 0 ${r + s} ${h - s}`, // BL corner arc
|
|
140
|
+
`L ${w - r - s} ${h - s}`, // right along bottom
|
|
141
|
+
`A ${r} ${r} 0 0 0 ${w - s} ${h - r - s}`, // BR corner arc
|
|
142
|
+
`L ${w - s} ${r + s}`, // up right edge
|
|
143
|
+
`A ${r} ${r} 0 0 0 ${w - r - s} ${s}`, // TR corner arc
|
|
144
|
+
`Z`, // top edge back to start
|
|
145
|
+
].join(" ")
|
|
146
|
+
: "";
|
|
147
|
+
|
|
148
|
+
// Always fill at least a visible initial segment on the top edge
|
|
149
|
+
const minPct = stats ? Math.max(percentage, 3) : 0;
|
|
48
150
|
|
|
49
151
|
return (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
className="
|
|
152
|
+
<>
|
|
153
|
+
{/* Stats box with SVG border progress */}
|
|
154
|
+
<div
|
|
155
|
+
ref={boxRef}
|
|
156
|
+
className="group relative h-[38px] min-w-[146px] shrink-0 rounded-[var(--radius-sm)] cursor-pointer"
|
|
157
|
+
onClick={() => setModalOpen(true)}
|
|
158
|
+
title="View session details"
|
|
55
159
|
>
|
|
56
|
-
{/*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
160
|
+
{/* Track border */}
|
|
161
|
+
<div className="absolute inset-0 rounded-[var(--radius-sm)] border border-border" />
|
|
162
|
+
{/* Progress border — CCW from top-left */}
|
|
163
|
+
{pathD && (
|
|
164
|
+
<svg className="absolute inset-0 w-full h-full opacity-45 group-hover:opacity-100 transition-opacity" fill="none">
|
|
165
|
+
<path
|
|
166
|
+
d={pathD}
|
|
167
|
+
stroke={progressColor}
|
|
168
|
+
strokeWidth="1.5"
|
|
169
|
+
pathLength="100"
|
|
170
|
+
strokeDasharray={`${minPct} ${100 - minPct}`}
|
|
171
|
+
/>
|
|
172
|
+
</svg>
|
|
173
|
+
)}
|
|
174
|
+
{/* Content */}
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="relative h-full w-full rounded-[var(--radius-sm)] bg-transparent flex items-center justify-center gap-2.5 px-3 text-xs text-foreground-secondary hover:text-foreground transition-colors cursor-pointer"
|
|
178
|
+
>
|
|
179
|
+
{stats ? (
|
|
180
|
+
<>
|
|
181
|
+
<span className={cn("tabular-nums whitespace-nowrap transition-colors", textColor)}>
|
|
182
|
+
{formatTokens(stats.context.used)} · {percentage}%
|
|
183
|
+
</span>
|
|
184
|
+
{isCompacting && (
|
|
185
|
+
<Loader2 className="h-3 w-3 animate-spin text-yellow-500" />
|
|
186
|
+
)}
|
|
187
|
+
<span className="w-px h-4 bg-border shrink-0" />
|
|
188
|
+
<span className="flex items-center gap-0.5 text-foreground-secondary/60 group-hover:text-foreground-secondary transition-colors">
|
|
189
|
+
<Minimize2 className="h-3.5 w-3.5" />
|
|
190
|
+
{displayCompactions}
|
|
191
|
+
</span>
|
|
192
|
+
</>
|
|
193
|
+
) : (
|
|
194
|
+
<>
|
|
195
|
+
<span className="skeleton h-3 w-[72px] rounded" />
|
|
196
|
+
<span className="w-px h-4 bg-border/50 shrink-0" />
|
|
197
|
+
<span className="skeleton h-3 w-[28px] rounded" />
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Full stats modal */}
|
|
204
|
+
<SessionStatsModal
|
|
205
|
+
open={modalOpen}
|
|
206
|
+
onClose={() => setModalOpen(false)}
|
|
207
|
+
stats={stats}
|
|
208
|
+
isCompacting={isCompacting}
|
|
209
|
+
/>
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Full stats modal — beautiful breakdown of session state
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
function SessionStatsModal({
|
|
219
|
+
open,
|
|
220
|
+
onClose,
|
|
221
|
+
stats,
|
|
222
|
+
isCompacting,
|
|
223
|
+
}: {
|
|
224
|
+
open: boolean;
|
|
225
|
+
onClose: () => void;
|
|
226
|
+
stats: SessionStatus | null;
|
|
227
|
+
isCompacting?: boolean;
|
|
228
|
+
}) {
|
|
229
|
+
if (!stats) return null;
|
|
230
|
+
|
|
231
|
+
const percentage = stats.context.percentage;
|
|
232
|
+
const contextColor = getContextColor(percentage);
|
|
233
|
+
const headroom = stats.context.limit - stats.context.used;
|
|
234
|
+
const headroomPct = Math.max(0, 100 - percentage);
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<Dialog open={open} onClose={onClose} className="max-w-md">
|
|
238
|
+
<DialogHeader>
|
|
239
|
+
<DialogTitle className="flex items-center gap-2">
|
|
240
|
+
<Activity className="h-5 w-5 text-foreground-secondary" />
|
|
241
|
+
Session
|
|
242
|
+
</DialogTitle>
|
|
243
|
+
<p className="text-sm text-foreground-secondary mt-1">
|
|
244
|
+
{stats.model} · {stats.modelProvider}
|
|
245
|
+
</p>
|
|
246
|
+
</DialogHeader>
|
|
247
|
+
|
|
248
|
+
<div className="space-y-5">
|
|
249
|
+
{/* Context window — hero section */}
|
|
250
|
+
<div className="space-y-2">
|
|
251
|
+
<div className="flex items-center justify-between text-sm">
|
|
252
|
+
<span className="text-foreground-secondary flex items-center gap-1.5">
|
|
253
|
+
<Eye className="h-4 w-4" />
|
|
254
|
+
Context Window
|
|
255
|
+
</span>
|
|
256
|
+
<span className="font-mono font-medium">
|
|
257
|
+
{percentage}%
|
|
258
|
+
</span>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Progress bar */}
|
|
262
|
+
<div className="w-full h-4 bg-surface-hover rounded-full overflow-hidden">
|
|
263
|
+
<div
|
|
264
|
+
className={cn("h-full rounded-full transition-all duration-500", contextColor)}
|
|
265
|
+
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Token counts */}
|
|
270
|
+
<div className="flex items-center justify-between text-xs text-foreground-secondary">
|
|
271
|
+
<span>
|
|
272
|
+
<span className="font-medium text-foreground">{formatTokens(stats.context.used)}</span> used
|
|
62
273
|
</span>
|
|
63
|
-
<span className="text-foreground-secondary/60">|</span>
|
|
64
274
|
<span>
|
|
65
|
-
|
|
275
|
+
<span className="font-medium text-foreground">{formatTokens(headroom)}</span> headroom
|
|
276
|
+
</span>
|
|
277
|
+
<span>
|
|
278
|
+
<span className="font-medium text-foreground">{formatTokens(stats.context.limit)}</span> limit
|
|
66
279
|
</span>
|
|
67
280
|
</div>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
281
|
+
|
|
282
|
+
{stats.context.modelMax > stats.context.limit && (
|
|
283
|
+
<p className="text-[11px] text-foreground-secondary/50">
|
|
284
|
+
Model supports {formatTokens(stats.context.modelMax)} — limit set to {formatTokens(stats.context.limit)} in config
|
|
285
|
+
</p>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* Stats grid */}
|
|
290
|
+
<div className="grid grid-cols-2 gap-4">
|
|
291
|
+
<StatCard
|
|
292
|
+
icon={<Brain className="h-4 w-4" />}
|
|
293
|
+
label="Thinking"
|
|
294
|
+
value={stats.thinkingLevel || "off"}
|
|
295
|
+
/>
|
|
296
|
+
<StatCard
|
|
297
|
+
icon={<Minimize2 className="h-4 w-4" />}
|
|
298
|
+
label="Compactions"
|
|
299
|
+
value={String(stats.compactions)}
|
|
300
|
+
highlight={stats.compactions > 0}
|
|
301
|
+
/>
|
|
302
|
+
<StatCard
|
|
303
|
+
label="Input tokens"
|
|
304
|
+
value={stats.tokens.input.toLocaleString()}
|
|
305
|
+
/>
|
|
306
|
+
<StatCard
|
|
307
|
+
label="Output tokens"
|
|
308
|
+
value={stats.tokens.output.toLocaleString()}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* Compaction status */}
|
|
313
|
+
{isCompacting && (
|
|
314
|
+
<div className="flex items-center gap-2 text-sm text-yellow-500 py-2 px-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
315
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
316
|
+
<span>Context compaction in progress...</span>
|
|
317
|
+
</div>
|
|
77
318
|
)}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
319
|
+
|
|
320
|
+
{/* System prompt breakdown */}
|
|
321
|
+
{stats.systemPrompt && (
|
|
322
|
+
<div className="space-y-3 pt-3 border-t border-border">
|
|
323
|
+
<h3 className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
|
324
|
+
<FileText className="h-4 w-4 text-foreground-secondary" />
|
|
325
|
+
System Prompt
|
|
326
|
+
</h3>
|
|
327
|
+
|
|
328
|
+
<div className="grid grid-cols-2 gap-3">
|
|
329
|
+
<MiniStat
|
|
330
|
+
icon={<FolderOpen className="h-3.5 w-3.5" />}
|
|
331
|
+
label="Project context"
|
|
332
|
+
value={formatChars(stats.systemPrompt.projectContextChars)}
|
|
333
|
+
/>
|
|
334
|
+
<MiniStat
|
|
335
|
+
label="Non-project"
|
|
336
|
+
value={formatChars(stats.systemPrompt.nonProjectContextChars)}
|
|
337
|
+
/>
|
|
338
|
+
<MiniStat
|
|
339
|
+
icon={<Sparkles className="h-3.5 w-3.5" />}
|
|
340
|
+
label="Skills"
|
|
341
|
+
value={`${stats.systemPrompt.skills.count} (${formatChars(stats.systemPrompt.skills.promptChars)})`}
|
|
342
|
+
/>
|
|
343
|
+
<MiniStat
|
|
344
|
+
icon={<Wrench className="h-3.5 w-3.5" />}
|
|
345
|
+
label="Tools"
|
|
346
|
+
value={`${stats.systemPrompt.tools.count} (${formatChars(stats.systemPrompt.tools.schemaChars)})`}
|
|
95
347
|
/>
|
|
96
348
|
</div>
|
|
97
|
-
</div>
|
|
98
349
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
350
|
+
{/* Workspace files */}
|
|
351
|
+
{stats.systemPrompt.workspaceFiles.length > 0 && (
|
|
352
|
+
<div className="space-y-1.5">
|
|
353
|
+
<p className="text-[11px] text-foreground-secondary/60 uppercase tracking-wider font-medium">
|
|
354
|
+
Workspace Files
|
|
355
|
+
</p>
|
|
356
|
+
<div className="flex flex-wrap gap-1.5">
|
|
357
|
+
{stats.systemPrompt.workspaceFiles.map((f) => (
|
|
358
|
+
<span
|
|
359
|
+
key={f.name}
|
|
360
|
+
className={cn(
|
|
361
|
+
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] bg-surface-hover border border-border/50",
|
|
362
|
+
f.truncated && "border-yellow-500/40"
|
|
363
|
+
)}
|
|
364
|
+
>
|
|
365
|
+
<FileText className="h-2.5 w-2.5 text-foreground-secondary/60" />
|
|
366
|
+
<span className="font-medium">{f.name}</span>
|
|
367
|
+
<span className="text-foreground-secondary/50">
|
|
368
|
+
{formatChars(f.injectedChars)}
|
|
369
|
+
</span>
|
|
370
|
+
{f.truncated && (
|
|
371
|
+
<span className="text-yellow-500 text-[9px] font-medium">truncated</span>
|
|
372
|
+
)}
|
|
373
|
+
</span>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
127
378
|
</div>
|
|
379
|
+
)}
|
|
128
380
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
381
|
+
{/* Footer */}
|
|
382
|
+
{stats.updatedAt > 0 && (
|
|
383
|
+
<p className="text-[11px] text-foreground-secondary/40 text-right pt-1">
|
|
384
|
+
Session updated {timeAgo(stats.updatedAt)}
|
|
385
|
+
</p>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
</Dialog>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============================================================================
|
|
393
|
+
// Small helper components
|
|
394
|
+
// ============================================================================
|
|
395
|
+
|
|
396
|
+
function StatCard({
|
|
397
|
+
icon,
|
|
398
|
+
label,
|
|
399
|
+
value,
|
|
400
|
+
highlight,
|
|
401
|
+
}: {
|
|
402
|
+
icon?: React.ReactNode;
|
|
403
|
+
label: string;
|
|
404
|
+
value: string;
|
|
405
|
+
highlight?: boolean;
|
|
406
|
+
}) {
|
|
407
|
+
return (
|
|
408
|
+
<div className="flex flex-col gap-1 p-3 rounded-lg bg-surface-hover/50 border border-border/30">
|
|
409
|
+
<span className="text-[11px] text-foreground-secondary flex items-center gap-1">
|
|
410
|
+
{icon}
|
|
411
|
+
{label}
|
|
412
|
+
</span>
|
|
413
|
+
<span className={cn("text-sm font-medium", highlight && "text-yellow-500")}>
|
|
414
|
+
{value}
|
|
415
|
+
</span>
|
|
137
416
|
</div>
|
|
138
417
|
);
|
|
139
418
|
}
|
|
419
|
+
|
|
420
|
+
function MiniStat({
|
|
421
|
+
icon,
|
|
422
|
+
label,
|
|
423
|
+
value,
|
|
424
|
+
}: {
|
|
425
|
+
icon?: React.ReactNode;
|
|
426
|
+
label: string;
|
|
427
|
+
value: string;
|
|
428
|
+
}) {
|
|
429
|
+
return (
|
|
430
|
+
<div className="flex items-center gap-2 text-xs">
|
|
431
|
+
{icon && <span className="text-foreground-secondary/60">{icon}</span>}
|
|
432
|
+
<span className="text-foreground-secondary">{label}</span>
|
|
433
|
+
<span className="font-medium ml-auto">{value}</span>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ============================================================================
|
|
439
|
+
// Keep the old export name for backwards compat (but it's now unused)
|
|
440
|
+
// ============================================================================
|
|
441
|
+
|
|
442
|
+
export function SessionStatsPanel(props: SessionStatsPanelProps) {
|
|
443
|
+
return <SessionStatsIndicator {...props} />;
|
|
444
|
+
}
|
|
@@ -25,6 +25,33 @@ export function useSearchContext() {
|
|
|
25
25
|
return useContext(SearchContext);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Reusable search trigger button (flow element — no fixed positioning)
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export function SearchTrigger({ className }: { className?: string }) {
|
|
33
|
+
const { openSearch } = useSearchContext();
|
|
34
|
+
const [isMac, setIsMac] = useState(true);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
onClick={openSearch}
|
|
43
|
+
className={className ?? "flex items-center gap-3 pl-3 pr-2.5 h-[38px] w-[320px] rounded-[var(--radius-sm)] bg-surface border border-border hover:border-border-hover text-foreground-secondary hover:text-foreground transition-colors cursor-pointer shadow-sm"}
|
|
44
|
+
>
|
|
45
|
+
<Search className="h-4 w-4 shrink-0" strokeWidth={2.5} />
|
|
46
|
+
<span className="text-sm text-foreground-secondary/50 flex-1 text-left">Search Castle...</span>
|
|
47
|
+
<kbd className="flex items-center justify-center h-[22px] px-1.5 gap-1 rounded-[4px] bg-surface-hover border border-border font-medium text-foreground-secondary">
|
|
48
|
+
{isMac ? <span className="text-[15px]">⌘</span> : <span className="text-[11px]">Ctrl</span>}
|
|
49
|
+
<span className="text-[11px]">K</span>
|
|
50
|
+
</kbd>
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
// ============================================================================
|
|
29
56
|
// Provider
|
|
30
57
|
// ============================================================================
|
|
@@ -39,8 +66,10 @@ export function SearchProvider({ children }: { children: React.ReactNode }) {
|
|
|
39
66
|
setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
|
|
40
67
|
}, []);
|
|
41
68
|
|
|
42
|
-
// Hide
|
|
43
|
-
const
|
|
69
|
+
// Hide floating trigger on pages that render their own SearchTrigger in the header
|
|
70
|
+
const showFloatingSearch =
|
|
71
|
+
!["/settings", "/ui-kit", "/"].includes(pathname) &&
|
|
72
|
+
!pathname.startsWith("/chat");
|
|
44
73
|
|
|
45
74
|
const openSearch = useCallback(() => setIsSearchOpen(true), []);
|
|
46
75
|
const closeSearch = useCallback(() => setIsSearchOpen(false), []);
|
|
@@ -61,8 +90,8 @@ export function SearchProvider({ children }: { children: React.ReactNode }) {
|
|
|
61
90
|
return (
|
|
62
91
|
<SearchContext.Provider value={{ isSearchOpen, openSearch, closeSearch }}>
|
|
63
92
|
{children}
|
|
64
|
-
{/* Floating search trigger —
|
|
65
|
-
{
|
|
93
|
+
{/* Floating search trigger — only on pages without an embedded header trigger */}
|
|
94
|
+
{showFloatingSearch && (
|
|
66
95
|
<button
|
|
67
96
|
onClick={openSearch}
|
|
68
97
|
className="fixed top-[28px] right-[28px] z-40 flex items-center gap-3 pl-3 pr-2.5 h-[38px] w-[320px] rounded-[var(--radius-sm)] bg-surface border border-border hover:border-border-hover text-foreground-secondary hover:text-foreground transition-colors cursor-pointer shadow-sm"
|
package/src/lib/db/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ const MAX_BACKUPS = 5;
|
|
|
25
25
|
const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1000;
|
|
26
26
|
|
|
27
27
|
/** Current schema version — bump when adding new migrations */
|
|
28
|
-
const SCHEMA_VERSION =
|
|
28
|
+
const SCHEMA_VERSION = 6;
|
|
29
29
|
|
|
30
30
|
// ============================================================================
|
|
31
31
|
// Singleton
|
|
@@ -353,7 +353,8 @@ const TABLE_SQL = `
|
|
|
353
353
|
ended_at INTEGER,
|
|
354
354
|
summary TEXT,
|
|
355
355
|
total_input_tokens INTEGER DEFAULT 0,
|
|
356
|
-
total_output_tokens INTEGER DEFAULT 0
|
|
356
|
+
total_output_tokens INTEGER DEFAULT 0,
|
|
357
|
+
compaction_boundary_message_id TEXT
|
|
357
358
|
);
|
|
358
359
|
CREATE INDEX IF NOT EXISTS idx_sessions_channel ON sessions(channel_id, started_at);
|
|
359
360
|
|
|
@@ -488,6 +489,15 @@ function runMigrations(
|
|
|
488
489
|
`);
|
|
489
490
|
}
|
|
490
491
|
|
|
492
|
+
// --- Migration 6: Add compaction_boundary_message_id to sessions ---
|
|
493
|
+
const sessionColsV6 = sqlite.prepare("PRAGMA table_info(sessions)").all() as {
|
|
494
|
+
name: string;
|
|
495
|
+
}[];
|
|
496
|
+
if (!sessionColsV6.some((c) => c.name === "compaction_boundary_message_id")) {
|
|
497
|
+
console.log("[Castle DB] Migration: adding compaction_boundary_message_id to sessions");
|
|
498
|
+
sqlite.exec("ALTER TABLE sessions ADD COLUMN compaction_boundary_message_id TEXT");
|
|
499
|
+
}
|
|
500
|
+
|
|
491
501
|
// Checkpoint after migration to persist changes to main DB file immediately
|
|
492
502
|
checkpointWal(sqlite, "TRUNCATE");
|
|
493
503
|
|