@castlekit/castle 0.1.5 → 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 (68) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +25 -4
  4. package/src/app/api/avatars/[id]/route.ts +122 -25
  5. package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
  6. package/src/app/api/openclaw/agents/route.ts +77 -41
  7. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  8. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  9. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  10. package/src/app/api/openclaw/chat/route.ts +272 -0
  11. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  12. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  13. package/src/app/api/openclaw/config/route.ts +45 -4
  14. package/src/app/api/openclaw/events/route.ts +31 -2
  15. package/src/app/api/openclaw/logs/route.ts +20 -5
  16. package/src/app/api/openclaw/restart/route.ts +12 -4
  17. package/src/app/api/openclaw/session/status/route.ts +42 -0
  18. package/src/app/api/settings/avatar/route.ts +190 -0
  19. package/src/app/api/settings/route.ts +88 -0
  20. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  21. package/src/app/chat/[channelId]/page.tsx +305 -0
  22. package/src/app/chat/layout.tsx +96 -0
  23. package/src/app/chat/page.tsx +52 -0
  24. package/src/app/globals.css +89 -2
  25. package/src/app/layout.tsx +7 -1
  26. package/src/app/page.tsx +147 -28
  27. package/src/app/settings/page.tsx +300 -0
  28. package/src/cli/onboarding.ts +202 -37
  29. package/src/components/chat/agent-mention-popup.tsx +89 -0
  30. package/src/components/chat/archived-channels.tsx +190 -0
  31. package/src/components/chat/channel-list.tsx +140 -0
  32. package/src/components/chat/chat-input.tsx +310 -0
  33. package/src/components/chat/create-channel-dialog.tsx +171 -0
  34. package/src/components/chat/markdown-content.tsx +205 -0
  35. package/src/components/chat/message-bubble.tsx +152 -0
  36. package/src/components/chat/message-list.tsx +508 -0
  37. package/src/components/chat/message-queue.tsx +68 -0
  38. package/src/components/chat/session-divider.tsx +61 -0
  39. package/src/components/chat/session-stats-panel.tsx +139 -0
  40. package/src/components/chat/storage-indicator.tsx +76 -0
  41. package/src/components/layout/sidebar.tsx +126 -45
  42. package/src/components/layout/user-menu.tsx +29 -4
  43. package/src/components/providers/presence-provider.tsx +8 -0
  44. package/src/components/providers/search-provider.tsx +81 -0
  45. package/src/components/search/search-dialog.tsx +269 -0
  46. package/src/components/ui/avatar.tsx +11 -9
  47. package/src/components/ui/dialog.tsx +10 -4
  48. package/src/components/ui/tooltip.tsx +25 -8
  49. package/src/components/ui/twemoji-text.tsx +37 -0
  50. package/src/lib/api-security.ts +188 -0
  51. package/src/lib/config.ts +36 -4
  52. package/src/lib/date-utils.ts +79 -0
  53. package/src/lib/db/__tests__/queries.test.ts +318 -0
  54. package/src/lib/db/index.ts +642 -0
  55. package/src/lib/db/queries.ts +1017 -0
  56. package/src/lib/db/schema.ts +160 -0
  57. package/src/lib/device-identity.ts +303 -0
  58. package/src/lib/gateway-connection.ts +273 -36
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +775 -0
  61. package/src/lib/hooks/use-openclaw.ts +105 -70
  62. package/src/lib/hooks/use-search.ts +113 -0
  63. package/src/lib/hooks/use-session-stats.ts +57 -0
  64. package/src/lib/hooks/use-user-settings.ts +46 -0
  65. package/src/lib/types/chat.ts +186 -0
  66. package/src/lib/types/search.ts +60 -0
  67. package/src/middleware.ts +52 -0
  68. package/vitest.config.ts +13 -0
@@ -14,6 +14,8 @@ import {
14
14
  writeConfig,
15
15
  type CastleConfig,
16
16
  } from "../lib/config.js";
17
+ // Device identity is handled by gateway-connection.ts for the persistent connection.
18
+ // The onboarding wizard uses simple token-only auth for agent discovery.
17
19
 
18
20
  // Read version from package.json at the project root
19
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -56,9 +58,15 @@ interface DiscoveredAgent {
56
58
 
57
59
  /**
58
60
  * Connect to Gateway and discover agents via agents.list.
61
+ * Supports both port-based (local) and full URL (remote) connections.
59
62
  * Returns the list of agents or an empty array on failure.
60
63
  */
61
- async function discoverAgents(port: number, token: string | null): Promise<DiscoveredAgent[]> {
64
+ async function discoverAgents(
65
+ portOrUrl: number | string,
66
+ token: string | null
67
+ ): Promise<DiscoveredAgent[]> {
68
+ const wsUrl = typeof portOrUrl === "string" ? portOrUrl : `ws://127.0.0.1:${portOrUrl}`;
69
+
62
70
  return new Promise((resolve) => {
63
71
  const timeout = setTimeout(() => {
64
72
  try { ws.close(); } catch { /* ignore */ }
@@ -67,7 +75,7 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
67
75
 
68
76
  let ws: WebSocket;
69
77
  try {
70
- ws = new WebSocket(`ws://127.0.0.1:${port}`);
78
+ ws = new WebSocket(wsUrl);
71
79
  } catch {
72
80
  clearTimeout(timeout);
73
81
  resolve([]);
@@ -81,6 +89,8 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
81
89
 
82
90
  ws.on("open", () => {
83
91
  // Send connect handshake
92
+ // NOTE: No device identity here — discoverAgents is just for listing agents
93
+ // during setup. Device auth happens in gateway-connection.ts for the real connection.
84
94
  const connectId = randomUUID();
85
95
  const connectFrame = {
86
96
  type: "req",
@@ -149,6 +159,137 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
149
159
  });
150
160
  }
151
161
 
162
+ /**
163
+ * Test a Gateway connection. Returns true if connection succeeds.
164
+ */
165
+ async function testConnection(
166
+ portOrUrl: number | string,
167
+ token: string | null
168
+ ): Promise<boolean> {
169
+ const wsUrl = typeof portOrUrl === "string" ? portOrUrl : `ws://127.0.0.1:${portOrUrl}`;
170
+
171
+ return new Promise((resolve) => {
172
+ const timeout = setTimeout(() => {
173
+ try { ws.close(); } catch { /* ignore */ }
174
+ resolve(false);
175
+ }, 5000);
176
+
177
+ let ws: WebSocket;
178
+ try {
179
+ ws = new WebSocket(wsUrl);
180
+ } catch {
181
+ clearTimeout(timeout);
182
+ resolve(false);
183
+ return;
184
+ }
185
+
186
+ ws.on("error", () => {
187
+ clearTimeout(timeout);
188
+ resolve(false);
189
+ });
190
+
191
+ ws.on("open", () => {
192
+ clearTimeout(timeout);
193
+ ws.close();
194
+ resolve(true);
195
+ });
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Prompt for manual Gateway configuration (remote or local).
201
+ * Returns connection details or null if cancelled.
202
+ */
203
+ async function promptManualGateway(): Promise<{
204
+ port: number;
205
+ token: string | null;
206
+ gatewayUrl?: string;
207
+ isRemote: boolean;
208
+ } | null> {
209
+ const locationType = await p.select({
210
+ message: "Where is your OpenClaw Gateway?",
211
+ options: [
212
+ {
213
+ value: "local",
214
+ label: "Local machine",
215
+ hint: "Running on this device (127.0.0.1)",
216
+ },
217
+ {
218
+ value: "remote",
219
+ label: "Remote / Tailscale",
220
+ hint: "Running on another machine",
221
+ },
222
+ ],
223
+ });
224
+
225
+ if (p.isCancel(locationType)) return null;
226
+
227
+ let port = 18789;
228
+ let gatewayUrl: string | undefined;
229
+ const isRemote = locationType === "remote";
230
+
231
+ if (isRemote) {
232
+ const urlInput = await p.text({
233
+ message: "Gateway WebSocket URL",
234
+ placeholder: "ws://192.168.1.50:18789",
235
+ validate(value: string | undefined) {
236
+ if (!value?.trim()) return "URL is required";
237
+ if (!value.startsWith("ws://") && !value.startsWith("wss://")) {
238
+ return "URL must start with ws:// or wss://";
239
+ }
240
+ },
241
+ });
242
+
243
+ if (p.isCancel(urlInput)) return null;
244
+ gatewayUrl = urlInput as string;
245
+
246
+ // Extract port from URL for config compatibility
247
+ try {
248
+ const parsed = new URL(gatewayUrl);
249
+ port = parseInt(parsed.port, 10) || 18789;
250
+ } catch {
251
+ port = 18789;
252
+ }
253
+
254
+ // Test the connection
255
+ const testSpinner = p.spinner();
256
+ testSpinner.start("Testing connection...");
257
+ const ok = await testConnection(gatewayUrl, null);
258
+ if (ok) {
259
+ testSpinner.stop(`\x1b[92m✔\x1b[0m Gateway reachable`);
260
+ } else {
261
+ testSpinner.stop(pc.dim("Could not reach Gateway — it may not be running yet"));
262
+ }
263
+ } else {
264
+ const gatewayPort = await p.text({
265
+ message: "OpenClaw Gateway port",
266
+ initialValue: "18789",
267
+ validate(value: string | undefined) {
268
+ const num = parseInt(value || "0", 10);
269
+ if (isNaN(num) || num < 1 || num > 65535) {
270
+ return "Please enter a valid port number (1-65535)";
271
+ }
272
+ },
273
+ });
274
+
275
+ if (p.isCancel(gatewayPort)) return null;
276
+ port = parseInt(gatewayPort as string, 10);
277
+ }
278
+
279
+ // Token entry
280
+ const tokenInput = await p.text({
281
+ message: "Gateway auth token",
282
+ placeholder: "Paste your token (or press Enter to skip)",
283
+ defaultValue: "",
284
+ });
285
+
286
+ if (p.isCancel(tokenInput)) return null;
287
+
288
+ const token = (tokenInput as string) || null;
289
+
290
+ return { port, token, gatewayUrl, isRemote };
291
+ }
292
+
152
293
  export async function runOnboarding(): Promise<void> {
153
294
 
154
295
  p.intro(BLUE_BOLD("Castle Setup"));
@@ -253,47 +394,65 @@ export async function runOnboarding(): Promise<void> {
253
394
  }
254
395
  }
255
396
 
256
- // Step 2: Auto-detect port and token, only ask if not found
397
+ // Step 2: Connection mode auto-detect or manual entry
257
398
  let port = readOpenClawPort() || 18789;
258
399
  let token = readOpenClawToken();
259
-
260
- if (!readOpenClawPort()) {
261
- const gatewayPort = await p.text({
262
- message: "OpenClaw Gateway port",
263
- initialValue: "18789",
264
- validate(value: string | undefined) {
265
- const num = parseInt(value || "0", 10);
266
- if (isNaN(num) || num < 1 || num > 65535) {
267
- return "Please enter a valid port number (1-65535)";
268
- }
269
- },
400
+ let gatewayUrl: string | undefined;
401
+ let isRemote = false;
402
+
403
+ // If we have auto-detected config, offer a choice
404
+ const hasLocalConfig = !!readOpenClawPort() || isOpenClawInstalled();
405
+
406
+ if (hasLocalConfig && token) {
407
+ // Both auto-detect and manual are available
408
+ const connectionMode = await p.select({
409
+ message: "How would you like to connect?",
410
+ options: [
411
+ {
412
+ value: "auto",
413
+ label: `Auto-detected local Gateway ${pc.dim(`(port ${port})`)}`,
414
+ hint: "Recommended for local setups",
415
+ },
416
+ {
417
+ value: "manual",
418
+ label: "Enter Gateway details manually",
419
+ hint: "For remote, Tailscale, or custom setups",
420
+ },
421
+ ],
270
422
  });
271
423
 
272
- if (p.isCancel(gatewayPort)) {
424
+ if (p.isCancel(connectionMode)) {
273
425
  p.cancel("Setup cancelled.");
274
426
  process.exit(0);
275
427
  }
276
428
 
277
- port = parseInt(gatewayPort as string, 10);
278
- }
279
-
280
- if (!token) {
281
- const tokenInput = await p.text({
282
- message: "Enter your OpenClaw Gateway token (or press Enter to skip)",
283
- placeholder: "Leave empty if no auth is configured",
284
- defaultValue: "",
285
- });
286
-
287
- if (p.isCancel(tokenInput)) {
429
+ if (connectionMode === "manual") {
430
+ const manualResult = await promptManualGateway();
431
+ if (!manualResult) {
432
+ p.cancel("Setup cancelled.");
433
+ process.exit(0);
434
+ }
435
+ port = manualResult.port;
436
+ token = manualResult.token;
437
+ gatewayUrl = manualResult.gatewayUrl;
438
+ isRemote = manualResult.isRemote;
439
+ }
440
+ } else if (!token) {
441
+ // No auto-detected token — fall through to manual entry
442
+ const manualResult = await promptManualGateway();
443
+ if (!manualResult) {
288
444
  p.cancel("Setup cancelled.");
289
445
  process.exit(0);
290
446
  }
291
-
292
- token = (tokenInput as string) || null;
447
+ port = manualResult.port;
448
+ token = manualResult.token;
449
+ gatewayUrl = manualResult.gatewayUrl;
450
+ isRemote = manualResult.isRemote;
293
451
  }
294
452
 
295
- // Step 4: Agent Discovery
296
- const agents = await discoverAgents(port, token);
453
+ // Step 3: Agent Discovery (use URL if remote, port if local)
454
+ const agentTarget = gatewayUrl || port;
455
+ const agents = await discoverAgents(agentTarget, token);
297
456
 
298
457
  let primaryAgent: string;
299
458
 
@@ -341,6 +500,8 @@ export async function runOnboarding(): Promise<void> {
341
500
  openclaw: {
342
501
  gateway_port: port,
343
502
  gateway_token: token || undefined,
503
+ gateway_url: gatewayUrl,
504
+ is_remote: isRemote || undefined,
344
505
  primary_agent: primaryAgent,
345
506
  },
346
507
  server: {
@@ -417,6 +578,10 @@ export async function runOnboarding(): Promise<void> {
417
578
  // Nothing on port or lsof not available
418
579
  }
419
580
 
581
+ // Escape XML special characters for plist values
582
+ const xmlEscape = (s: string) =>
583
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
584
+
420
585
  // Install as a persistent service (auto-start on login)
421
586
  if (process.platform === "darwin") {
422
587
  const plistDir = join(home(), "Library", "LaunchAgents");
@@ -430,14 +595,14 @@ export async function runOnboarding(): Promise<void> {
430
595
  <string>com.castlekit.castle</string>
431
596
  <key>ProgramArguments</key>
432
597
  <array>
433
- <string>${nodePath}</string>
434
- <string>${nextBin}</string>
598
+ <string>${xmlEscape(nodePath)}</string>
599
+ <string>${xmlEscape(nextBin)}</string>
435
600
  <string>start</string>
436
601
  <string>-p</string>
437
- <string>${castlePort}</string>
602
+ <string>${xmlEscape(castlePort)}</string>
438
603
  </array>
439
604
  <key>WorkingDirectory</key>
440
- <string>${PROJECT_ROOT}</string>
605
+ <string>${xmlEscape(PROJECT_ROOT)}</string>
441
606
  <key>RunAtLoad</key>
442
607
  <true/>
443
608
  <key>KeepAlive</key>
@@ -446,15 +611,15 @@ export async function runOnboarding(): Promise<void> {
446
611
  <false/>
447
612
  </dict>
448
613
  <key>StandardOutPath</key>
449
- <string>${logsDir}/server.log</string>
614
+ <string>${xmlEscape(logsDir)}/server.log</string>
450
615
  <key>StandardErrorPath</key>
451
- <string>${logsDir}/server.err</string>
616
+ <string>${xmlEscape(logsDir)}/server.err</string>
452
617
  <key>EnvironmentVariables</key>
453
618
  <dict>
454
619
  <key>NODE_ENV</key>
455
620
  <string>production</string>
456
621
  <key>PATH</key>
457
- <string>${process.env.PATH}</string>
622
+ <string>${xmlEscape(process.env.PATH || "")}</string>
458
623
  </dict>
459
624
  </dict>
460
625
  </plist>`;
@@ -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
+ }