@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.
Files changed (60) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +20 -3
  4. package/src/app/api/avatars/[id]/route.ts +57 -7
  5. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  6. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  7. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  8. package/src/app/api/openclaw/chat/route.ts +272 -0
  9. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  10. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  11. package/src/app/api/openclaw/logs/route.ts +17 -3
  12. package/src/app/api/openclaw/restart/route.ts +6 -1
  13. package/src/app/api/openclaw/session/status/route.ts +42 -0
  14. package/src/app/api/settings/avatar/route.ts +190 -0
  15. package/src/app/api/settings/route.ts +88 -0
  16. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  17. package/src/app/chat/[channelId]/page.tsx +305 -0
  18. package/src/app/chat/layout.tsx +96 -0
  19. package/src/app/chat/page.tsx +52 -0
  20. package/src/app/globals.css +89 -2
  21. package/src/app/layout.tsx +7 -1
  22. package/src/app/page.tsx +49 -17
  23. package/src/app/settings/page.tsx +300 -0
  24. package/src/components/chat/agent-mention-popup.tsx +89 -0
  25. package/src/components/chat/archived-channels.tsx +190 -0
  26. package/src/components/chat/channel-list.tsx +140 -0
  27. package/src/components/chat/chat-input.tsx +310 -0
  28. package/src/components/chat/create-channel-dialog.tsx +171 -0
  29. package/src/components/chat/markdown-content.tsx +205 -0
  30. package/src/components/chat/message-bubble.tsx +152 -0
  31. package/src/components/chat/message-list.tsx +508 -0
  32. package/src/components/chat/message-queue.tsx +68 -0
  33. package/src/components/chat/session-divider.tsx +61 -0
  34. package/src/components/chat/session-stats-panel.tsx +139 -0
  35. package/src/components/chat/storage-indicator.tsx +76 -0
  36. package/src/components/layout/sidebar.tsx +126 -45
  37. package/src/components/layout/user-menu.tsx +29 -4
  38. package/src/components/providers/presence-provider.tsx +8 -0
  39. package/src/components/providers/search-provider.tsx +81 -0
  40. package/src/components/search/search-dialog.tsx +269 -0
  41. package/src/components/ui/avatar.tsx +11 -9
  42. package/src/components/ui/dialog.tsx +10 -4
  43. package/src/components/ui/tooltip.tsx +25 -8
  44. package/src/components/ui/twemoji-text.tsx +37 -0
  45. package/src/lib/api-security.ts +125 -0
  46. package/src/lib/date-utils.ts +79 -0
  47. package/src/lib/db/__tests__/queries.test.ts +318 -0
  48. package/src/lib/db/index.ts +642 -0
  49. package/src/lib/db/queries.ts +1017 -0
  50. package/src/lib/db/schema.ts +160 -0
  51. package/src/lib/hooks/use-agent-status.ts +251 -0
  52. package/src/lib/hooks/use-chat.ts +775 -0
  53. package/src/lib/hooks/use-openclaw.ts +105 -70
  54. package/src/lib/hooks/use-search.ts +113 -0
  55. package/src/lib/hooks/use-session-stats.ts +57 -0
  56. package/src/lib/hooks/use-user-settings.ts +46 -0
  57. package/src/lib/types/chat.ts +186 -0
  58. package/src/lib/types/search.ts +60 -0
  59. package/src/middleware.ts +52 -0
  60. package/vitest.config.ts +13 -0
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Hash, MessageCircle, Loader2, Archive } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+ import { Button } from "@/components/ui/button";
8
+ import type { Channel } from "@/lib/types/chat";
9
+
10
+ interface ChannelListProps {
11
+ activeChannelId?: string;
12
+ className?: string;
13
+ showCreateDialog?: boolean;
14
+ onCreateDialogChange?: (open: boolean) => void;
15
+ newChannel?: Channel | null;
16
+ }
17
+
18
+ export function ChannelList({
19
+ activeChannelId,
20
+ className,
21
+ onCreateDialogChange,
22
+ newChannel,
23
+ }: ChannelListProps) {
24
+ const router = useRouter();
25
+ const [channels, setChannels] = useState<Channel[]>([]);
26
+ const [loading, setLoading] = useState(true);
27
+
28
+ const setShowCreate = onCreateDialogChange ?? (() => {});
29
+
30
+ const fetchChannels = async () => {
31
+ try {
32
+ const res = await fetch("/api/openclaw/chat/channels");
33
+ if (res.ok) {
34
+ const data = await res.json();
35
+ setChannels(data.channels || []);
36
+ }
37
+ } catch (error) {
38
+ console.error("Failed to fetch channels:", error);
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ };
43
+
44
+ useEffect(() => {
45
+ fetchChannels();
46
+ }, []); // Only fetch once on mount — channel list doesn't change on navigation
47
+
48
+ // Instantly add a newly created channel to the list
49
+ useEffect(() => {
50
+ if (newChannel && !channels.some((c) => c.id === newChannel.id)) {
51
+ setChannels((prev) => [newChannel, ...prev]);
52
+ }
53
+ }, [newChannel]); // eslint-disable-line react-hooks/exhaustive-deps
54
+
55
+ return (
56
+ <div className={cn("flex flex-col gap-2", className)}>
57
+ {/* Loading skeleton */}
58
+ {loading && (
59
+ <div className="flex flex-col gap-1">
60
+ {[1, 2, 3, 4].map((i) => (
61
+ <div key={i} className="flex items-center gap-1.5 px-2 h-[34px] rounded-[var(--radius-sm)]">
62
+ <div className="skeleton h-4 w-4 rounded shrink-0" />
63
+ <div className="skeleton h-3.5 rounded" style={{ width: `${50 + i * 12}%` }} />
64
+ </div>
65
+ ))}
66
+ </div>
67
+ )}
68
+
69
+ {/* Empty state */}
70
+ {!loading && channels.length === 0 && (
71
+ <div className="text-center py-8 text-foreground-secondary">
72
+ <MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
73
+ <p className="text-sm">No active channels</p>
74
+ <Button
75
+ variant="outline"
76
+ size="sm"
77
+ className="mt-3"
78
+ onClick={() => setShowCreate(true)}
79
+ >
80
+ New channel
81
+ </Button>
82
+ </div>
83
+ )}
84
+
85
+ {/* Channel list */}
86
+ {!loading && channels.length > 0 && (
87
+ <div className="selectable-list">
88
+ {channels.map((channel) => (
89
+ <div
90
+ key={channel.id}
91
+ role="button"
92
+ tabIndex={0}
93
+ onClick={() => router.push(`/chat/${channel.id}`)}
94
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") router.push(`/chat/${channel.id}`); }}
95
+ className={cn(
96
+ "selectable-list-item transition-colors group relative flex items-center gap-1.5 w-full text-left cursor-pointer",
97
+ activeChannelId === channel.id
98
+ ? "bg-accent/10 text-accent"
99
+ : "text-foreground"
100
+ )}
101
+ >
102
+ <Hash className="h-4 w-4 shrink-0" strokeWidth={2.5} />
103
+ <span className="truncate flex-1 min-w-0">{channel.name}</span>
104
+ <button
105
+ onClick={async (e) => {
106
+ e.stopPropagation();
107
+ try {
108
+ const res = await fetch("/api/openclaw/chat/channels", {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify({ action: "archive", id: channel.id }),
112
+ });
113
+ if (res.ok) {
114
+ setChannels((prev) => prev.filter((c) => c.id !== channel.id));
115
+ if (activeChannelId === channel.id) {
116
+ const remaining = channels.filter((c) => c.id !== channel.id);
117
+ if (remaining.length > 0) {
118
+ router.push(`/chat/${remaining[0].id}`);
119
+ } else {
120
+ router.push("/chat");
121
+ }
122
+ }
123
+ }
124
+ } catch {
125
+ // silent
126
+ }
127
+ }}
128
+ className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-surface-hover text-foreground-secondary hover:text-foreground cursor-pointer"
129
+ title="Archive channel"
130
+ >
131
+ <Archive className="h-3.5 w-3.5" />
132
+ </button>
133
+ </div>
134
+ ))}
135
+ </div>
136
+ )}
137
+
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,310 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect, useCallback } from "react";
4
+ import { Send, Loader2, ImageIcon, X, Square } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { cn } from "@/lib/utils";
7
+ import { AgentMentionPopup, getFilteredAgents, type AgentInfo } from "./agent-mention-popup";
8
+
9
+ interface ChatInputProps {
10
+ onSend: (content: string, agentId?: string) => Promise<void>;
11
+ onAbort?: () => void;
12
+ sending?: boolean;
13
+ streaming?: boolean;
14
+ disabled?: boolean;
15
+ agents: AgentInfo[];
16
+ defaultAgentId?: string;
17
+ channelId?: string;
18
+ className?: string;
19
+ }
20
+
21
+ export function ChatInput({
22
+ onSend,
23
+ onAbort,
24
+ sending,
25
+ streaming,
26
+ disabled,
27
+ agents,
28
+ defaultAgentId,
29
+ channelId,
30
+ className,
31
+ }: ChatInputProps) {
32
+ const [showMentions, setShowMentions] = useState(false);
33
+ const [mentionFilter, setMentionFilter] = useState("");
34
+ const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
35
+ const [isEmpty, setIsEmpty] = useState(true);
36
+
37
+ const editorRef = useRef<HTMLDivElement>(null);
38
+
39
+ // Get plain text content from editor
40
+ const getPlainText = useCallback(() => {
41
+ if (!editorRef.current) return "";
42
+ return editorRef.current.innerText || "";
43
+ }, []);
44
+
45
+ // Get message content with mentions converted to IDs
46
+ const getMessageContent = useCallback(() => {
47
+ if (!editorRef.current) return { text: "", firstMentionId: undefined as string | undefined };
48
+
49
+ let text = "";
50
+ let firstMentionId: string | undefined;
51
+
52
+ const processNode = (node: Node) => {
53
+ if (node.nodeType === Node.TEXT_NODE) {
54
+ text += node.textContent || "";
55
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
56
+ const element = node as HTMLElement;
57
+ if (element.classList.contains("mention-chip")) {
58
+ const agentId = element.dataset.agentId;
59
+ if (agentId) {
60
+ text += `@${agentId}`;
61
+ if (!firstMentionId) firstMentionId = agentId;
62
+ }
63
+ } else if (element.tagName === "BR") {
64
+ text += "\n";
65
+ } else {
66
+ element.childNodes.forEach(processNode);
67
+ }
68
+ }
69
+ };
70
+
71
+ editorRef.current.childNodes.forEach(processNode);
72
+ return { text: text.trim(), firstMentionId };
73
+ }, []);
74
+
75
+ // Check for @mention trigger
76
+ const checkForMentionTrigger = useCallback(() => {
77
+ const selection = window.getSelection();
78
+ if (!selection || !selection.rangeCount) return;
79
+
80
+ const range = selection.getRangeAt(0);
81
+ const container = range.startContainer;
82
+
83
+ if (container.nodeType !== Node.TEXT_NODE) {
84
+ setShowMentions(false);
85
+ return;
86
+ }
87
+
88
+ const text = container.textContent || "";
89
+ const cursorPos = range.startOffset;
90
+ const textBeforeCursor = text.slice(0, cursorPos);
91
+ const mentionMatch = textBeforeCursor.match(/@(\w*)$/);
92
+
93
+ if (mentionMatch) {
94
+ const newFilter = mentionMatch[1].toLowerCase();
95
+ if (newFilter !== mentionFilter) setMentionHighlightIndex(0);
96
+ setMentionFilter(newFilter);
97
+ setShowMentions(true);
98
+ } else {
99
+ setShowMentions(false);
100
+ setMentionFilter("");
101
+ setMentionHighlightIndex(0);
102
+ }
103
+ }, [mentionFilter]);
104
+
105
+ // Handle input changes
106
+ const handleInput = useCallback(() => {
107
+ const text = getPlainText();
108
+ setIsEmpty(!text.trim());
109
+ checkForMentionTrigger();
110
+ }, [getPlainText, checkForMentionTrigger]);
111
+
112
+ // Insert mention chip
113
+ const insertMention = useCallback(
114
+ (agentId: string) => {
115
+ const agent = agents.find((a) => a.id === agentId);
116
+ const displayName = agent?.name || agentId;
117
+
118
+ const selection = window.getSelection();
119
+ if (!selection || !selection.rangeCount || !editorRef.current) return;
120
+
121
+ const range = selection.getRangeAt(0);
122
+ const container = range.startContainer;
123
+
124
+ if (container.nodeType !== Node.TEXT_NODE) {
125
+ setShowMentions(false);
126
+ return;
127
+ }
128
+
129
+ const text = container.textContent || "";
130
+ const cursorPos = range.startOffset;
131
+ const textBeforeCursor = text.slice(0, cursorPos);
132
+ const atIndex = textBeforeCursor.lastIndexOf("@");
133
+
134
+ if (atIndex === -1) {
135
+ setShowMentions(false);
136
+ return;
137
+ }
138
+
139
+ const replaceRange = document.createRange();
140
+ replaceRange.setStart(container, atIndex);
141
+ replaceRange.setEnd(container, cursorPos);
142
+ selection.removeAllRanges();
143
+ selection.addRange(replaceRange);
144
+
145
+ const chipHtml = `<span class="mention-chip inline-flex items-center px-1.5 py-0.5 rounded bg-accent/20 text-accent font-medium text-sm cursor-default" contenteditable="false" data-agent-id="${agentId}">@${displayName}</span>&nbsp;`;
146
+ document.execCommand("insertHTML", false, chipHtml);
147
+
148
+ setShowMentions(false);
149
+ setMentionFilter("");
150
+ setIsEmpty(false);
151
+ editorRef.current.focus();
152
+ },
153
+ [agents]
154
+ );
155
+
156
+ // Handle paste (plain text only, with @mention chips)
157
+ const handlePaste = useCallback(
158
+ (e: React.ClipboardEvent) => {
159
+ const text = e.clipboardData.getData("text/plain");
160
+ if (text) {
161
+ e.preventDefault();
162
+ document.execCommand("insertText", false, text);
163
+ setIsEmpty(false);
164
+ }
165
+ },
166
+ []
167
+ );
168
+
169
+ // Handle submit
170
+ const handleSubmit = useCallback(
171
+ async (e?: React.FormEvent) => {
172
+ e?.preventDefault();
173
+ const { text, firstMentionId } = getMessageContent();
174
+ if (!text || sending || streaming || disabled) return;
175
+
176
+ const targetAgent = firstMentionId || defaultAgentId;
177
+
178
+ if (editorRef.current) editorRef.current.innerHTML = "";
179
+ setIsEmpty(true);
180
+
181
+ await onSend(text, targetAgent);
182
+ },
183
+ [getMessageContent, sending, streaming, disabled, defaultAgentId, onSend]
184
+ );
185
+
186
+ // Handle keyboard shortcuts
187
+ const handleKeyDown = useCallback(
188
+ (e: React.KeyboardEvent) => {
189
+ // Handle mention popup navigation
190
+ if (showMentions) {
191
+ const filteredAgents = getFilteredAgents(agents, mentionFilter);
192
+
193
+ if (e.key === "ArrowDown") {
194
+ e.preventDefault();
195
+ setMentionHighlightIndex((prev) =>
196
+ prev < filteredAgents.length - 1 ? prev + 1 : 0
197
+ );
198
+ return;
199
+ }
200
+ if (e.key === "ArrowUp") {
201
+ e.preventDefault();
202
+ setMentionHighlightIndex((prev) =>
203
+ prev > 0 ? prev - 1 : filteredAgents.length - 1
204
+ );
205
+ return;
206
+ }
207
+ if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
208
+ e.preventDefault();
209
+ if (filteredAgents.length > 0) {
210
+ insertMention(filteredAgents[mentionHighlightIndex].id);
211
+ }
212
+ return;
213
+ }
214
+ if (e.key === "Escape") {
215
+ e.preventDefault();
216
+ setShowMentions(false);
217
+ return;
218
+ }
219
+ }
220
+
221
+ // Regular Enter to send
222
+ if (e.key === "Enter" && !e.shiftKey) {
223
+ e.preventDefault();
224
+ handleSubmit();
225
+ return;
226
+ }
227
+
228
+ if (e.key === "Escape") {
229
+ setShowMentions(false);
230
+ return;
231
+ }
232
+ },
233
+ [handleSubmit, showMentions, agents, mentionFilter, mentionHighlightIndex, insertMention]
234
+ );
235
+
236
+
237
+
238
+ // Focus editor on mount
239
+ useEffect(() => {
240
+ editorRef.current?.focus();
241
+ }, [channelId]);
242
+
243
+ return (
244
+ <div className={cn("space-y-3", className)}>
245
+ {/* Input Area */}
246
+ <form onSubmit={handleSubmit} className="relative">
247
+ {/* @mention popup */}
248
+ {showMentions && (
249
+ <AgentMentionPopup
250
+ agents={agents}
251
+ filter={mentionFilter}
252
+ onSelect={insertMention}
253
+ onClose={() => setShowMentions(false)}
254
+ highlightedIndex={mentionHighlightIndex}
255
+ />
256
+ )}
257
+
258
+ <div className="flex items-end gap-3">
259
+ <div className="flex-1 min-w-0 relative">
260
+ {/* ContentEditable editor */}
261
+ <div
262
+ ref={editorRef}
263
+ contentEditable={!disabled}
264
+ onInput={handleInput}
265
+ onKeyDown={handleKeyDown}
266
+ onPaste={handlePaste}
267
+ data-placeholder="Message (Enter to send, Shift+Enter for new line, @ to mention)"
268
+ className={cn(
269
+ "w-full px-4 py-3 rounded-[var(--radius-sm)] bg-surface border border-border resize-none min-h-[48px] max-h-[200px] overflow-y-auto text-sm focus:outline-none focus:border-accent/50 break-all",
270
+ "empty:before:content-[attr(data-placeholder)] empty:before:text-foreground-secondary/50 empty:before:pointer-events-none",
271
+ (sending || streaming || disabled) && "opacity-50 pointer-events-none"
272
+ )}
273
+ role="textbox"
274
+ aria-multiline="true"
275
+ suppressContentEditableWarning
276
+ />
277
+
278
+ </div>
279
+
280
+ {/* Stop / Send button */}
281
+ {streaming ? (
282
+ <Button
283
+ type="button"
284
+ variant="destructive"
285
+ size="icon"
286
+ onClick={onAbort}
287
+ className="h-12 w-12 rounded-[var(--radius-sm)] shrink-0"
288
+ title="Stop response"
289
+ >
290
+ <Square className="h-5 w-5" />
291
+ </Button>
292
+ ) : (
293
+ <Button
294
+ type="submit"
295
+ size="icon"
296
+ disabled={isEmpty || sending || streaming || disabled}
297
+ className="h-12 w-12 rounded-[var(--radius-sm)] shrink-0"
298
+ >
299
+ {sending ? (
300
+ <Loader2 className="h-5 w-5 animate-spin" />
301
+ ) : (
302
+ <Send className="h-5 w-5" />
303
+ )}
304
+ </Button>
305
+ )}
306
+ </div>
307
+ </form>
308
+ </div>
309
+ );
310
+ }
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { useState, useRef } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Loader2, ChevronDown } from "lucide-react";
6
+ import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Checkbox } from "@/components/ui/checkbox";
10
+ import { useOpenClaw } from "@/lib/hooks/use-openclaw";
11
+ import type { Channel } from "@/lib/types/chat";
12
+
13
+ interface CreateChannelDialogProps {
14
+ open: boolean;
15
+ onOpenChange: (open: boolean) => void;
16
+ onCreated?: (channel: Channel) => void;
17
+ }
18
+
19
+ export function CreateChannelDialog({ open, onOpenChange, onCreated }: CreateChannelDialogProps) {
20
+ const router = useRouter();
21
+ const { agents } = useOpenClaw();
22
+ const [name, setName] = useState("");
23
+ const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
24
+ const [defaultAgentId, setDefaultAgentId] = useState("");
25
+ const [loading, setLoading] = useState(false);
26
+ const [error, setError] = useState("");
27
+
28
+ // Default to first agent when agents load (once only)
29
+ const didInit = useRef(false);
30
+ if (agents.length > 0 && !didInit.current) {
31
+ didInit.current = true;
32
+ setDefaultAgentId(agents[0].id);
33
+ setSelectedAgents([agents[0].id]);
34
+ }
35
+
36
+ const handleSubmit = async (e: React.FormEvent) => {
37
+ e.preventDefault();
38
+ if (!name.trim() || !defaultAgentId) {
39
+ setError("Please provide a name and select a default agent");
40
+ return;
41
+ }
42
+
43
+ setLoading(true);
44
+ setError("");
45
+
46
+ try {
47
+ const res = await fetch("/api/openclaw/chat/channels", {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify({
51
+ name: name.trim(),
52
+ defaultAgentId,
53
+ agents: [...new Set([defaultAgentId, ...selectedAgents])],
54
+ }),
55
+ });
56
+
57
+ const data = await res.json();
58
+
59
+ if (res.ok && data.channel) {
60
+ onCreated?.(data.channel);
61
+ router.push(`/chat/${data.channel.id}`);
62
+ setName("");
63
+ setSelectedAgents([]);
64
+ setDefaultAgentId("");
65
+ onOpenChange(false);
66
+ } else {
67
+ setError(data.error || "Failed to create channel");
68
+ }
69
+ } catch (err) {
70
+ console.error("Failed to create channel:", err);
71
+ setError("Failed to create channel");
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ };
76
+
77
+ const toggleAgent = (agentId: string) => {
78
+ setSelectedAgents((prev) => {
79
+ const next = prev.includes(agentId)
80
+ ? prev.filter((id) => id !== agentId)
81
+ : [...prev, agentId];
82
+
83
+ if (next.length === 1) {
84
+ setDefaultAgentId(next[0]);
85
+ } else {
86
+ setDefaultAgentId("");
87
+ }
88
+
89
+ return next;
90
+ });
91
+ };
92
+
93
+ return (
94
+ <Dialog open={open} onClose={() => onOpenChange(false)}>
95
+ <DialogHeader>
96
+ <DialogTitle>Create Channel</DialogTitle>
97
+ </DialogHeader>
98
+
99
+ <form onSubmit={handleSubmit} className="space-y-4">
100
+ {/* Name */}
101
+ <div>
102
+ <label className="block text-sm font-medium mb-1.5">Channel Name</label>
103
+ <Input
104
+ value={name}
105
+ onChange={(e) => setName(e.target.value)}
106
+ placeholder="e.g., Project Discussion"
107
+ autoFocus
108
+ />
109
+ </div>
110
+
111
+ {/* Agent Selection */}
112
+ <div>
113
+ <label className="block text-sm font-medium mb-2">Agents</label>
114
+ <div className="selectable-list max-h-48 overflow-y-auto">
115
+ {agents.map((agent) => (
116
+ <div
117
+ key={agent.id}
118
+ className="selectable-list-item cursor-pointer"
119
+ onClick={() => toggleAgent(agent.id)}
120
+ >
121
+ <Checkbox
122
+ checked={selectedAgents.includes(agent.id)}
123
+ />
124
+ <span className="text-sm select-none">{agent.name}</span>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </div>
129
+
130
+ {/* Default Agent */}
131
+ <div>
132
+ <label className="block text-sm font-medium mb-1.5">Default Agent</label>
133
+ <div className="relative">
134
+ <select
135
+ value={defaultAgentId}
136
+ onChange={(e) => setDefaultAgentId(e.target.value)}
137
+ className="input-base appearance-none pr-10 cursor-pointer"
138
+ >
139
+ <option value="">Select an agent</option>
140
+ {agents.map((agent) => (
141
+ <option key={agent.id} value={agent.id} disabled={!selectedAgents.includes(agent.id)}>
142
+ {agent.name}
143
+ </option>
144
+ ))}
145
+ </select>
146
+ <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-foreground-secondary pointer-events-none" />
147
+ </div>
148
+ </div>
149
+
150
+ {/* Error */}
151
+ {error && <p className="text-sm text-error">{error}</p>}
152
+
153
+ {/* Actions */}
154
+ <div className="flex justify-end gap-2 pt-2">
155
+ <Button
156
+ type="button"
157
+ variant="ghost"
158
+ onClick={() => onOpenChange(false)}
159
+ disabled={loading}
160
+ >
161
+ Cancel
162
+ </Button>
163
+ <Button type="submit" disabled={loading || !name.trim() || !defaultAgentId}>
164
+ {loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
165
+ Create Channel
166
+ </Button>
167
+ </div>
168
+ </form>
169
+ </Dialog>
170
+ );
171
+ }