@castlekit/castle 0.1.6 → 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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/drizzle.config.ts +7 -0
  3. package/install.sh +20 -1
  4. package/next.config.ts +1 -0
  5. package/package.json +35 -3
  6. package/src/app/api/avatars/[id]/route.ts +57 -7
  7. package/src/app/api/openclaw/agents/route.ts +7 -1
  8. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  9. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  10. package/src/app/api/openclaw/chat/channels/route.ts +217 -0
  11. package/src/app/api/openclaw/chat/route.ts +283 -0
  12. package/src/app/api/openclaw/chat/search/route.ts +150 -0
  13. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  14. package/src/app/api/openclaw/config/route.ts +2 -0
  15. package/src/app/api/openclaw/events/route.ts +23 -8
  16. package/src/app/api/openclaw/logs/route.ts +17 -3
  17. package/src/app/api/openclaw/ping/route.ts +5 -0
  18. package/src/app/api/openclaw/restart/route.ts +6 -1
  19. package/src/app/api/openclaw/session/context/route.ts +163 -0
  20. package/src/app/api/openclaw/session/status/route.ts +210 -0
  21. package/src/app/api/openclaw/sessions/route.ts +2 -0
  22. package/src/app/api/settings/avatar/route.ts +190 -0
  23. package/src/app/api/settings/route.ts +88 -0
  24. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  25. package/src/app/chat/[channelId]/page.tsx +385 -0
  26. package/src/app/chat/layout.tsx +96 -0
  27. package/src/app/chat/page.tsx +52 -0
  28. package/src/app/globals.css +99 -2
  29. package/src/app/layout.tsx +7 -1
  30. package/src/app/page.tsx +59 -25
  31. package/src/app/settings/page.tsx +300 -0
  32. package/src/components/chat/agent-mention-popup.tsx +89 -0
  33. package/src/components/chat/archived-channels.tsx +190 -0
  34. package/src/components/chat/channel-list.tsx +140 -0
  35. package/src/components/chat/chat-input.tsx +328 -0
  36. package/src/components/chat/create-channel-dialog.tsx +171 -0
  37. package/src/components/chat/markdown-content.tsx +205 -0
  38. package/src/components/chat/message-bubble.tsx +168 -0
  39. package/src/components/chat/message-list.tsx +666 -0
  40. package/src/components/chat/message-queue.tsx +68 -0
  41. package/src/components/chat/session-divider.tsx +61 -0
  42. package/src/components/chat/session-stats-panel.tsx +444 -0
  43. package/src/components/chat/storage-indicator.tsx +76 -0
  44. package/src/components/layout/sidebar.tsx +126 -45
  45. package/src/components/layout/user-menu.tsx +29 -4
  46. package/src/components/providers/presence-provider.tsx +8 -0
  47. package/src/components/providers/search-provider.tsx +110 -0
  48. package/src/components/search/search-dialog.tsx +269 -0
  49. package/src/components/ui/avatar.tsx +11 -9
  50. package/src/components/ui/dialog.tsx +10 -4
  51. package/src/components/ui/tooltip.tsx +25 -8
  52. package/src/components/ui/twemoji-text.tsx +37 -0
  53. package/src/lib/api-security.ts +125 -0
  54. package/src/lib/date-utils.ts +79 -0
  55. package/src/lib/db/index.ts +652 -0
  56. package/src/lib/db/queries.ts +1144 -0
  57. package/src/lib/db/schema.ts +164 -0
  58. package/src/lib/gateway-connection.ts +24 -3
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +753 -0
  61. package/src/lib/hooks/use-compaction-events.ts +132 -0
  62. package/src/lib/hooks/use-context-boundary.ts +82 -0
  63. package/src/lib/hooks/use-openclaw.ts +122 -100
  64. package/src/lib/hooks/use-search.ts +114 -0
  65. package/src/lib/hooks/use-session-stats.ts +60 -0
  66. package/src/lib/hooks/use-user-settings.ts +46 -0
  67. package/src/lib/sse-singleton.ts +184 -0
  68. package/src/lib/types/chat.ts +202 -0
  69. package/src/lib/types/search.ts +60 -0
  70. package/src/middleware.ts +52 -0
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { X, Send, Loader2 } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface MessageQueueProps {
8
+ messages: string[];
9
+ onRemove: (index: number) => void;
10
+ onSendAll: () => void;
11
+ sending?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ export function MessageQueue({ messages, onRemove, onSendAll, sending, className }: MessageQueueProps) {
16
+ if (messages.length === 0) {
17
+ return null;
18
+ }
19
+
20
+ return (
21
+ <div className={cn("space-y-3", className)}>
22
+ {/* Header */}
23
+ <div className="flex items-center justify-between">
24
+ <span className="text-sm font-medium text-foreground-secondary">
25
+ Queued ({messages.length})
26
+ </span>
27
+ <Button
28
+ variant="primary"
29
+ size="sm"
30
+ onClick={onSendAll}
31
+ disabled={sending}
32
+ className="h-7"
33
+ >
34
+ {sending ? (
35
+ <>
36
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
37
+ Sending...
38
+ </>
39
+ ) : (
40
+ <>
41
+ <Send className="h-3 w-3 mr-1" />
42
+ Send All
43
+ </>
44
+ )}
45
+ </Button>
46
+ </div>
47
+
48
+ {/* Queued messages */}
49
+ <div className="space-y-2">
50
+ {messages.map((message, index) => (
51
+ <div
52
+ key={index}
53
+ className="relative p-3 rounded-xl border border-dashed border-border bg-surface-hover/50"
54
+ >
55
+ <button
56
+ onClick={() => onRemove(index)}
57
+ className="absolute top-2 right-2 p-1 rounded-full hover:bg-surface-hover text-foreground-secondary hover:text-foreground"
58
+ disabled={sending}
59
+ >
60
+ <X className="h-3 w-3" />
61
+ </button>
62
+ <p className="text-sm pr-6 line-clamp-2">{message}</p>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, ChevronUp } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import { formatDateTime } from "@/lib/date-utils";
7
+ import type { ChannelSession } from "@/lib/types/chat";
8
+
9
+ interface SessionDividerProps {
10
+ session: ChannelSession;
11
+ className?: string;
12
+ }
13
+
14
+ export function SessionDivider({ session, className }: SessionDividerProps) {
15
+ const [expanded, setExpanded] = useState(false);
16
+
17
+ const endedDate = session.endedAt
18
+ ? formatDateTime(session.endedAt)
19
+ : "Ongoing";
20
+
21
+ return (
22
+ <div className={cn("relative py-4", className)}>
23
+ {/* Divider line */}
24
+ <div className="absolute inset-x-0 top-1/2 h-px bg-border" />
25
+
26
+ {/* Content */}
27
+ <div className="relative flex items-center justify-center">
28
+ <div className="bg-surface px-4 py-2 rounded-full border border-border">
29
+ <button
30
+ onClick={() => setExpanded(!expanded)}
31
+ className="flex items-center gap-2 text-xs text-foreground-secondary hover:text-foreground transition-colors"
32
+ >
33
+ <span>Session ended {endedDate}</span>
34
+ {session.summary && (
35
+ expanded ? (
36
+ <ChevronUp className="h-3 w-3" />
37
+ ) : (
38
+ <ChevronDown className="h-3 w-3" />
39
+ )
40
+ )}
41
+ </button>
42
+ </div>
43
+ </div>
44
+
45
+ {/* Expanded summary */}
46
+ {expanded && session.summary && (
47
+ <div className="mt-3 mx-auto max-w-xl p-4 rounded-xl bg-surface-hover border border-border">
48
+ <p className="text-sm text-foreground-secondary leading-relaxed">
49
+ <span className="font-medium text-foreground">Summary:</span>{" "}
50
+ {session.summary}
51
+ </p>
52
+ <div className="mt-2 flex items-center gap-4 text-xs text-foreground-secondary/60">
53
+ <span>
54
+ {session.totalInputTokens.toLocaleString()} in / {session.totalOutputTokens.toLocaleString()} out
55
+ </span>
56
+ </div>
57
+ </div>
58
+ )}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,444 @@
1
+ "use client";
2
+
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";
15
+ import { cn } from "@/lib/utils";
16
+ import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
17
+ import type { SessionStatus } from "@/lib/types/chat";
18
+
19
+ interface SessionStatsPanelProps {
20
+ stats: SessionStatus | null;
21
+ isLoading?: boolean;
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;
27
+ }
28
+
29
+ /** Simple relative time helper */
30
+ function timeAgo(timestamp: number): string {
31
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
32
+ if (seconds < 5) return "just now";
33
+ if (seconds < 60) return `${seconds}s ago`;
34
+ const minutes = Math.floor(seconds / 60);
35
+ if (minutes < 60) return `${minutes}m ago`;
36
+ const hours = Math.floor(minutes / 60);
37
+ if (hours < 24) return `${hours}h ago`;
38
+ const days = Math.floor(hours / 24);
39
+ return `${days}d ago`;
40
+ }
41
+
42
+ /** Get context dot/bar color based on usage percentage */
43
+ function getContextColor(percentage: number): string {
44
+ if (percentage >= 90) return "bg-error";
45
+ if (percentage >= 80) return "bg-orange-500";
46
+ if (percentage >= 60) return "bg-yellow-500";
47
+ return "bg-success";
48
+ }
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
+
65
+ function formatTokens(n: number): string {
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
+ }
74
+ return String(n);
75
+ }
76
+
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
+ }, []);
113
+
114
+ // Cleanup on unmount
115
+ useLayoutEffect(() => {
116
+ return () => roRef.current?.disconnect();
117
+ }, []);
118
+
119
+ const percentage = stats?.context?.percentage ?? 0;
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;
150
+
151
+ return (
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"
159
+ >
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
273
+ </span>
274
+ <span>
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
279
+ </span>
280
+ </div>
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>
318
+ )}
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)})`}
347
+ />
348
+ </div>
349
+
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
+ )}
378
+ </div>
379
+ )}
380
+
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>
416
+ </div>
417
+ );
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
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { HardDrive, AlertTriangle } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface StorageData {
8
+ messages: number;
9
+ channels: number;
10
+ attachments: number;
11
+ totalAttachmentBytes: number;
12
+ dbSizeBytes: number;
13
+ attachmentsDirBytes: number;
14
+ warnings: string[];
15
+ }
16
+
17
+ function formatBytes(bytes: number): string {
18
+ if (bytes === 0) return "0 B";
19
+ if (bytes < 1024) return `${bytes} B`;
20
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
21
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
22
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
23
+ }
24
+
25
+ interface StorageIndicatorProps {
26
+ className?: string;
27
+ }
28
+
29
+ export function StorageIndicator({ className }: StorageIndicatorProps) {
30
+ const [data, setData] = useState<StorageData | null>(null);
31
+
32
+ useEffect(() => {
33
+ async function fetchStats() {
34
+ try {
35
+ const res = await fetch("/api/openclaw/chat/storage");
36
+ if (res.ok) {
37
+ setData(await res.json());
38
+ }
39
+ } catch {
40
+ // Silently fail — storage indicator is non-critical
41
+ }
42
+ }
43
+
44
+ fetchStats();
45
+ // Refresh every 5 minutes
46
+ const interval = setInterval(fetchStats, 300000);
47
+ return () => clearInterval(interval);
48
+ }, []);
49
+
50
+ if (!data) return null;
51
+
52
+ const hasWarnings = data.warnings && data.warnings.length > 0;
53
+ const totalSize = (data.dbSizeBytes || 0) + (data.attachmentsDirBytes || 0);
54
+
55
+ return (
56
+ <div className={cn("px-4 py-2 border-t border-border", className)}>
57
+ <div className="flex items-center justify-between text-xs text-foreground-secondary">
58
+ <div className="flex items-center gap-2">
59
+ {hasWarnings ? (
60
+ <AlertTriangle className="h-3 w-3 text-warning" />
61
+ ) : (
62
+ <HardDrive className="h-3 w-3" />
63
+ )}
64
+ <span>
65
+ {data.messages.toLocaleString()} messages · {formatBytes(totalSize)}
66
+ </span>
67
+ </div>
68
+ {hasWarnings && (
69
+ <span className="text-warning text-xs truncate ml-2 max-w-[200px]" title={data.warnings.join("; ")}>
70
+ {data.warnings[0]}
71
+ </span>
72
+ )}
73
+ </div>
74
+ </div>
75
+ );
76
+ }