@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,132 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { subscribe, type SSEEvent } from "@/lib/sse-singleton";
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ export interface CompactionEvent {
11
+ phase: "start" | "end";
12
+ willRetry?: boolean;
13
+ timestamp: number;
14
+ }
15
+
16
+ interface UseCompactionEventsOptions {
17
+ /** The session key to monitor. Null disables monitoring. */
18
+ sessionKey: string | null;
19
+ /** Callback fired when compaction completes (phase: "end"). */
20
+ onCompactionComplete?: () => void;
21
+ }
22
+
23
+ interface UseCompactionEventsReturn {
24
+ /** Whether a compaction is currently in progress. */
25
+ isCompacting: boolean;
26
+ /** The last compaction event received. */
27
+ lastCompaction: CompactionEvent | null;
28
+ /** Number of compactions observed in this session (since mount). */
29
+ compactionCount: number;
30
+ /** Whether to show the compaction banner (auto-dismissed after 8s). */
31
+ showBanner: boolean;
32
+ /** Manually dismiss the banner. */
33
+ dismissBanner: () => void;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Hook
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Subscribes to real-time compaction events from the Gateway via SSE singleton.
42
+ *
43
+ * Compaction events arrive as agent events with `stream: "compaction"`:
44
+ * { stream: "compaction", data: { phase: "start"|"end", willRetry?: boolean } }
45
+ *
46
+ * These are forwarded through the SSE system as events with type starting
47
+ * with "agent." or as top-level events. We subscribe to "*" and filter.
48
+ */
49
+ export function useCompactionEvents({
50
+ sessionKey,
51
+ onCompactionComplete,
52
+ }: UseCompactionEventsOptions): UseCompactionEventsReturn {
53
+ const [isCompacting, setIsCompacting] = useState(false);
54
+ const [lastCompaction, setLastCompaction] = useState<CompactionEvent | null>(null);
55
+ const [compactionCount, setCompactionCount] = useState(0);
56
+ const [showBanner, setShowBanner] = useState(false);
57
+ const bannerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
58
+ const onCompleteRef = useRef(onCompactionComplete);
59
+ onCompleteRef.current = onCompactionComplete;
60
+
61
+ useEffect(() => {
62
+ if (!sessionKey) return;
63
+
64
+ // Subscribe to all events and filter for compaction events.
65
+ // Compaction events come through as agent events with stream: "compaction"
66
+ const unsubscribe = subscribe("*", (evt: SSEEvent) => {
67
+ // Check for compaction event payload
68
+ const payload = evt.payload as Record<string, unknown> | undefined;
69
+ if (!payload) return;
70
+
71
+ // Compaction events can arrive in several shapes depending on Gateway version.
72
+ // Look for stream === "compaction" in the payload
73
+ const stream = payload.stream as string | undefined;
74
+ if (stream !== "compaction") return;
75
+
76
+ const data = (payload.data ?? payload) as {
77
+ phase?: string;
78
+ willRetry?: boolean;
79
+ };
80
+
81
+ const phase = data.phase as "start" | "end" | undefined;
82
+ if (!phase) return;
83
+
84
+ const compEvent: CompactionEvent = {
85
+ phase,
86
+ willRetry: data.willRetry,
87
+ timestamp: Date.now(),
88
+ };
89
+
90
+ setLastCompaction(compEvent);
91
+
92
+ if (phase === "start") {
93
+ setIsCompacting(true);
94
+ } else if (phase === "end") {
95
+ setIsCompacting(false);
96
+ setCompactionCount((c) => c + 1);
97
+
98
+ // Show banner
99
+ setShowBanner(true);
100
+ if (bannerTimer.current) clearTimeout(bannerTimer.current);
101
+ bannerTimer.current = setTimeout(() => setShowBanner(false), 8000);
102
+
103
+ // Notify parent (e.g. to refresh session stats)
104
+ onCompleteRef.current?.();
105
+ }
106
+ });
107
+
108
+ return () => {
109
+ unsubscribe();
110
+ if (bannerTimer.current) {
111
+ clearTimeout(bannerTimer.current);
112
+ bannerTimer.current = null;
113
+ }
114
+ };
115
+ }, [sessionKey]);
116
+
117
+ const dismissBanner = useCallback(() => {
118
+ setShowBanner(false);
119
+ if (bannerTimer.current) {
120
+ clearTimeout(bannerTimer.current);
121
+ bannerTimer.current = null;
122
+ }
123
+ }, []);
124
+
125
+ return {
126
+ isCompacting,
127
+ lastCompaction,
128
+ compactionCount,
129
+ showBanner,
130
+ dismissBanner,
131
+ };
132
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import useSWR from "swr";
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ interface ContextBoundaryData {
11
+ boundaryMessageId: string | null;
12
+ fresh: boolean;
13
+ }
14
+
15
+ interface UseContextBoundaryOptions {
16
+ /** Gateway session key. Null disables fetching. */
17
+ sessionKey: string | null;
18
+ /** Channel ID for matching preview messages to local DB. */
19
+ channelId: string;
20
+ }
21
+
22
+ interface UseContextBoundaryReturn {
23
+ /** ID of the oldest message still in agent context. Null = no compaction yet or unknown. */
24
+ boundaryMessageId: string | null;
25
+ /** Whether data is loading. */
26
+ isLoading: boolean;
27
+ /** Re-fetch the boundary (call after compaction events). */
28
+ refresh: () => void;
29
+ }
30
+
31
+ // ============================================================================
32
+ // Fetcher
33
+ // ============================================================================
34
+
35
+ const fetcher = async (url: string): Promise<ContextBoundaryData | null> => {
36
+ const res = await fetch(url);
37
+ if (res.status === 204) return null;
38
+ if (!res.ok) return null;
39
+ return res.json();
40
+ };
41
+
42
+ // ============================================================================
43
+ // Hook
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Fetches and caches the compaction context boundary for a session.
48
+ *
49
+ * Returns the ID of the oldest message the agent can still "see".
50
+ * Messages before this boundary have been compacted (summarized).
51
+ *
52
+ * Polls infrequently (every 60s) since compaction events trigger explicit refreshes.
53
+ */
54
+ export function useContextBoundary({
55
+ sessionKey,
56
+ channelId,
57
+ }: UseContextBoundaryOptions): UseContextBoundaryReturn {
58
+ const swrKey = sessionKey
59
+ ? `/api/openclaw/session/context?sessionKey=${encodeURIComponent(sessionKey)}&channelId=${encodeURIComponent(channelId)}`
60
+ : null;
61
+
62
+ const { data, isLoading, mutate } = useSWR<ContextBoundaryData | null>(
63
+ swrKey,
64
+ fetcher,
65
+ {
66
+ refreshInterval: 60000, // Refresh every 60s as background check
67
+ dedupingInterval: 10000,
68
+ revalidateOnFocus: false,
69
+ errorRetryCount: 1,
70
+ }
71
+ );
72
+
73
+ const refresh = useCallback(() => {
74
+ mutate();
75
+ }, [mutate]);
76
+
77
+ return {
78
+ boundaryMessageId: data?.boundaryMessageId ?? null,
79
+ isLoading,
80
+ refresh,
81
+ };
82
+ }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useCallback, useRef } from "react";
3
+ import { useEffect, useCallback } from "react";
4
+ import useSWR from "swr";
5
+ import { subscribe, onError, type SSEEvent } from "@/lib/sse-singleton";
4
6
 
5
7
  // ============================================================================
6
8
  // Types
@@ -26,138 +28,158 @@ export interface OpenClawStatus {
26
28
  };
27
29
  }
28
30
 
29
- interface GatewaySSEEvent {
30
- event: string;
31
- payload?: unknown;
32
- seq?: number;
33
- }
31
+ // ============================================================================
32
+ // Fetchers
33
+ // ============================================================================
34
+
35
+ const statusFetcher = async (url: string): Promise<OpenClawStatus> => {
36
+ const res = await fetch(url, { method: "POST" });
37
+ if (!res.ok) {
38
+ console.warn(`[useOpenClaw] Status fetch returned ${res.status}`);
39
+ }
40
+ return res.json();
41
+ };
42
+
43
+ const agentsFetcher = async (url: string): Promise<OpenClawAgent[]> => {
44
+ const res = await fetch(url);
45
+ if (!res.ok) {
46
+ console.warn(`[useOpenClaw] Agents fetch failed: ${res.status}`);
47
+ return [];
48
+ }
49
+ const data = await res.json();
50
+ return data.agents || [];
51
+ };
34
52
 
35
53
  // ============================================================================
36
54
  // Hook
37
55
  // ============================================================================
38
56
 
57
+ /**
58
+ * Shared OpenClaw connection status and agents hook.
59
+ * Uses SWR for cache-based sharing across all components —
60
+ * any component calling useOpenClaw() gets the same cached data without extra fetches.
61
+ *
62
+ * SSE subscription pushes real-time updates that trigger SWR cache invalidation.
63
+ */
39
64
  export function useOpenClaw() {
40
- const [status, setStatus] = useState<OpenClawStatus | null>(null);
41
- const [agents, setAgents] = useState<OpenClawAgent[]>([]);
42
- const [isLoading, setIsLoading] = useState(true);
43
- const [agentsLoading, setAgentsLoading] = useState(true);
44
- const eventSourceRef = useRef<EventSource | null>(null);
45
-
46
- // Fetch connection status
47
- const fetchStatus = useCallback(async () => {
48
- try {
49
- const res = await fetch("/api/openclaw/ping", { method: "POST" });
50
- const data: OpenClawStatus = await res.json();
51
- setStatus(data);
52
- return data;
53
- } catch {
54
- setStatus({ ok: false, configured: false, error: "Failed to reach Castle server" });
55
- return null;
56
- } finally {
57
- setIsLoading(false);
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // SWR: Connection status
68
+ // ---------------------------------------------------------------------------
69
+ const {
70
+ data: status,
71
+ error: statusError,
72
+ isLoading: statusLoading,
73
+ mutate: mutateStatus,
74
+ } = useSWR<OpenClawStatus>(
75
+ "/api/openclaw/ping",
76
+ statusFetcher,
77
+ {
78
+ refreshInterval: 60000, // Background refresh every 60s
79
+ revalidateOnFocus: true,
80
+ dedupingInterval: 10000, // Dedup rapid calls within 10s
81
+ errorRetryCount: 2,
58
82
  }
59
- }, []);
60
-
61
- // Fetch agents
62
- const fetchAgents = useCallback(async () => {
63
- try {
64
- setAgentsLoading(true);
65
- const res = await fetch("/api/openclaw/agents");
66
- if (!res.ok) {
67
- setAgents([]);
68
- return;
69
- }
70
- const data = await res.json();
71
- setAgents(data.agents || []);
72
- } catch {
73
- setAgents([]);
74
- } finally {
75
- setAgentsLoading(false);
83
+ );
84
+
85
+ const isConnected = status?.ok ?? false;
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // SWR: Agents (conditional — only fetch when connected)
89
+ // ---------------------------------------------------------------------------
90
+ const {
91
+ data: agents,
92
+ isLoading: agentsLoading,
93
+ mutate: mutateAgents,
94
+ } = useSWR<OpenClawAgent[]>(
95
+ isConnected ? "/api/openclaw/agents" : null,
96
+ agentsFetcher,
97
+ {
98
+ refreshInterval: 300000, // Refresh agents every 5 min
99
+ revalidateOnFocus: false,
100
+ dedupingInterval: 30000, // Dedup within 30s
76
101
  }
77
- }, []);
102
+ );
78
103
 
79
- // Refresh everything
104
+ // ---------------------------------------------------------------------------
105
+ // Refresh helpers
106
+ // ---------------------------------------------------------------------------
80
107
  const refresh = useCallback(async () => {
81
- const newStatus = await fetchStatus();
82
- if (newStatus?.ok) {
83
- await fetchAgents();
84
- }
85
- }, [fetchStatus, fetchAgents]);
86
-
87
- // Initial data fetch
88
- useEffect(() => {
89
- let cancelled = false;
90
-
91
- async function init() {
92
- const s = await fetchStatus();
93
- if (!cancelled && s?.ok) {
94
- await fetchAgents();
95
- }
108
+ await mutateStatus();
109
+ if (isConnected) {
110
+ await mutateAgents();
96
111
  }
112
+ }, [mutateStatus, mutateAgents, isConnected]);
97
113
 
98
- init();
99
- return () => { cancelled = true; };
100
- }, [fetchStatus, fetchAgents]);
114
+ const refreshAgents = useCallback(() => mutateAgents(), [mutateAgents]);
101
115
 
102
- // SSE subscription for real-time events
116
+ // ---------------------------------------------------------------------------
117
+ // SSE subscription for real-time events via shared singleton
118
+ // ---------------------------------------------------------------------------
103
119
  useEffect(() => {
104
- const es = new EventSource("/api/openclaw/events");
105
- eventSourceRef.current = es;
106
-
107
- es.onmessage = (e) => {
108
- try {
109
- const evt: GatewaySSEEvent = JSON.parse(e.data);
110
-
111
- // Handle Castle state changes
112
- if (evt.event === "castle.state") {
113
- const payload = evt.payload as { state: string; isConnected: boolean; server?: Record<string, unknown> };
114
- setStatus((prev) => ({
115
- ok: payload.isConnected,
116
- configured: prev?.configured ?? true,
117
- state: payload.state,
118
- server: payload.server as OpenClawStatus["server"],
119
- }));
120
-
121
- // Re-fetch agents when connection state changes to connected
122
- if (payload.isConnected) {
123
- fetchAgents();
124
- }
125
- }
126
-
127
- // Handle agent-related events -- re-fetch agent list
128
- if (evt.event?.startsWith("agent.")) {
129
- fetchAgents();
130
- }
131
- } catch {
132
- // Ignore parse errors
120
+ const handleState = (evt: SSEEvent) => {
121
+ const payload = evt.payload as {
122
+ state: string;
123
+ isConnected: boolean;
124
+ server?: Record<string, unknown>;
125
+ };
126
+
127
+ mutateStatus(
128
+ (prev) => ({
129
+ ok: payload.isConnected,
130
+ configured: prev?.configured ?? true,
131
+ state: payload.state,
132
+ server: payload.server as OpenClawStatus["server"],
133
+ }),
134
+ { revalidate: false }
135
+ );
136
+
137
+ if (payload.isConnected) {
138
+ mutateAgents();
133
139
  }
134
140
  };
135
141
 
136
- es.onerror = () => {
137
- // EventSource auto-reconnects, but update state
138
- setStatus((prev) => prev ? { ...prev, ok: false, state: "disconnected" } : prev);
142
+ const handleAgent = (_evt: SSEEvent) => {
143
+ mutateAgents();
139
144
  };
140
145
 
146
+ const handleError = () => {
147
+ console.warn("[useOpenClaw] SSE error — marking as disconnected");
148
+ mutateStatus(
149
+ (prev) => prev ? { ...prev, ok: false, state: "disconnected" } : prev,
150
+ { revalidate: false }
151
+ );
152
+ };
153
+
154
+ const unsub1 = subscribe("castle.state", handleState);
155
+ const unsub2 = subscribe("agent.*", handleAgent);
156
+ const unsub3 = subscribe("agentAvatarUpdated", handleAgent);
157
+ const unsub4 = onError(handleError);
158
+
141
159
  return () => {
142
- es.close();
143
- eventSourceRef.current = null;
160
+ unsub1();
161
+ unsub2();
162
+ unsub3();
163
+ unsub4();
144
164
  };
145
- }, [fetchAgents]);
165
+ }, [mutateStatus, mutateAgents]);
146
166
 
147
167
  return {
148
168
  // Status
149
169
  status,
150
- isLoading,
151
- isConnected: status?.ok ?? false,
170
+ isLoading: statusLoading,
171
+ isError: !!statusError,
172
+ isConnected,
152
173
  isConfigured: status?.configured ?? false,
153
174
  latency: status?.latency_ms,
154
175
  serverVersion: status?.server?.version,
155
176
 
156
177
  // Agents
157
- agents,
178
+ agents: agents ?? [],
158
179
  agentsLoading,
159
180
 
160
181
  // Actions
161
182
  refresh,
183
+ refreshAgents,
162
184
  };
163
185
  }
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef, useEffect } from "react";
4
+ import type { SearchResult } from "@/lib/types/search";
5
+
6
+ // ============================================================================
7
+ // Constants
8
+ // ============================================================================
9
+
10
+ const DEBOUNCE_MS = 300;
11
+
12
+ // ============================================================================
13
+ // Hook
14
+ // ============================================================================
15
+
16
+ export function useSearch() {
17
+ const [query, setQuery] = useState("");
18
+ const [results, setResults] = useState<SearchResult[]>([]);
19
+ const [isSearching, setIsSearching] = useState(false);
20
+ const [recentSearches, setRecentSearches] = useState<string[]>([]);
21
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
+ const abortRef = useRef<AbortController | null>(null);
23
+ const recentLoaded = useRef(false);
24
+
25
+ // Load recent searches from DB on first mount
26
+ useEffect(() => {
27
+ if (recentLoaded.current) return;
28
+ recentLoaded.current = true;
29
+ fetch("/api/openclaw/chat/search?recent=1")
30
+ .then((res) => (res.ok ? res.json() : { recent: [] }))
31
+ .then((data) => setRecentSearches(data.recent ?? []))
32
+ .catch(() => {});
33
+ }, []);
34
+
35
+ // Debounced search
36
+ const search = useCallback((q: string) => {
37
+ setQuery(q);
38
+
39
+ // Cancel pending request
40
+ if (timerRef.current) clearTimeout(timerRef.current);
41
+ if (abortRef.current) abortRef.current.abort();
42
+
43
+ if (!q.trim()) {
44
+ setResults([]);
45
+ setIsSearching(false);
46
+ return;
47
+ }
48
+
49
+ setIsSearching(true);
50
+ timerRef.current = setTimeout(async () => {
51
+ const controller = new AbortController();
52
+ abortRef.current = controller;
53
+
54
+ try {
55
+ const res = await fetch(
56
+ `/api/openclaw/chat/search?q=${encodeURIComponent(q.trim())}`,
57
+ { signal: controller.signal }
58
+ );
59
+ if (res.ok) {
60
+ const data = await res.json();
61
+ const searchResults: SearchResult[] = data.results ?? [];
62
+ setResults(searchResults);
63
+
64
+ // Save to recent searches in DB if results found
65
+ if (searchResults.length > 0) {
66
+ const trimmed = q.trim();
67
+ fetch("/api/openclaw/chat/search", {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({ query: trimmed }),
71
+ })
72
+ .then(() => {
73
+ // Update local state to reflect the save
74
+ setRecentSearches((prev) => {
75
+ const filtered = prev.filter((s) => s !== trimmed);
76
+ return [trimmed, ...filtered].slice(0, 15);
77
+ });
78
+ })
79
+ .catch(() => {});
80
+ }
81
+ }
82
+ } catch (err) {
83
+ if ((err as Error).name !== "AbortError") {
84
+ console.error("[useSearch] Search failed:", (err as Error).message);
85
+ setResults([]);
86
+ }
87
+ } finally {
88
+ setIsSearching(false);
89
+ }
90
+ }, DEBOUNCE_MS);
91
+ }, []);
92
+
93
+ const clearRecentSearches = useCallback(() => {
94
+ setRecentSearches([]);
95
+ fetch("/api/openclaw/chat/search", { method: "DELETE" }).catch(() => {});
96
+ }, []);
97
+
98
+ // Cleanup on unmount
99
+ useEffect(() => {
100
+ return () => {
101
+ if (timerRef.current) clearTimeout(timerRef.current);
102
+ if (abortRef.current) abortRef.current.abort();
103
+ };
104
+ }, []);
105
+
106
+ return {
107
+ query,
108
+ setQuery: search,
109
+ results,
110
+ isSearching,
111
+ recentSearches,
112
+ clearRecentSearches,
113
+ };
114
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import useSWR from "swr";
5
+ import type { SessionStatus } from "@/lib/types/chat";
6
+
7
+ const fetcher = async (url: string): Promise<SessionStatus | null> => {
8
+ const res = await fetch(url);
9
+ if (res.status === 204) return null;
10
+ if (!res.ok) {
11
+ console.error(`[useSessionStats] Fetch failed: ${res.status} ${url}`);
12
+ throw new Error("Failed to fetch session stats");
13
+ }
14
+ return res.json();
15
+ };
16
+
17
+ interface UseSessionStatsOptions {
18
+ sessionKey: string | null;
19
+ }
20
+
21
+ interface UseSessionStatsReturn {
22
+ stats: SessionStatus | null;
23
+ isLoading: boolean;
24
+ isError: boolean;
25
+ refresh: () => void;
26
+ }
27
+
28
+ /**
29
+ * SWR-based hook for fetching session statistics from the Gateway.
30
+ * Conditional fetching: only when sessionKey is non-null.
31
+ * Polls every 30s while active, dedupes within 5s.
32
+ */
33
+ export function useSessionStats({ sessionKey }: UseSessionStatsOptions): UseSessionStatsReturn {
34
+ const {
35
+ data,
36
+ isLoading,
37
+ error,
38
+ mutate,
39
+ } = useSWR<SessionStatus | null>(
40
+ sessionKey ? `/api/openclaw/session/status?sessionKey=${sessionKey}` : null,
41
+ fetcher,
42
+ {
43
+ refreshInterval: 30000,
44
+ dedupingInterval: 5000,
45
+ revalidateOnFocus: false,
46
+ errorRetryCount: 1,
47
+ }
48
+ );
49
+
50
+ const refresh = useCallback(() => {
51
+ mutate();
52
+ }, [mutate]);
53
+
54
+ return {
55
+ stats: data ?? null,
56
+ isLoading,
57
+ isError: !!error,
58
+ refresh,
59
+ };
60
+ }