@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
package/src/app/page.tsx CHANGED
@@ -3,18 +3,47 @@
3
3
  import { useRef, useState, useCallback } from "react";
4
4
  import { Bot, Wifi, WifiOff, Crown, RefreshCw, Loader2, AlertCircle, Camera } from "lucide-react";
5
5
  import { Sidebar } from "@/components/layout/sidebar";
6
- import { UserMenu } from "@/components/layout/user-menu";
6
+
7
7
  import { PageHeader } from "@/components/layout/page-header";
8
8
  import { Card, CardContent } from "@/components/ui/card";
9
9
  import { Badge } from "@/components/ui/badge";
10
10
  import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
11
11
  import { cn } from "@/lib/utils";
12
12
  import { useOpenClaw, type OpenClawAgent } from "@/lib/hooks/use-openclaw";
13
+ import { useAgentStatus, type AgentStatus } from "@/lib/hooks/use-agent-status";
14
+ import { SearchTrigger } from "@/components/providers/search-provider";
13
15
 
14
16
  function getInitials(name: string) {
15
17
  return name.slice(0, 2).toUpperCase();
16
18
  }
17
19
 
20
+ function getStatusLabel(status: AgentStatus, isConnected: boolean): string {
21
+ if (!isConnected) return "Offline";
22
+ switch (status) {
23
+ case "thinking": return "Thinking";
24
+ case "active": return "Active";
25
+ default: return "Idle";
26
+ }
27
+ }
28
+
29
+ function getStatusBadgeVariant(status: AgentStatus, isConnected: boolean): "success" | "warning" | "outline" {
30
+ if (!isConnected) return "outline";
31
+ switch (status) {
32
+ case "thinking": return "warning";
33
+ case "active": return "success";
34
+ default: return "outline";
35
+ }
36
+ }
37
+
38
+ function getAvatarStatus(status: AgentStatus, isConnected: boolean): "online" | "offline" | "busy" | "away" {
39
+ if (!isConnected) return "offline";
40
+ switch (status) {
41
+ case "thinking": return "away";
42
+ case "active": return "online";
43
+ default: return "offline";
44
+ }
45
+ }
46
+
18
47
  function AgentCard({
19
48
  agent,
20
49
  isPrimary,
@@ -28,6 +57,8 @@ function AgentCard({
28
57
  }) {
29
58
  const fileInputRef = useRef<HTMLInputElement>(null);
30
59
  const [uploading, setUploading] = useState(false);
60
+ const { getStatus } = useAgentStatus();
61
+ const agentStatus = getStatus(agent.id);
31
62
 
32
63
  const handleAvatarClick = useCallback(() => {
33
64
  if (!isConnected) return;
@@ -79,23 +110,23 @@ function AgentCard({
79
110
  <Card
80
111
  variant="bordered"
81
112
  className={cn(
82
- "p-4 transition-colors",
113
+ "py-4 pl-5 pr-4 transition-colors min-h-[80px] flex items-center",
83
114
  isConnected
84
115
  ? "hover:border-border-hover"
85
116
  : "opacity-60"
86
117
  )}
87
118
  >
88
- <div className="flex items-center justify-between">
119
+ <div className="flex items-center justify-between flex-1">
89
120
  <div className="flex items-center gap-3">
90
121
  {/* Clickable avatar with upload overlay */}
91
122
  <button
92
123
  type="button"
93
124
  onClick={handleAvatarClick}
94
125
  disabled={!isConnected || uploading}
95
- className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
126
+ className="relative group rounded-[4px] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent leading-[0]"
96
127
  title={isConnected ? "Click to change avatar" : undefined}
97
128
  >
98
- <Avatar size="md" status={isConnected ? "online" : "offline"}>
129
+ <Avatar size="md" status={getAvatarStatus(agentStatus, isConnected)} statusPulse={agentStatus === "thinking"}>
99
130
  {agent.avatar ? (
100
131
  <AvatarImage
101
132
  src={agent.avatar}
@@ -109,12 +140,12 @@ function AgentCard({
109
140
  )}
110
141
  </Avatar>
111
142
  {isConnected && !uploading && (
112
- <div className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
143
+ <div className="absolute inset-0 rounded-[4px] bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
113
144
  <Camera className="h-4 w-4 text-white" />
114
145
  </div>
115
146
  )}
116
147
  {uploading && (
117
- <div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
148
+ <div className="absolute inset-0 rounded-[4px] bg-black/50 flex items-center justify-center">
118
149
  <Loader2 className="h-4 w-4 text-white animate-spin" />
119
150
  </div>
120
151
  )}
@@ -146,10 +177,10 @@ function AgentCard({
146
177
  </div>
147
178
  </div>
148
179
  <Badge
149
- variant={isConnected ? "success" : "outline"}
180
+ variant={getStatusBadgeVariant(agentStatus, isConnected)}
150
181
  size="sm"
151
182
  >
152
- {isConnected ? "Active" : "Offline"}
183
+ {getStatusLabel(agentStatus, isConnected)}
153
184
  </Badge>
154
185
  </div>
155
186
  </Card>
@@ -230,13 +261,16 @@ function AgentsSkeleton() {
230
261
  return (
231
262
  <div className="grid gap-3">
232
263
  {[1, 2, 3].map((i) => (
233
- <Card key={i} variant="bordered" className="p-4">
234
- <div className="flex items-center gap-3">
235
- <div className="h-10 w-10 rounded-full bg-surface-hover animate-pulse" />
236
- <div className="space-y-2">
237
- <div className="h-4 w-24 bg-surface-hover rounded animate-pulse" />
238
- <div className="h-3 w-32 bg-surface-hover rounded animate-pulse" />
264
+ <Card key={i} variant="bordered" className="p-4 min-h-[80px] flex items-center">
265
+ <div className="flex items-center justify-between flex-1">
266
+ <div className="flex items-center gap-3">
267
+ <div className="skeleton h-10 w-10 rounded-full" />
268
+ <div className="space-y-2">
269
+ <div className="skeleton h-4 w-24 rounded" />
270
+ <div className="skeleton h-3 w-32 rounded" />
271
+ </div>
239
272
  </div>
273
+ <div className="skeleton h-5 w-14 rounded-full" />
240
274
  </div>
241
275
  </Card>
242
276
  ))}
@@ -294,18 +328,18 @@ export default function HomePage() {
294
328
  return (
295
329
  <div className="min-h-screen bg-background">
296
330
  <Sidebar variant="solid" />
297
- <UserMenu className="fixed top-5 right-6 z-50" variant="solid" />
298
331
 
299
332
  <main className="min-h-screen ml-[80px]">
300
- <div className="p-8 max-w-4xl">
301
- {/* Header */}
302
- <div className="mb-8">
303
- <PageHeader
304
- title="Castle"
305
- subtitle="The multi-agent workspace"
306
- />
307
- </div>
333
+ {/* Header — full width */}
334
+ <div className="px-8 py-5 flex items-center justify-between gap-4 border-b border-border">
335
+ <PageHeader
336
+ title="Castle"
337
+ subtitle="The multi-agent workspace"
338
+ />
339
+ <SearchTrigger />
340
+ </div>
308
341
 
342
+ <div className="p-8 max-w-4xl">
309
343
  {/* OpenClaw Connection Status */}
310
344
  <ConnectionCard
311
345
  isConnected={isConnected}
@@ -331,7 +365,7 @@ export default function HomePage() {
331
365
  )}
332
366
  </div>
333
367
 
334
- {agentsLoading && isLoading ? (
368
+ {agentsLoading || (isLoading && agents.length === 0) ? (
335
369
  <AgentsSkeleton />
336
370
  ) : agents.length > 0 ? (
337
371
  <div className="grid gap-3">
@@ -0,0 +1,300 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useRef } from "react";
4
+ import { Loader2, Check, ArrowLeft, Camera, Trash2, User } from "lucide-react";
5
+ import { useRouter } from "next/navigation";
6
+ import { Sidebar } from "@/components/layout/sidebar";
7
+
8
+ import { Button } from "@/components/ui/button";
9
+ import { Input } from "@/components/ui/input";
10
+ import { useUserSettings } from "@/lib/hooks/use-user-settings";
11
+ import { cn } from "@/lib/utils";
12
+
13
+ export default function SettingsPage() {
14
+ const router = useRouter();
15
+ const { displayName: savedName, avatarUrl: sharedAvatarUrl, tooltips: savedTooltips, isLoading: settingsLoading, refresh } = useUserSettings();
16
+ const [displayName, setDisplayName] = useState("");
17
+ const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
18
+ const [initialized, setInitialized] = useState(false);
19
+ const [saving, setSaving] = useState(false);
20
+ const [saved, setSaved] = useState(false);
21
+ const [uploadingAvatar, setUploadingAvatar] = useState(false);
22
+ const [avatarSaved, setAvatarSaved] = useState(false);
23
+ const [avatarError, setAvatarError] = useState("");
24
+ const [tooltipsEnabled, setTooltipsEnabled] = useState<boolean | null>(null);
25
+ const fileInputRef = useRef<HTMLInputElement>(null);
26
+
27
+ // Sync from SWR on initial load
28
+ useEffect(() => {
29
+ if (!settingsLoading && !initialized) {
30
+ setDisplayName(savedName);
31
+ setAvatarUrl(sharedAvatarUrl);
32
+ setTooltipsEnabled(savedTooltips);
33
+ setInitialized(true);
34
+ }
35
+ }, [settingsLoading, savedName, sharedAvatarUrl, savedTooltips, initialized]);
36
+
37
+ const loading = settingsLoading && !initialized;
38
+
39
+ const handleSave = async () => {
40
+ setSaving(true);
41
+ setSaved(false);
42
+ try {
43
+ const res = await fetch("/api/settings", {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ displayName }),
47
+ });
48
+ if (res.ok) {
49
+ setSaved(true);
50
+ refresh();
51
+ setTimeout(() => setSaved(false), 2000);
52
+ }
53
+ } catch {
54
+ // silent
55
+ } finally {
56
+ setSaving(false);
57
+ }
58
+ };
59
+
60
+ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
61
+ const file = e.target.files?.[0];
62
+ if (!file) return;
63
+
64
+ setAvatarError("");
65
+ setAvatarSaved(false);
66
+ setUploadingAvatar(true);
67
+
68
+ try {
69
+ const formData = new FormData();
70
+ formData.append("avatar", file);
71
+
72
+ const res = await fetch("/api/settings/avatar", {
73
+ method: "POST",
74
+ body: formData,
75
+ });
76
+
77
+ const data = await res.json();
78
+
79
+ if (res.ok) {
80
+ setAvatarUrl(data.avatar);
81
+ setAvatarSaved(true);
82
+ refresh();
83
+ setTimeout(() => setAvatarSaved(false), 2000);
84
+ } else {
85
+ setAvatarError(data.error || "Upload failed");
86
+ }
87
+ } catch {
88
+ setAvatarError("Upload failed");
89
+ } finally {
90
+ setUploadingAvatar(false);
91
+ // Reset file input so the same file can be re-selected
92
+ if (fileInputRef.current) fileInputRef.current.value = "";
93
+ }
94
+ };
95
+
96
+ const handleAvatarRemove = async () => {
97
+ try {
98
+ const res = await fetch("/api/settings/avatar", { method: "DELETE" });
99
+ if (res.ok) {
100
+ setAvatarUrl(null);
101
+ refresh();
102
+ }
103
+ } catch {
104
+ // silent
105
+ }
106
+ };
107
+
108
+ return (
109
+ <div className="min-h-screen bg-background">
110
+ <Sidebar variant="solid" />
111
+
112
+ <div className="ml-[80px] p-8 max-w-2xl">
113
+ <div className="mb-8">
114
+ <button
115
+ onClick={() => router.push("/")}
116
+ className="inline-flex items-center gap-1.5 text-sm text-foreground-secondary hover:text-foreground transition-colors mb-4 cursor-pointer"
117
+ >
118
+ <ArrowLeft className="h-4 w-4" />
119
+ Back
120
+ </button>
121
+ <h1 className="text-2xl font-semibold text-foreground">Settings</h1>
122
+ <p className="text-sm text-foreground-secondary mt-1">
123
+ Configure your Castle preferences
124
+ </p>
125
+ </div>
126
+
127
+ {loading ? (
128
+ <div className="flex items-center justify-center py-12">
129
+ <Loader2 className="h-6 w-6 animate-spin text-foreground-secondary" />
130
+ </div>
131
+ ) : (
132
+ <div className="space-y-8">
133
+ {/* Profile section */}
134
+ <div className="panel p-6">
135
+ <h2 className="text-sm font-semibold text-foreground mb-4">
136
+ Profile
137
+ </h2>
138
+ <div className="space-y-6">
139
+ {/* Avatar */}
140
+ <div>
141
+ <label className="block text-sm font-medium text-foreground-secondary mb-3">
142
+ Avatar
143
+ </label>
144
+ <div className="flex items-center gap-4">
145
+ {/* Avatar preview */}
146
+ <div className="relative group">
147
+ <div className="w-20 h-20 rounded-[4px] overflow-hidden bg-surface-hover border-2 border-border flex items-center justify-center">
148
+ {uploadingAvatar ? (
149
+ <Loader2 className="h-6 w-6 animate-spin text-foreground-secondary" />
150
+ ) : avatarUrl ? (
151
+ <img
152
+ src={avatarUrl}
153
+ alt="Your avatar"
154
+ className="w-full h-full object-cover"
155
+ />
156
+ ) : (
157
+ <User className="h-8 w-8 text-foreground-secondary" />
158
+ )}
159
+ </div>
160
+ {/* Hover overlay */}
161
+ <button
162
+ onClick={() => fileInputRef.current?.click()}
163
+ disabled={uploadingAvatar}
164
+ className="absolute inset-0 w-20 h-20 rounded-[4px] bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
165
+ >
166
+ <Camera className="h-5 w-5 text-white" />
167
+ </button>
168
+ </div>
169
+
170
+ <div className="flex flex-col gap-2">
171
+ <div className="flex items-center gap-2">
172
+ <Button
173
+ variant="outline"
174
+ size="sm"
175
+ onClick={() => fileInputRef.current?.click()}
176
+ disabled={uploadingAvatar}
177
+ >
178
+ {avatarUrl ? "Change" : "Upload"}
179
+ </Button>
180
+ {avatarUrl && (
181
+ <Button
182
+ variant="ghost"
183
+ size="sm"
184
+ onClick={handleAvatarRemove}
185
+ className="text-foreground-secondary hover:text-red-400"
186
+ >
187
+ <Trash2 className="h-3.5 w-3.5" />
188
+ </Button>
189
+ )}
190
+ </div>
191
+ <p className="text-xs text-foreground-secondary">
192
+ PNG, JPEG, WebP, or GIF. Max 5MB. Resized to 256x256.
193
+ </p>
194
+ {avatarSaved && (
195
+ <p className="text-xs text-green-400 flex items-center gap-1">
196
+ <Check className="h-3 w-3" />
197
+ Avatar saved
198
+ </p>
199
+ )}
200
+ {avatarError && (
201
+ <p className="text-xs text-red-400">{avatarError}</p>
202
+ )}
203
+ </div>
204
+ </div>
205
+ <input
206
+ ref={fileInputRef}
207
+ type="file"
208
+ accept="image/png,image/jpeg,image/webp,image/gif"
209
+ onChange={handleAvatarUpload}
210
+ className="hidden"
211
+ />
212
+ </div>
213
+
214
+ {/* Display Name */}
215
+ <div>
216
+ <label className="block text-sm font-medium text-foreground-secondary mb-1.5">
217
+ Display Name
218
+ </label>
219
+ <div className="flex items-center gap-3">
220
+ <Input
221
+ value={displayName}
222
+ onChange={(e) => setDisplayName(e.target.value)}
223
+ placeholder="Your name"
224
+ className="max-w-xs"
225
+ />
226
+ <Button
227
+ onClick={handleSave}
228
+ disabled={saving}
229
+ size="sm"
230
+ >
231
+ {saving ? (
232
+ <Loader2 className="h-4 w-4 animate-spin" />
233
+ ) : saved ? (
234
+ <>
235
+ <Check className="h-4 w-4 mr-1.5" />
236
+ Saved
237
+ </>
238
+ ) : (
239
+ "Save"
240
+ )}
241
+ </Button>
242
+ </div>
243
+ <p className="text-xs text-foreground-secondary mt-1.5">
244
+ Shown in chat channel headers and message attribution.
245
+ </p>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ {/* Interface section */}
251
+ <div className="panel p-6">
252
+ <h2 className="text-sm font-semibold text-foreground mb-4">
253
+ Interface
254
+ </h2>
255
+ <div className="flex items-center justify-between">
256
+ <div>
257
+ <p className="text-sm font-medium text-foreground">
258
+ Menu tooltips
259
+ </p>
260
+ <p className="text-xs text-foreground-secondary mt-0.5">
261
+ Show tooltips when hovering sidebar menu icons
262
+ </p>
263
+ </div>
264
+ <button
265
+ onClick={async () => {
266
+ const newValue = !tooltipsEnabled;
267
+ setTooltipsEnabled(newValue);
268
+ try {
269
+ await fetch("/api/settings", {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/json" },
272
+ body: JSON.stringify({ tooltips: String(newValue) }),
273
+ });
274
+ refresh();
275
+ } catch {
276
+ setTooltipsEnabled(!newValue); // revert on error
277
+ }
278
+ }}
279
+ className={cn(
280
+ "relative inline-flex h-6 w-11 items-center rounded-full cursor-pointer",
281
+ initialized && "transition-colors",
282
+ tooltipsEnabled ? "bg-accent" : "bg-foreground-muted/30"
283
+ )}
284
+ >
285
+ <span
286
+ className={cn(
287
+ "inline-block h-4 w-4 rounded-full bg-white",
288
+ initialized && "transition-transform",
289
+ tooltipsEnabled ? "translate-x-6" : "translate-x-1"
290
+ )}
291
+ />
292
+ </button>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ )}
297
+ </div>
298
+ </div>
299
+ );
300
+ }
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import { Bot } from "lucide-react";
4
+ import { cn } from "@/lib/utils";
5
+ import { useEffect, useRef } from "react";
6
+
7
+ export interface AgentInfo {
8
+ id: string;
9
+ name: string;
10
+ avatar?: string | null;
11
+ }
12
+
13
+ interface AgentMentionPopupProps {
14
+ agents: AgentInfo[];
15
+ filter: string;
16
+ onSelect: (agentId: string) => void;
17
+ onClose: () => void;
18
+ highlightedIndex?: number;
19
+ }
20
+
21
+ export function AgentMentionPopup({ agents, filter, onSelect, onClose, highlightedIndex = 0 }: AgentMentionPopupProps) {
22
+ const listRef = useRef<HTMLDivElement>(null);
23
+
24
+ const filteredAgents = getFilteredAgents(agents, filter);
25
+
26
+ // Scroll highlighted item into view
27
+ useEffect(() => {
28
+ if (listRef.current && highlightedIndex >= 0) {
29
+ const items = listRef.current.querySelectorAll("button");
30
+ const item = items[highlightedIndex];
31
+ if (item) {
32
+ item.scrollIntoView({ block: "nearest" });
33
+ }
34
+ }
35
+ }, [highlightedIndex]);
36
+
37
+ if (filteredAgents.length === 0) {
38
+ return null;
39
+ }
40
+
41
+ return (
42
+ <div className="absolute bottom-full left-0 mb-2 w-64 bg-surface border border-border rounded-xl shadow-lg overflow-hidden z-50">
43
+ <div className="p-2" ref={listRef}>
44
+ <p className="text-xs text-foreground-secondary px-2 py-1 mb-1">
45
+ Mention an agent (Up/Down to navigate, Tab/Enter to select)
46
+ </p>
47
+ {filteredAgents.map((agent, index) => (
48
+ <button
49
+ key={agent.id}
50
+ type="button"
51
+ onClick={() => onSelect(agent.id)}
52
+ className={cn(
53
+ "w-full flex items-center gap-2 px-2 py-2 rounded-lg text-left text-sm",
54
+ "hover:bg-accent/80 hover:text-white focus:outline-none",
55
+ index === highlightedIndex
56
+ ? "bg-accent text-white"
57
+ : "text-foreground"
58
+ )}
59
+ >
60
+ {agent.avatar ? (
61
+ <img
62
+ src={agent.avatar}
63
+ alt={agent.name}
64
+ className="w-6 h-6 rounded-full object-cover"
65
+ />
66
+ ) : (
67
+ <div className={cn(
68
+ "flex items-center justify-center w-6 h-6 rounded-full",
69
+ index === highlightedIndex
70
+ ? "bg-white/20 text-white"
71
+ : "bg-accent/20 text-accent"
72
+ )}>
73
+ <Bot className="w-3 h-3" />
74
+ </div>
75
+ )}
76
+ <span className="font-medium">{agent.name}</span>
77
+ </button>
78
+ ))}
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ /** Filter agents by name match */
85
+ export function getFilteredAgents(agents: AgentInfo[], filter: string): AgentInfo[] {
86
+ return agents.filter(agent =>
87
+ agent.name.toLowerCase().includes(filter.toLowerCase())
88
+ );
89
+ }