@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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/install.sh +20 -1
  3. package/package.json +17 -2
  4. package/src/app/api/openclaw/agents/route.ts +7 -1
  5. package/src/app/api/openclaw/chat/channels/route.ts +6 -3
  6. package/src/app/api/openclaw/chat/route.ts +17 -6
  7. package/src/app/api/openclaw/chat/search/route.ts +2 -1
  8. package/src/app/api/openclaw/config/route.ts +2 -0
  9. package/src/app/api/openclaw/events/route.ts +23 -8
  10. package/src/app/api/openclaw/ping/route.ts +5 -0
  11. package/src/app/api/openclaw/session/context/route.ts +163 -0
  12. package/src/app/api/openclaw/session/status/route.ts +179 -11
  13. package/src/app/api/openclaw/sessions/route.ts +2 -0
  14. package/src/app/chat/[channelId]/page.tsx +115 -35
  15. package/src/app/globals.css +10 -0
  16. package/src/app/page.tsx +10 -8
  17. package/src/components/chat/chat-input.tsx +23 -5
  18. package/src/components/chat/message-bubble.tsx +29 -13
  19. package/src/components/chat/message-list.tsx +238 -80
  20. package/src/components/chat/session-stats-panel.tsx +391 -86
  21. package/src/components/providers/search-provider.tsx +33 -4
  22. package/src/lib/db/index.ts +12 -2
  23. package/src/lib/db/queries.ts +199 -72
  24. package/src/lib/db/schema.ts +4 -0
  25. package/src/lib/gateway-connection.ts +24 -3
  26. package/src/lib/hooks/use-chat.ts +219 -241
  27. package/src/lib/hooks/use-compaction-events.ts +132 -0
  28. package/src/lib/hooks/use-context-boundary.ts +82 -0
  29. package/src/lib/hooks/use-openclaw.ts +44 -57
  30. package/src/lib/hooks/use-search.ts +1 -0
  31. package/src/lib/hooks/use-session-stats.ts +4 -1
  32. package/src/lib/sse-singleton.ts +184 -0
  33. package/src/lib/types/chat.ts +22 -6
  34. package/src/lib/db/__tests__/queries.test.ts +0 -318
  35. package/vitest.config.ts +0 -13
@@ -1,17 +1,32 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { ChevronDown, ChevronUp, Brain, Zap } from "lucide-react";
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 — avoids adding date-fns dependency */
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) return `${(n / 1_000_000).toFixed(1)}M`;
37
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
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
- export function SessionStatsPanel({ stats, isLoading, className }: SessionStatsPanelProps) {
42
- const [expanded, setExpanded] = useState(false);
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
- if (!stats && !isLoading) return null;
114
+ // Cleanup on unmount
115
+ useLayoutEffect(() => {
116
+ return () => roRef.current?.disconnect();
117
+ }, []);
45
118
 
46
119
  const percentage = stats?.context?.percentage ?? 0;
47
- const contextColor = getContextColor(percentage);
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
- <div className={cn("border-b border-border", className)}>
51
- <button
52
- type="button"
53
- onClick={() => setExpanded(!expanded)}
54
- className="w-full px-4 py-2 flex items-center justify-between text-xs text-foreground-secondary hover:bg-surface-hover/50 transition-colors"
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
- {/* Collapsed view */}
57
- {stats ? (
58
- <div className="flex items-center gap-3 min-w-0">
59
- <span className="flex items-center gap-1">
60
- <span className={cn("w-2 h-2 rounded-full", contextColor)} />
61
- Context: {formatTokens(stats.context.used)}/{formatTokens(stats.context.limit)} ({percentage}%)
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
- Tokens: {formatTokens(stats.tokens.input)} in / {formatTokens(stats.tokens.output)} out
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
- <span className="text-foreground-secondary/60">
70
- {isLoading ? "Loading session stats..." : "No session data"}
71
- </span>
72
- )}
73
- {expanded ? (
74
- <ChevronUp className="h-3 w-3 shrink-0 ml-2" />
75
- ) : (
76
- <ChevronDown className="h-3 w-3 shrink-0 ml-2" />
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
- </button>
79
-
80
- {/* Expanded view */}
81
- {expanded && stats && (
82
- <div className="px-4 pb-3 space-y-3">
83
- {/* Context progress bar */}
84
- <div>
85
- <div className="flex items-center justify-between text-xs mb-1">
86
- <span className="text-foreground-secondary">Context Window</span>
87
- <span className="font-mono">
88
- {formatTokens(stats.context.used)} / {formatTokens(stats.context.limit)}
89
- </span>
90
- </div>
91
- <div className="w-full h-2 bg-surface-hover rounded-full overflow-hidden">
92
- <div
93
- className={cn("h-full rounded-full transition-all duration-300", contextColor)}
94
- style={{ width: `${Math.min(percentage, 100)}%` }}
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
- {/* Details grid */}
100
- <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
101
- <div className="flex items-center gap-2">
102
- <span className="text-foreground-secondary">Model</span>
103
- <span className="font-medium truncate">{stats.model}</span>
104
- </div>
105
- <div className="flex items-center gap-2">
106
- <span className="text-foreground-secondary">Runtime</span>
107
- <span className="font-medium">{stats.runtime}</span>
108
- </div>
109
- <div className="flex items-center gap-2">
110
- <Brain className="h-3 w-3 text-foreground-secondary" />
111
- <span className="text-foreground-secondary">Thinking</span>
112
- <span className="font-medium">{stats.thinking || "off"}</span>
113
- </div>
114
- <div className="flex items-center gap-2">
115
- <Zap className="h-3 w-3 text-foreground-secondary" />
116
- <span className="text-foreground-secondary">Compactions</span>
117
- <span className="font-medium">{stats.compactions}</span>
118
- </div>
119
- <div className="flex items-center gap-2">
120
- <span className="text-foreground-secondary">Input tokens</span>
121
- <span className="font-medium">{stats.tokens.input.toLocaleString()}</span>
122
- </div>
123
- <div className="flex items-center gap-2">
124
- <span className="text-foreground-secondary">Output tokens</span>
125
- <span className="font-medium">{stats.tokens.output.toLocaleString()}</span>
126
- </div>
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
- {/* Updated timestamp */}
130
- {stats.updatedAt && (
131
- <div className="text-xs text-foreground-secondary/60 text-right">
132
- Updated {timeAgo(stats.updatedAt)}
133
- </div>
134
- )}
135
- </div>
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 the floating search trigger on pages where it would overlap or isn't relevant
43
- const showSearchBar = pathname !== "/settings" && pathname !== "/ui-kit";
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 — top right (hidden on settings/ui-kit) */}
65
- {showSearchBar && (
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"
@@ -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 = 5;
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