@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.
- package/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- 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 +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -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 +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- 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 +328 -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 +168 -0
- package/src/components/chat/message-list.tsx +666 -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 +444 -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 +110 -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/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- 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
|
-
|
|
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
|
-
"
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
180
|
+
variant={getStatusBadgeVariant(agentStatus, isConnected)}
|
|
150
181
|
size="sm"
|
|
151
182
|
>
|
|
152
|
-
{isConnected
|
|
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
|
|
235
|
-
<div className="
|
|
236
|
-
|
|
237
|
-
<div className="
|
|
238
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
<
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
+
}
|