@castlekit/castle 0.1.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +20 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +49 -17
- package/src/app/settings/page.tsx +300 -0
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, primaryKey, index } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// channels
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export const channels = sqliteTable("channels", {
|
|
8
|
+
id: text("id").primaryKey(),
|
|
9
|
+
name: text("name").notNull(),
|
|
10
|
+
defaultAgentId: text("default_agent_id").notNull(),
|
|
11
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
12
|
+
updatedAt: integer("updated_at"), // unix ms
|
|
13
|
+
lastAccessedAt: integer("last_accessed_at"), // unix ms — last time user opened this channel
|
|
14
|
+
archivedAt: integer("archived_at"), // unix ms — null if active, set when archived
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// settings (key-value store for user preferences)
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export const settings = sqliteTable("settings", {
|
|
22
|
+
key: text("key").primaryKey(),
|
|
23
|
+
value: text("value").notNull(),
|
|
24
|
+
updatedAt: integer("updated_at").notNull(), // unix ms
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// agent_statuses (live agent activity state)
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export const agentStatuses = sqliteTable("agent_statuses", {
|
|
32
|
+
agentId: text("agent_id").primaryKey(),
|
|
33
|
+
status: text("status").notNull().default("idle"), // "idle" | "thinking" | "active"
|
|
34
|
+
updatedAt: integer("updated_at").notNull(), // unix ms
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// channel_agents (many-to-many junction)
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export const channelAgents = sqliteTable(
|
|
42
|
+
"channel_agents",
|
|
43
|
+
{
|
|
44
|
+
channelId: text("channel_id")
|
|
45
|
+
.notNull()
|
|
46
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
47
|
+
agentId: text("agent_id").notNull(),
|
|
48
|
+
},
|
|
49
|
+
(table) => [
|
|
50
|
+
primaryKey({ columns: [table.channelId, table.agentId] }),
|
|
51
|
+
]
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// sessions
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export const sessions = sqliteTable(
|
|
59
|
+
"sessions",
|
|
60
|
+
{
|
|
61
|
+
id: text("id").primaryKey(),
|
|
62
|
+
channelId: text("channel_id")
|
|
63
|
+
.notNull()
|
|
64
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
65
|
+
sessionKey: text("session_key"), // Gateway session key
|
|
66
|
+
startedAt: integer("started_at").notNull(), // unix ms
|
|
67
|
+
endedAt: integer("ended_at"), // unix ms, nullable
|
|
68
|
+
summary: text("summary"),
|
|
69
|
+
totalInputTokens: integer("total_input_tokens").default(0),
|
|
70
|
+
totalOutputTokens: integer("total_output_tokens").default(0),
|
|
71
|
+
},
|
|
72
|
+
(table) => [
|
|
73
|
+
index("idx_sessions_channel").on(table.channelId, table.startedAt),
|
|
74
|
+
]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// messages
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
export const messages = sqliteTable(
|
|
82
|
+
"messages",
|
|
83
|
+
{
|
|
84
|
+
id: text("id").primaryKey(),
|
|
85
|
+
channelId: text("channel_id")
|
|
86
|
+
.notNull()
|
|
87
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
88
|
+
sessionId: text("session_id").references(() => sessions.id),
|
|
89
|
+
senderType: text("sender_type").notNull(), // "user" | "agent"
|
|
90
|
+
senderId: text("sender_id").notNull(),
|
|
91
|
+
senderName: text("sender_name"),
|
|
92
|
+
content: text("content").notNull().default(""),
|
|
93
|
+
status: text("status").notNull().default("complete"), // "complete" | "interrupted" | "aborted"
|
|
94
|
+
mentionedAgentId: text("mentioned_agent_id"),
|
|
95
|
+
runId: text("run_id"), // Gateway run ID for streaming correlation
|
|
96
|
+
sessionKey: text("session_key"), // Gateway session key
|
|
97
|
+
inputTokens: integer("input_tokens"),
|
|
98
|
+
outputTokens: integer("output_tokens"),
|
|
99
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
100
|
+
},
|
|
101
|
+
(table) => [
|
|
102
|
+
index("idx_messages_channel").on(table.channelId, table.createdAt),
|
|
103
|
+
index("idx_messages_session").on(table.sessionId, table.createdAt),
|
|
104
|
+
index("idx_messages_run_id").on(table.runId),
|
|
105
|
+
]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// message_attachments
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
export const messageAttachments = sqliteTable(
|
|
113
|
+
"message_attachments",
|
|
114
|
+
{
|
|
115
|
+
id: text("id").primaryKey(),
|
|
116
|
+
messageId: text("message_id")
|
|
117
|
+
.notNull()
|
|
118
|
+
.references(() => messages.id, { onDelete: "cascade" }),
|
|
119
|
+
attachmentType: text("attachment_type").notNull(), // "image" | "audio"
|
|
120
|
+
filePath: text("file_path").notNull(),
|
|
121
|
+
mimeType: text("mime_type"),
|
|
122
|
+
fileSize: integer("file_size"),
|
|
123
|
+
originalName: text("original_name"),
|
|
124
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
125
|
+
},
|
|
126
|
+
(table) => [
|
|
127
|
+
index("idx_attachments_message").on(table.messageId),
|
|
128
|
+
]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// recent_searches
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
export const recentSearches = sqliteTable("recent_searches", {
|
|
136
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
137
|
+
query: text("query").notNull(),
|
|
138
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// message_reactions
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
export const messageReactions = sqliteTable(
|
|
146
|
+
"message_reactions",
|
|
147
|
+
{
|
|
148
|
+
id: text("id").primaryKey(),
|
|
149
|
+
messageId: text("message_id")
|
|
150
|
+
.notNull()
|
|
151
|
+
.references(() => messages.id, { onDelete: "cascade" }),
|
|
152
|
+
agentId: text("agent_id"),
|
|
153
|
+
emoji: text("emoji").notNull(),
|
|
154
|
+
emojiChar: text("emoji_char").notNull(),
|
|
155
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
156
|
+
},
|
|
157
|
+
(table) => [
|
|
158
|
+
index("idx_reactions_message").on(table.messageId),
|
|
159
|
+
]
|
|
160
|
+
);
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect } from "react";
|
|
4
|
+
import useSWR, { mutate as globalMutate } from "swr";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export type AgentStatus = "idle" | "thinking" | "active";
|
|
11
|
+
|
|
12
|
+
interface AgentStatusRow {
|
|
13
|
+
agentId: string;
|
|
14
|
+
status: AgentStatus;
|
|
15
|
+
updatedAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type StatusMap = Record<string, AgentStatus>;
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const SWR_KEY = "/api/openclaw/agents/status";
|
|
25
|
+
const CHANNEL_NAME = "agent-status";
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Fetcher
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const statusFetcher = async (url: string): Promise<StatusMap> => {
|
|
32
|
+
const res = await fetch(url);
|
|
33
|
+
if (!res.ok) return {};
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
const map: StatusMap = {};
|
|
36
|
+
for (const row of (data.statuses || []) as AgentStatusRow[]) {
|
|
37
|
+
map[row.agentId] = row.status;
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// BroadcastChannel: cross-tab real-time sync (no polling needed)
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
let broadcast: BroadcastChannel | null = null;
|
|
47
|
+
|
|
48
|
+
function getBroadcast(): BroadcastChannel | null {
|
|
49
|
+
if (typeof window === "undefined") return null;
|
|
50
|
+
if (!broadcast) {
|
|
51
|
+
try {
|
|
52
|
+
broadcast = new BroadcastChannel(CHANNEL_NAME);
|
|
53
|
+
} catch {
|
|
54
|
+
// BroadcastChannel not supported (e.g. some older browsers)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return broadcast;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// Client-side active → idle timers (mirrors the server-side 2 min expiry)
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
const ACTIVE_TIMEOUT_MS = 2 * 60 * 1000;
|
|
65
|
+
const activeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
66
|
+
|
|
67
|
+
function scheduleIdleTransition(agentId: string) {
|
|
68
|
+
// Clear any existing timer for this agent
|
|
69
|
+
const existing = activeTimers.get(agentId);
|
|
70
|
+
if (existing) clearTimeout(existing);
|
|
71
|
+
|
|
72
|
+
activeTimers.set(
|
|
73
|
+
agentId,
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
activeTimers.delete(agentId);
|
|
76
|
+
// Explicitly transition to idle — triggers SWR + BroadcastChannel
|
|
77
|
+
updateStatus(agentId, "idle");
|
|
78
|
+
}, ACTIVE_TIMEOUT_MS)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clearIdleTimer(agentId: string) {
|
|
83
|
+
const existing = activeTimers.get(agentId);
|
|
84
|
+
if (existing) {
|
|
85
|
+
clearTimeout(existing);
|
|
86
|
+
activeTimers.delete(agentId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Channel tracking: which channel each agent is currently thinking in
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
const thinkingChannels = new Map<string, string>();
|
|
95
|
+
|
|
96
|
+
/** Returns the channelId the agent is currently thinking in, or undefined. */
|
|
97
|
+
export function getThinkingChannel(agentId: string): string | undefined {
|
|
98
|
+
return thinkingChannels.get(agentId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Exported setters (optimistic SWR + DB persist + cross-tab broadcast)
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
function updateStatus(agentId: string, status: AgentStatus) {
|
|
106
|
+
// 1. Optimistic: update SWR cache immediately (current tab)
|
|
107
|
+
globalMutate(
|
|
108
|
+
SWR_KEY,
|
|
109
|
+
(prev: StatusMap | undefined) => ({ ...prev, [agentId]: status }),
|
|
110
|
+
{ revalidate: false }
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// 2. Broadcast to other tabs (instant, no server round-trip)
|
|
114
|
+
getBroadcast()?.postMessage({ agentId, status });
|
|
115
|
+
|
|
116
|
+
// 3. Persist to DB via API (fire-and-forget)
|
|
117
|
+
fetch(SWR_KEY, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ agentId, status }),
|
|
121
|
+
}).catch(() => {
|
|
122
|
+
// Silent — DB will be stale but next page load corrects it
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function setAgentThinking(agentId: string, channelId?: string) {
|
|
127
|
+
clearIdleTimer(agentId);
|
|
128
|
+
if (channelId) thinkingChannels.set(agentId, channelId);
|
|
129
|
+
updateStatus(agentId, "thinking");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function setAgentActive(agentId: string) {
|
|
133
|
+
thinkingChannels.delete(agentId);
|
|
134
|
+
updateStatus(agentId, "active");
|
|
135
|
+
// Schedule automatic idle transition after 2 minutes
|
|
136
|
+
scheduleIdleTransition(agentId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function setAgentIdle(agentId: string) {
|
|
140
|
+
thinkingChannels.delete(agentId);
|
|
141
|
+
clearIdleTimer(agentId);
|
|
142
|
+
updateStatus(agentId, "idle");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// User presence constants
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
export const USER_STATUS_ID = "__user__";
|
|
150
|
+
const USER_IDLE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes of inactivity → idle
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// Hook
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Shared agent status hook backed by the database.
|
|
158
|
+
* Uses BroadcastChannel for instant cross-tab sync — no polling.
|
|
159
|
+
* On mount, fetches current state from DB (catches up after page load).
|
|
160
|
+
* The server auto-expires "active" statuses older than 2 minutes to "idle".
|
|
161
|
+
*
|
|
162
|
+
* Status lifecycle: idle → thinking → active (2 min server-side) → idle
|
|
163
|
+
*/
|
|
164
|
+
export function useAgentStatus() {
|
|
165
|
+
const { data: statuses } = useSWR<StatusMap>(SWR_KEY, statusFetcher, {
|
|
166
|
+
fallbackData: {},
|
|
167
|
+
revalidateOnFocus: true,
|
|
168
|
+
revalidateOnReconnect: true,
|
|
169
|
+
refreshInterval: 120_000, // Light background poll every 2 min just to catch expired "active" → "idle"
|
|
170
|
+
dedupingInterval: 5000,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Listen for cross-tab broadcasts and update SWR cache instantly
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const bc = getBroadcast();
|
|
176
|
+
if (!bc) return;
|
|
177
|
+
|
|
178
|
+
const handler = (e: MessageEvent) => {
|
|
179
|
+
const { agentId, status } = e.data as { agentId: string; status: AgentStatus };
|
|
180
|
+
if (agentId && status) {
|
|
181
|
+
globalMutate(
|
|
182
|
+
SWR_KEY,
|
|
183
|
+
(prev: StatusMap | undefined) => ({ ...prev, [agentId]: status }),
|
|
184
|
+
{ revalidate: false }
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
bc.addEventListener("message", handler);
|
|
190
|
+
return () => bc.removeEventListener("message", handler);
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const getStatus = useCallback(
|
|
194
|
+
(agentId: string): AgentStatus => {
|
|
195
|
+
return statuses?.[agentId] ?? "idle";
|
|
196
|
+
},
|
|
197
|
+
[statuses]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
statuses: statuses ?? {},
|
|
202
|
+
getStatus,
|
|
203
|
+
setThinking: setAgentThinking,
|
|
204
|
+
setActive: setAgentActive,
|
|
205
|
+
setIdle: setAgentIdle,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// User Presence Hook
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Tracks user activity and sets active/idle status.
|
|
215
|
+
* Call once at the app layout level — listens for mouse, keyboard,
|
|
216
|
+
* scroll, and touch events. After 2 minutes of inactivity, sets idle.
|
|
217
|
+
*/
|
|
218
|
+
export function useUserPresence() {
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
|
+
let isActive = false;
|
|
222
|
+
|
|
223
|
+
const setActive = () => {
|
|
224
|
+
if (!isActive) {
|
|
225
|
+
isActive = true;
|
|
226
|
+
updateStatus(USER_STATUS_ID, "active");
|
|
227
|
+
}
|
|
228
|
+
// Reset idle timer on every interaction
|
|
229
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
230
|
+
idleTimer = setTimeout(() => {
|
|
231
|
+
isActive = false;
|
|
232
|
+
updateStatus(USER_STATUS_ID, "idle");
|
|
233
|
+
}, USER_IDLE_TIMEOUT_MS);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Set active immediately on mount
|
|
237
|
+
setActive();
|
|
238
|
+
|
|
239
|
+
const events = ["mousemove", "keydown", "scroll", "click", "touchstart"] as const;
|
|
240
|
+
for (const event of events) {
|
|
241
|
+
document.addEventListener(event, setActive, { passive: true });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return () => {
|
|
245
|
+
for (const event of events) {
|
|
246
|
+
document.removeEventListener(event, setActive);
|
|
247
|
+
}
|
|
248
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
249
|
+
};
|
|
250
|
+
}, []);
|
|
251
|
+
}
|