@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.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- 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/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- 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 +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- 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 +188 -0
- package/src/lib/config.ts +36 -4
- 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/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- 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,305 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { use, useState, useEffect } from "react";
|
|
4
|
+
import { useSearchParams } from "next/navigation";
|
|
5
|
+
import { WifiOff, X, AlertCircle } from "lucide-react";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { useOpenClaw } from "@/lib/hooks/use-openclaw";
|
|
8
|
+
import { useChat } from "@/lib/hooks/use-chat";
|
|
9
|
+
import { useSessionStats } from "@/lib/hooks/use-session-stats";
|
|
10
|
+
import { useUserSettings } from "@/lib/hooks/use-user-settings";
|
|
11
|
+
import { MessageList } from "@/components/chat/message-list";
|
|
12
|
+
import { ChatInput } from "@/components/chat/chat-input";
|
|
13
|
+
import { SessionStatsPanel } from "@/components/chat/session-stats-panel";
|
|
14
|
+
import { ChatErrorBoundary } from "./error-boundary";
|
|
15
|
+
import type { AgentInfo } from "@/components/chat/agent-mention-popup";
|
|
16
|
+
|
|
17
|
+
interface ChannelPageProps {
|
|
18
|
+
params: Promise<{ channelId: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Module-level flag: once any channel has rendered real content, subsequent
|
|
22
|
+
// channel switches use a smooth opacity transition instead of the skeleton.
|
|
23
|
+
// NOTE: This persists for the entire browser session and never resets.
|
|
24
|
+
// If Castle ever supports logout or multi-user, this will need a reset mechanism.
|
|
25
|
+
let hasEverRendered = false;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Skeleton loader — Facebook-style placeholder while data loads
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const LINE_WIDTHS = ["80%", "60%", "40%", "75%", "55%", "85%", "45%", "70%", "50%"];
|
|
31
|
+
|
|
32
|
+
function SkeletonMessage({ lines = 2, short = false, offset = 0 }: { lines?: number; short?: boolean; offset?: number }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex gap-3 mb-[4px]">
|
|
35
|
+
<div className="skeleton w-9 h-9 rounded-full shrink-0 mt-0.5" />
|
|
36
|
+
<div className="flex flex-col gap-1.5 flex-1">
|
|
37
|
+
<div className="flex items-center gap-2">
|
|
38
|
+
<div className={cn("skeleton h-3.5", short ? "w-16" : "w-24")} />
|
|
39
|
+
<div className="skeleton h-3 w-14" />
|
|
40
|
+
</div>
|
|
41
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
42
|
+
<div
|
|
43
|
+
key={i}
|
|
44
|
+
className="skeleton h-3.5"
|
|
45
|
+
style={{ width: LINE_WIDTHS[(offset + i) % LINE_WIDTHS.length] }}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ChatSkeleton() {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
|
56
|
+
{/* Header skeleton */}
|
|
57
|
+
<div className="py-4 border-b border-border shrink-0 min-h-[83px] flex items-center">
|
|
58
|
+
<div>
|
|
59
|
+
<div className="skeleton h-5 w-40 mb-1.5" />
|
|
60
|
+
<div className="skeleton h-3.5 w-28" />
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Messages skeleton */}
|
|
65
|
+
<div className="flex-1 overflow-hidden py-[20px] pr-[20px]">
|
|
66
|
+
<div className="flex flex-col gap-6">
|
|
67
|
+
<SkeletonMessage lines={3} offset={0} />
|
|
68
|
+
<SkeletonMessage lines={1} short offset={3} />
|
|
69
|
+
<SkeletonMessage lines={2} offset={4} />
|
|
70
|
+
<SkeletonMessage lines={1} short offset={6} />
|
|
71
|
+
<SkeletonMessage lines={2} offset={7} />
|
|
72
|
+
<SkeletonMessage lines={3} offset={1} />
|
|
73
|
+
<SkeletonMessage lines={1} short offset={5} />
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Real input, just disabled while loading */}
|
|
78
|
+
<div className="shrink-0">
|
|
79
|
+
<ChatInput
|
|
80
|
+
onSend={() => Promise.resolve()}
|
|
81
|
+
onAbort={() => Promise.resolve()}
|
|
82
|
+
sending={false}
|
|
83
|
+
streaming={false}
|
|
84
|
+
disabled
|
|
85
|
+
agents={[]}
|
|
86
|
+
channelId=""
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ChannelChatContent({ channelId }: { channelId: string }) {
|
|
94
|
+
// Read ?m= param for scroll-to-message from search results.
|
|
95
|
+
// useSearchParams() is reactive — it updates when the query string
|
|
96
|
+
// changes, even for same-channel navigation (e.g. clicking two
|
|
97
|
+
// different search results in the same channel).
|
|
98
|
+
const searchParams = useSearchParams();
|
|
99
|
+
const highlightMessageId = searchParams.get("m") || undefined;
|
|
100
|
+
const { agents, isConnected, isLoading: gatewayLoading, agentsLoading } = useOpenClaw();
|
|
101
|
+
const [channelName, setChannelName] = useState<string | null>(null);
|
|
102
|
+
const [channelAgentIds, setChannelAgentIds] = useState<string[]>([]);
|
|
103
|
+
const [channelCreatedAt, setChannelCreatedAt] = useState<number | null>(null);
|
|
104
|
+
const [channelArchived, setChannelArchived] = useState(false);
|
|
105
|
+
const { displayName, avatarUrl: userAvatar, isLoading: userSettingsLoading } = useUserSettings();
|
|
106
|
+
|
|
107
|
+
// Mark this channel as last accessed and fetch channel info
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
// Touch (mark as last accessed)
|
|
110
|
+
fetch("/api/openclaw/chat/channels", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({ action: "touch", id: channelId }),
|
|
114
|
+
}).catch(() => {});
|
|
115
|
+
|
|
116
|
+
// Fetch channel details for the name and agents.
|
|
117
|
+
// Try active channels first, then archived if not found.
|
|
118
|
+
// Falls back to channelId as name if both fail (prevents stuck loading).
|
|
119
|
+
fetch("/api/openclaw/chat/channels")
|
|
120
|
+
.then((r) => r.json())
|
|
121
|
+
.then((data) => {
|
|
122
|
+
const ch = (data.channels || []).find(
|
|
123
|
+
(c: { id: string; name: string; agents?: string[] }) =>
|
|
124
|
+
c.id === channelId
|
|
125
|
+
);
|
|
126
|
+
if (ch) {
|
|
127
|
+
setChannelName(ch.name);
|
|
128
|
+
setChannelAgentIds(ch.agents || []);
|
|
129
|
+
setChannelCreatedAt(ch.createdAt ?? null);
|
|
130
|
+
setChannelArchived(false);
|
|
131
|
+
} else {
|
|
132
|
+
// Channel not in active list — check archived channels
|
|
133
|
+
return fetch("/api/openclaw/chat/channels?archived=1")
|
|
134
|
+
.then((r) => r.json())
|
|
135
|
+
.then((archived) => {
|
|
136
|
+
const archivedCh = (archived.channels || []).find(
|
|
137
|
+
(c: { id: string; name: string; agents?: string[] }) =>
|
|
138
|
+
c.id === channelId
|
|
139
|
+
);
|
|
140
|
+
if (archivedCh) {
|
|
141
|
+
setChannelName(archivedCh.name);
|
|
142
|
+
setChannelAgentIds(archivedCh.agents || []);
|
|
143
|
+
setChannelCreatedAt(archivedCh.createdAt ?? null);
|
|
144
|
+
setChannelArchived(true);
|
|
145
|
+
} else {
|
|
146
|
+
// Channel not found anywhere — use ID as fallback name
|
|
147
|
+
setChannelName("Chat");
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
.catch(() => {
|
|
153
|
+
// Network error — fall back so page doesn't stay stuck
|
|
154
|
+
setChannelName("Chat");
|
|
155
|
+
});
|
|
156
|
+
}, [channelId]);
|
|
157
|
+
|
|
158
|
+
// Map agents to the AgentInfo format used by chat components
|
|
159
|
+
const chatAgents: AgentInfo[] = agents.map((a) => ({
|
|
160
|
+
id: a.id,
|
|
161
|
+
name: a.name,
|
|
162
|
+
avatar: a.avatar,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
// Get default agent (first in list)
|
|
166
|
+
const defaultAgentId = agents[0]?.id;
|
|
167
|
+
|
|
168
|
+
const {
|
|
169
|
+
messages,
|
|
170
|
+
isLoading,
|
|
171
|
+
hasMore,
|
|
172
|
+
loadMore,
|
|
173
|
+
loadingMore,
|
|
174
|
+
hasMoreAfter,
|
|
175
|
+
loadNewer,
|
|
176
|
+
loadingNewer,
|
|
177
|
+
streamingMessages,
|
|
178
|
+
isStreaming,
|
|
179
|
+
currentSessionKey,
|
|
180
|
+
sendMessage,
|
|
181
|
+
abortResponse,
|
|
182
|
+
sending,
|
|
183
|
+
sendError,
|
|
184
|
+
clearSendError,
|
|
185
|
+
} = useChat({ channelId, defaultAgentId, anchorMessageId: highlightMessageId });
|
|
186
|
+
|
|
187
|
+
const { stats, isLoading: statsLoading } = useSessionStats({
|
|
188
|
+
sessionKey: currentSessionKey,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Don't render until channel name, agents, and user settings have all loaded.
|
|
192
|
+
const channelReady = channelName !== null && !agentsLoading && !userSettingsLoading;
|
|
193
|
+
|
|
194
|
+
// First cold load → skeleton. If agents/user are already cached (e.g.
|
|
195
|
+
// navigated from dashboard) or we've rendered before, use opacity transition.
|
|
196
|
+
const dataAlreadyCached = !agentsLoading && !userSettingsLoading;
|
|
197
|
+
if (channelReady) hasEverRendered = true;
|
|
198
|
+
|
|
199
|
+
if (!channelReady && !hasEverRendered && !dataAlreadyCached) {
|
|
200
|
+
return <ChatSkeleton />;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className={cn("flex-1 flex flex-col h-full overflow-hidden transition-opacity duration-150", channelReady ? "opacity-100" : "opacity-0")}>
|
|
205
|
+
{/* Channel header — sticky */}
|
|
206
|
+
<div className="pb-4 border-b border-border flex items-center justify-between shrink-0 min-h-[83px]">
|
|
207
|
+
<div>
|
|
208
|
+
<h2 className="text-lg font-semibold text-foreground">
|
|
209
|
+
{channelName}
|
|
210
|
+
{channelArchived && (
|
|
211
|
+
<span className="ml-2 text-sm font-normal text-foreground-secondary">(Archived)</span>
|
|
212
|
+
)}
|
|
213
|
+
</h2>
|
|
214
|
+
{(displayName || channelAgentIds.length > 0) && agents.length > 0 && (
|
|
215
|
+
<p className="text-sm text-foreground-secondary mt-0.5">
|
|
216
|
+
with{" "}
|
|
217
|
+
{(() => {
|
|
218
|
+
const names = [
|
|
219
|
+
displayName,
|
|
220
|
+
...channelAgentIds.map(
|
|
221
|
+
(id) => agents.find((a) => a.id === id)?.name || id
|
|
222
|
+
),
|
|
223
|
+
].filter(Boolean);
|
|
224
|
+
if (names.length <= 2) return names.join(" & ");
|
|
225
|
+
return names.slice(0, -1).join(", ") + " & " + names[names.length - 1];
|
|
226
|
+
})()}
|
|
227
|
+
</p>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Connection warning banner — sticky */}
|
|
233
|
+
{!isConnected && !gatewayLoading && (
|
|
234
|
+
<div className="px-4 py-2 bg-error/10 border-b border-error/20 flex items-center gap-2 text-sm text-error shrink-0">
|
|
235
|
+
<WifiOff className="h-4 w-4" />
|
|
236
|
+
<span>Gateway disconnected. Reconnecting...</span>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{/* Session stats — sticky */}
|
|
241
|
+
<div className="shrink-0">
|
|
242
|
+
<SessionStatsPanel stats={stats} isLoading={statsLoading} />
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{/* Messages — this is the ONLY scrollable area */}
|
|
246
|
+
<MessageList
|
|
247
|
+
messages={messages}
|
|
248
|
+
loading={isLoading}
|
|
249
|
+
loadingMore={loadingMore}
|
|
250
|
+
hasMore={hasMore}
|
|
251
|
+
agents={chatAgents}
|
|
252
|
+
userAvatar={userAvatar}
|
|
253
|
+
streamingMessages={streamingMessages}
|
|
254
|
+
onLoadMore={loadMore}
|
|
255
|
+
hasMoreAfter={hasMoreAfter}
|
|
256
|
+
onLoadNewer={loadNewer}
|
|
257
|
+
loadingNewer={loadingNewer}
|
|
258
|
+
channelId={channelId}
|
|
259
|
+
channelName={channelName}
|
|
260
|
+
channelCreatedAt={channelCreatedAt}
|
|
261
|
+
highlightMessageId={highlightMessageId}
|
|
262
|
+
/>
|
|
263
|
+
|
|
264
|
+
{/* Error toast — sticky above input */}
|
|
265
|
+
{sendError && (
|
|
266
|
+
<div className="mx-4 mb-2 px-4 py-2 rounded-lg bg-error/10 border border-error/20 flex items-center justify-between shrink-0">
|
|
267
|
+
<div className="flex items-center gap-2 text-sm text-error">
|
|
268
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
269
|
+
<span>{sendError}</span>
|
|
270
|
+
</div>
|
|
271
|
+
<button
|
|
272
|
+
onClick={clearSendError}
|
|
273
|
+
className="p-1 hover:bg-error/20 rounded"
|
|
274
|
+
>
|
|
275
|
+
<X className="h-3 w-3 text-error" />
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{/* Input — sticky at bottom */}
|
|
281
|
+
<div className="shrink-0">
|
|
282
|
+
<ChatInput
|
|
283
|
+
onSend={sendMessage}
|
|
284
|
+
onAbort={abortResponse}
|
|
285
|
+
sending={sending}
|
|
286
|
+
streaming={isStreaming}
|
|
287
|
+
disabled={!isConnected && !gatewayLoading}
|
|
288
|
+
agents={chatAgents}
|
|
289
|
+
defaultAgentId={defaultAgentId}
|
|
290
|
+
channelId={channelId}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export default function ChannelPage({ params }: ChannelPageProps) {
|
|
298
|
+
const { channelId } = use(params);
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<ChatErrorBoundary>
|
|
302
|
+
<ChannelChatContent channelId={channelId} />
|
|
303
|
+
</ChatErrorBoundary>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Plus, Archive } from "lucide-react";
|
|
5
|
+
import { Sidebar } from "@/components/layout/sidebar";
|
|
6
|
+
|
|
7
|
+
import { ChannelList } from "@/components/chat/channel-list";
|
|
8
|
+
import { ArchivedChannels } from "@/components/chat/archived-channels";
|
|
9
|
+
import { CreateChannelDialog } from "@/components/chat/create-channel-dialog";
|
|
10
|
+
import { useParams, useRouter } from "next/navigation";
|
|
11
|
+
import type { Channel } from "@/lib/types/chat";
|
|
12
|
+
|
|
13
|
+
export default function ChatLayout({
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}) {
|
|
18
|
+
const params = useParams();
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const channelId = params?.channelId as string | undefined;
|
|
21
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
22
|
+
const [showArchived, setShowArchived] = useState(false);
|
|
23
|
+
const [newChannel, setNewChannel] = useState<Channel | null>(null);
|
|
24
|
+
|
|
25
|
+
const handleChannelCreated = (channel: Channel) => {
|
|
26
|
+
setNewChannel(channel);
|
|
27
|
+
setShowCreate(false);
|
|
28
|
+
router.push(`/chat/${channel.id}`);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="h-screen overflow-hidden bg-background">
|
|
33
|
+
<Sidebar variant="solid" />
|
|
34
|
+
|
|
35
|
+
<div className="h-screen ml-[80px] flex py-[20px]">
|
|
36
|
+
{/* Channel sidebar — floating glass panel, aligned with sidebar pill */}
|
|
37
|
+
<div className="w-[290px] shrink-0 px-[25px]">
|
|
38
|
+
<div className="h-full panel flex flex-col">
|
|
39
|
+
<div className="pl-4 pr-3 py-3 flex items-center justify-between shrink-0 border-b border-border">
|
|
40
|
+
<h2 className="text-sm font-semibold text-foreground">
|
|
41
|
+
Channels
|
|
42
|
+
</h2>
|
|
43
|
+
<button
|
|
44
|
+
onClick={() => setShowCreate(true)}
|
|
45
|
+
title="New channel"
|
|
46
|
+
className="flex items-center justify-center h-7 w-7 rounded-[var(--radius-sm)] bg-accent text-white hover:bg-accent/90 transition-colors cursor-pointer"
|
|
47
|
+
>
|
|
48
|
+
<Plus className="h-4 w-4 stroke-[2.5]" />
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flex-1 overflow-y-auto px-3 py-3">
|
|
52
|
+
<ChannelList
|
|
53
|
+
activeChannelId={channelId}
|
|
54
|
+
showCreateDialog={showCreate}
|
|
55
|
+
onCreateDialogChange={setShowCreate}
|
|
56
|
+
newChannel={newChannel}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="shrink-0 px-3 pb-3">
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => setShowArchived(true)}
|
|
62
|
+
className="flex items-center justify-center gap-2 w-full px-2 py-2 text-xs text-foreground-secondary hover:text-foreground transition-colors cursor-pointer rounded-[var(--radius-sm)] hover:bg-surface-hover"
|
|
63
|
+
>
|
|
64
|
+
<Archive className="h-3.5 w-3.5" />
|
|
65
|
+
Archived channels
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{/* Main content — fills remaining space, aligned with floating boxes */}
|
|
72
|
+
<div className="flex-1 min-w-0 h-full overflow-hidden pr-[20px] flex flex-col">
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Create channel dialog — rendered at layout root, outside glass panel */}
|
|
78
|
+
<CreateChannelDialog
|
|
79
|
+
open={showCreate}
|
|
80
|
+
onOpenChange={setShowCreate}
|
|
81
|
+
onCreated={handleChannelCreated}
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{/* Archived channels dialog */}
|
|
85
|
+
<ArchivedChannels
|
|
86
|
+
open={showArchived}
|
|
87
|
+
onOpenChange={setShowArchived}
|
|
88
|
+
onRestored={(channel) => {
|
|
89
|
+
setNewChannel(channel);
|
|
90
|
+
setShowArchived(false);
|
|
91
|
+
router.push(`/chat/${channel.id}`);
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { MessageCircle, Loader2 } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
export default function ChatPage() {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const [checking, setChecking] = useState(true);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
// Ask the DB for the last accessed channel
|
|
13
|
+
fetch("/api/openclaw/chat/channels?last=1")
|
|
14
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
15
|
+
.then((data) => {
|
|
16
|
+
if (data?.channelId) {
|
|
17
|
+
router.replace(`/chat/${data.channelId}`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// No last accessed — try the most recent channel
|
|
21
|
+
return fetch("/api/openclaw/chat/channels")
|
|
22
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
23
|
+
.then((chData) => {
|
|
24
|
+
const channels = chData?.channels;
|
|
25
|
+
if (channels && channels.length > 0) {
|
|
26
|
+
router.replace(`/chat/${channels[0].id}`);
|
|
27
|
+
} else {
|
|
28
|
+
setChecking(false);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
})
|
|
32
|
+
.catch(() => setChecking(false));
|
|
33
|
+
}, [router]);
|
|
34
|
+
|
|
35
|
+
if (checking) {
|
|
36
|
+
return <div className="flex-1" />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex-1 flex items-center justify-center">
|
|
41
|
+
<div className="text-center space-y-3">
|
|
42
|
+
<MessageCircle className="h-12 w-12 mx-auto text-foreground-secondary/30" />
|
|
43
|
+
<div>
|
|
44
|
+
<h2 className="text-lg font-semibold text-foreground">Welcome to Chat</h2>
|
|
45
|
+
<p className="text-sm text-foreground-secondary mt-1">
|
|
46
|
+
Select a channel from the sidebar or create a new one to start chatting.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/app/globals.css
CHANGED
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
--border: #35353d;
|
|
87
87
|
--border-hover: #45454d;
|
|
88
88
|
|
|
89
|
-
--foreground: #
|
|
90
|
-
--foreground-secondary: #
|
|
89
|
+
--foreground: #d1d2d3;
|
|
90
|
+
--foreground-secondary: #9a9b9e;
|
|
91
91
|
--foreground-muted: #71717a;
|
|
92
92
|
|
|
93
93
|
--accent: #3b82f6;
|
|
@@ -186,6 +186,43 @@ body {
|
|
|
186
186
|
overscroll-behavior: none;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/* Twemoji — inline emoji images matching text size exactly.
|
|
190
|
+
Sized to 1em so the img is no taller than a capital letter,
|
|
191
|
+
preventing any line-height expansion. */
|
|
192
|
+
img.emoji {
|
|
193
|
+
display: inline;
|
|
194
|
+
height: 1em;
|
|
195
|
+
width: 1em;
|
|
196
|
+
margin: 0 0.05em;
|
|
197
|
+
vertical-align: -0.1em;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* Custom scrollbar — overlay style, just the thumb, no track */
|
|
201
|
+
/* WebKit (Chrome, Safari, Edge) */
|
|
202
|
+
::-webkit-scrollbar {
|
|
203
|
+
width: 6px;
|
|
204
|
+
background: none;
|
|
205
|
+
}
|
|
206
|
+
::-webkit-scrollbar-track {
|
|
207
|
+
background: none;
|
|
208
|
+
}
|
|
209
|
+
::-webkit-scrollbar-thumb {
|
|
210
|
+
background: rgba(255, 255, 255, 0.2);
|
|
211
|
+
border-radius: 3px;
|
|
212
|
+
}
|
|
213
|
+
::-webkit-scrollbar-thumb:hover {
|
|
214
|
+
background: rgba(255, 255, 255, 0.35);
|
|
215
|
+
}
|
|
216
|
+
::-webkit-scrollbar-corner {
|
|
217
|
+
background: none;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* Firefox */
|
|
221
|
+
* {
|
|
222
|
+
scrollbar-width: thin;
|
|
223
|
+
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
|
224
|
+
}
|
|
225
|
+
|
|
189
226
|
/* ============================================
|
|
190
227
|
Reusable Component Patterns
|
|
191
228
|
============================================ */
|
|
@@ -221,6 +258,21 @@ body {
|
|
|
221
258
|
@apply cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50;
|
|
222
259
|
}
|
|
223
260
|
|
|
261
|
+
/* Panel — standard container style matching dashboard cards (bg-surface, bordered, rounded).
|
|
262
|
+
Use for sidebars, floating panels, content sections. */
|
|
263
|
+
.panel {
|
|
264
|
+
@apply rounded-[var(--radius-md)] bg-surface border border-border;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* Selectable list — compact spacing for checkbox/radio lists, nav lists, etc.
|
|
268
|
+
Apply .selectable-list to the container; .selectable-list-item to each row. */
|
|
269
|
+
.selectable-list {
|
|
270
|
+
@apply space-y-0.5;
|
|
271
|
+
}
|
|
272
|
+
.selectable-list-item {
|
|
273
|
+
@apply flex items-center gap-2.5 px-2 py-1.5 rounded-[var(--radius-sm)] hover:bg-surface-hover cursor-pointer text-sm;
|
|
274
|
+
}
|
|
275
|
+
|
|
224
276
|
/* Input base - shared input/select/textarea styles */
|
|
225
277
|
.input-base {
|
|
226
278
|
@apply h-11 w-full rounded-[var(--radius-sm)] border px-3 py-2 text-sm text-foreground transition-all;
|
|
@@ -284,3 +336,38 @@ body {
|
|
|
284
336
|
transform: scale(1);
|
|
285
337
|
}
|
|
286
338
|
}
|
|
339
|
+
|
|
340
|
+
/* Search result highlight flash */
|
|
341
|
+
@keyframes highlight-flash {
|
|
342
|
+
0% {
|
|
343
|
+
background-color: oklch(from var(--color-accent) l c h / 0.15);
|
|
344
|
+
}
|
|
345
|
+
100% {
|
|
346
|
+
background-color: transparent;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
.animate-highlight-flash {
|
|
350
|
+
animation: highlight-flash 2s ease-out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* Skeleton shimmer */
|
|
354
|
+
@keyframes shimmer {
|
|
355
|
+
0% {
|
|
356
|
+
background-position: -200% 0;
|
|
357
|
+
}
|
|
358
|
+
100% {
|
|
359
|
+
background-position: 200% 0;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
.skeleton {
|
|
363
|
+
background: linear-gradient(
|
|
364
|
+
90deg,
|
|
365
|
+
oklch(from var(--color-foreground) l c h / 0.06) 25%,
|
|
366
|
+
oklch(from var(--color-foreground) l c h / 0.12) 50%,
|
|
367
|
+
oklch(from var(--color-foreground) l c h / 0.06) 75%
|
|
368
|
+
);
|
|
369
|
+
background-size: 200% 100%;
|
|
370
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
371
|
+
border-radius: 4px;
|
|
372
|
+
}
|
|
373
|
+
|
package/src/app/layout.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
3
|
import { ThemeProvider } from "next-themes";
|
|
4
|
+
import { PresenceProvider } from "@/components/providers/presence-provider";
|
|
5
|
+
import { SearchProvider } from "@/components/providers/search-provider";
|
|
4
6
|
import "./globals.css";
|
|
5
7
|
|
|
6
8
|
const geistSans = Geist({
|
|
@@ -34,7 +36,11 @@ export default function RootLayout({
|
|
|
34
36
|
enableSystem={false}
|
|
35
37
|
disableTransitionOnChange
|
|
36
38
|
>
|
|
37
|
-
|
|
39
|
+
<PresenceProvider>
|
|
40
|
+
<SearchProvider>
|
|
41
|
+
{children}
|
|
42
|
+
</SearchProvider>
|
|
43
|
+
</PresenceProvider>
|
|
38
44
|
</ThemeProvider>
|
|
39
45
|
</body>
|
|
40
46
|
</html>
|