@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,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
+ }
@@ -0,0 +1,190 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Loader2, RotateCcw, Trash2, Hash, MessageCircle } from "lucide-react";
5
+ import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
6
+ import { Button } from "@/components/ui/button";
7
+ import { formatDateTime, formatTimeAgo } from "@/lib/date-utils";
8
+ import type { Channel } from "@/lib/types/chat";
9
+
10
+ interface ArchivedChannelsProps {
11
+ open: boolean;
12
+ onOpenChange: (open: boolean) => void;
13
+ onRestored?: (channel: Channel) => void;
14
+ }
15
+
16
+ export function ArchivedChannels({
17
+ open,
18
+ onOpenChange,
19
+ onRestored,
20
+ }: ArchivedChannelsProps) {
21
+ const [channels, setChannels] = useState<Channel[]>([]);
22
+ const [loading, setLoading] = useState(true);
23
+ const [actionLoading, setActionLoading] = useState<string | null>(null);
24
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
25
+
26
+ const fetchArchived = async () => {
27
+ setLoading(true);
28
+ try {
29
+ const res = await fetch("/api/openclaw/chat/channels?archived=1");
30
+ if (res.ok) {
31
+ const data = await res.json();
32
+ setChannels(data.channels || []);
33
+ }
34
+ } catch {
35
+ // silent
36
+ } finally {
37
+ setLoading(false);
38
+ }
39
+ };
40
+
41
+ useEffect(() => {
42
+ if (open) {
43
+ fetchArchived();
44
+ setConfirmDelete(null);
45
+ }
46
+ }, [open]);
47
+
48
+ const handleRestore = async (channel: Channel) => {
49
+ setActionLoading(channel.id);
50
+ try {
51
+ const res = await fetch("/api/openclaw/chat/channels", {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify({ action: "restore", id: channel.id }),
55
+ });
56
+ if (res.ok) {
57
+ setChannels((prev) => prev.filter((c) => c.id !== channel.id));
58
+ onRestored?.(channel);
59
+ }
60
+ } catch {
61
+ // silent
62
+ } finally {
63
+ setActionLoading(null);
64
+ }
65
+ };
66
+
67
+ const handleDelete = async (id: string) => {
68
+ setActionLoading(id);
69
+ try {
70
+ const res = await fetch("/api/openclaw/chat/channels", {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify({ action: "delete", id }),
74
+ });
75
+ if (res.ok) {
76
+ setChannels((prev) => prev.filter((c) => c.id !== id));
77
+ setConfirmDelete(null);
78
+ } else {
79
+ const data = await res.json().catch(() => ({}));
80
+ console.error("[Archive] Delete failed:", res.status, data);
81
+ }
82
+ } catch (err) {
83
+ console.error("[Archive] Delete error:", err);
84
+ } finally {
85
+ setActionLoading(null);
86
+ }
87
+ };
88
+
89
+ return (
90
+ <Dialog open={open} onOpenChange={onOpenChange}>
91
+ <DialogHeader>
92
+ <DialogTitle>Archived Channels</DialogTitle>
93
+ </DialogHeader>
94
+
95
+ <div className="min-h-[200px]">
96
+ {loading ? (
97
+ <div className="flex items-center justify-center py-12">
98
+ <Loader2 className="h-6 w-6 animate-spin text-foreground-secondary" />
99
+ </div>
100
+ ) : channels.length === 0 ? (
101
+ <div className="text-center py-12 text-foreground-secondary">
102
+ <MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
103
+ <p className="text-sm">No archived channels</p>
104
+ </div>
105
+ ) : (
106
+ <div className="space-y-3">
107
+ {channels.map((channel) => (
108
+ <div
109
+ key={channel.id}
110
+ className="flex items-center justify-between px-3 py-2.5 rounded-[var(--radius-sm)] bg-surface-hover/50 hover:bg-surface-hover group"
111
+ >
112
+ <div className="flex items-center gap-1.5 min-w-0">
113
+ <Hash className="h-4 w-4 shrink-0 text-foreground-secondary" strokeWidth={2.5} />
114
+ <div className="min-w-0">
115
+ <span className="text-sm text-foreground truncate block">
116
+ {channel.name}
117
+ </span>
118
+ <span className="text-xs text-foreground-secondary">
119
+ Created {formatDateTime(new Date(channel.createdAt).getTime())}
120
+ {channel.archivedAt && (
121
+ <> · Archived {formatTimeAgo(new Date(channel.archivedAt).getTime())}</>
122
+ )}
123
+ </span>
124
+ </div>
125
+ </div>
126
+
127
+ <div className="flex items-center gap-1 shrink-0">
128
+ {confirmDelete === channel.id ? (
129
+ <>
130
+ <span className="text-xs text-red-400 mr-1">
131
+ Delete forever?
132
+ </span>
133
+ <Button
134
+ variant="ghost"
135
+ size="sm"
136
+ className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 px-2 text-xs"
137
+ onClick={() => handleDelete(channel.id)}
138
+ disabled={actionLoading === channel.id}
139
+ >
140
+ {actionLoading === channel.id ? (
141
+ <Loader2 className="h-3 w-3 animate-spin" />
142
+ ) : (
143
+ "Yes"
144
+ )}
145
+ </Button>
146
+ <Button
147
+ variant="ghost"
148
+ size="sm"
149
+ className="h-7 px-2 text-xs"
150
+ onClick={() => setConfirmDelete(null)}
151
+ >
152
+ No
153
+ </Button>
154
+ </>
155
+ ) : (
156
+ <>
157
+ <Button
158
+ variant="ghost"
159
+ size="icon"
160
+ className="h-7 w-7 text-foreground-secondary hover:text-foreground"
161
+ onClick={() => handleRestore(channel)}
162
+ disabled={actionLoading === channel.id}
163
+ title="Restore channel"
164
+ >
165
+ {actionLoading === channel.id ? (
166
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
167
+ ) : (
168
+ <RotateCcw className="h-3.5 w-3.5" />
169
+ )}
170
+ </Button>
171
+ <Button
172
+ variant="ghost"
173
+ size="icon"
174
+ className="h-7 w-7 text-foreground-secondary hover:text-red-400"
175
+ onClick={() => setConfirmDelete(channel.id)}
176
+ title="Delete permanently"
177
+ >
178
+ <Trash2 className="h-3.5 w-3.5" />
179
+ </Button>
180
+ </>
181
+ )}
182
+ </div>
183
+ </div>
184
+ ))}
185
+ </div>
186
+ )}
187
+ </div>
188
+ </Dialog>
189
+ );
190
+ }