@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.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +20 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- 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 +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/status/route.ts +42 -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 +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +49 -17
- 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 +310 -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 +152 -0
- package/src/components/chat/message-list.tsx +508 -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 +139 -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 +81 -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/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- 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
|
+
}
|