@castlekit/castle 0.3.0 → 0.3.1
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/LICENSE +21 -0
- package/install.sh +20 -1
- package/package.json +17 -2
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/chat/channels/route.ts +6 -3
- package/src/app/api/openclaw/chat/route.ts +17 -6
- package/src/app/api/openclaw/chat/search/route.ts +2 -1
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +179 -11
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/chat/[channelId]/page.tsx +115 -35
- package/src/app/globals.css +10 -0
- package/src/app/page.tsx +10 -8
- package/src/components/chat/chat-input.tsx +23 -5
- package/src/components/chat/message-bubble.tsx +29 -13
- package/src/components/chat/message-list.tsx +238 -80
- package/src/components/chat/session-stats-panel.tsx +391 -86
- package/src/components/providers/search-provider.tsx +33 -4
- package/src/lib/db/index.ts +12 -2
- package/src/lib/db/queries.ts +199 -72
- package/src/lib/db/schema.ts +4 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-chat.ts +219 -241
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +44 -57
- package/src/lib/hooks/use-search.ts +1 -0
- package/src/lib/hooks/use-session-stats.ts +4 -1
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +22 -6
- package/src/lib/db/__tests__/queries.test.ts +0 -318
- package/vitest.config.ts +0 -13
|
@@ -1,12 +1,110 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { getOpenClawDir } from "@/lib/config";
|
|
3
5
|
import { sanitizeForApi } from "@/lib/api-security";
|
|
4
6
|
import type { SessionStatus } from "@/lib/types/chat";
|
|
7
|
+
import JSON5 from "json5";
|
|
5
8
|
|
|
6
9
|
// ============================================================================
|
|
7
|
-
// GET /api/openclaw/session/status?sessionKey=X —
|
|
10
|
+
// GET /api/openclaw/session/status?sessionKey=X — Real session stats
|
|
11
|
+
//
|
|
12
|
+
// Reads directly from the OpenClaw session store on the filesystem.
|
|
13
|
+
// This is the source of truth — the same data the Gateway uses.
|
|
8
14
|
// ============================================================================
|
|
9
15
|
|
|
16
|
+
export const dynamic = "force-dynamic";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* OpenClaw's default context window when nothing is configured.
|
|
20
|
+
* Matches DEFAULT_CONTEXT_TOKENS in the Gateway source (2e5 = 200,000).
|
|
21
|
+
*/
|
|
22
|
+
const OPENCLAW_DEFAULT_CONTEXT_TOKENS = 200_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a session key like "agent:main:castle:2d16fadb-..." to extract the agent ID.
|
|
26
|
+
* Format: agent:<agentId>:<channel>:<channelId>
|
|
27
|
+
*/
|
|
28
|
+
function parseAgentId(sessionKey: string): string | null {
|
|
29
|
+
const parts = sessionKey.split(":");
|
|
30
|
+
// agent:<agentId>:...
|
|
31
|
+
if (parts[0] === "agent" && parts.length >= 2) {
|
|
32
|
+
return parts[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load a session entry from the OpenClaw session store.
|
|
39
|
+
* Reads ~/.openclaw/agents/<agentId>/sessions/sessions.json
|
|
40
|
+
*/
|
|
41
|
+
function loadSessionEntry(
|
|
42
|
+
sessionKey: string,
|
|
43
|
+
agentId: string
|
|
44
|
+
): Record<string, unknown> | null {
|
|
45
|
+
const openclawDir = getOpenClawDir();
|
|
46
|
+
const storePath = join(openclawDir, "agents", agentId, "sessions", "sessions.json");
|
|
47
|
+
|
|
48
|
+
if (!existsSync(storePath)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(storePath, "utf-8");
|
|
54
|
+
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
|
|
55
|
+
return store[sessionKey] ?? null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the effective context window limit from OpenClaw config.
|
|
63
|
+
*
|
|
64
|
+
* Resolution order (matches Gateway logic):
|
|
65
|
+
* 1. agents.<agentId>.contextTokens (per-agent override)
|
|
66
|
+
* 2. agents.defaults.contextTokens (global default)
|
|
67
|
+
* 3. OPENCLAW_DEFAULT_CONTEXT_TOKENS (200k — Gateway hardcoded default)
|
|
68
|
+
*
|
|
69
|
+
* Note: The session entry's `contextTokens` stores the MODEL's max context
|
|
70
|
+
* (e.g. 1M for Sonnet 4.5) which is NOT the operating limit. The real limit
|
|
71
|
+
* is set in config or defaults to 200k.
|
|
72
|
+
*/
|
|
73
|
+
function resolveEffectiveContextLimit(agentId: string): number {
|
|
74
|
+
const openclawDir = getOpenClawDir();
|
|
75
|
+
const configPaths = [
|
|
76
|
+
join(openclawDir, "openclaw.json"),
|
|
77
|
+
join(openclawDir, "openclaw.json5"),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
for (const configPath of configPaths) {
|
|
81
|
+
if (!existsSync(configPath)) continue;
|
|
82
|
+
try {
|
|
83
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
84
|
+
const config = JSON5.parse(raw) as Record<string, unknown>;
|
|
85
|
+
const agents = config.agents as Record<string, unknown> | undefined;
|
|
86
|
+
if (!agents) continue;
|
|
87
|
+
|
|
88
|
+
// 1. Per-agent override: agents.<agentId>.contextTokens
|
|
89
|
+
const agentCfg = agents[agentId] as Record<string, unknown> | undefined;
|
|
90
|
+
if (agentCfg?.contextTokens && typeof agentCfg.contextTokens === "number") {
|
|
91
|
+
return agentCfg.contextTokens;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Global default: agents.defaults.contextTokens
|
|
95
|
+
const defaults = agents.defaults as Record<string, unknown> | undefined;
|
|
96
|
+
if (defaults?.contextTokens && typeof defaults.contextTokens === "number") {
|
|
97
|
+
return defaults.contextTokens;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Continue to next config path
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Fallback: OpenClaw's hardcoded default
|
|
105
|
+
return OPENCLAW_DEFAULT_CONTEXT_TOKENS;
|
|
106
|
+
}
|
|
107
|
+
|
|
10
108
|
export async function GET(request: NextRequest) {
|
|
11
109
|
const { searchParams } = new URL(request.url);
|
|
12
110
|
const sessionKey = searchParams.get("sessionKey");
|
|
@@ -19,20 +117,90 @@ export async function GET(request: NextRequest) {
|
|
|
19
117
|
}
|
|
20
118
|
|
|
21
119
|
try {
|
|
22
|
-
const
|
|
23
|
-
|
|
120
|
+
const agentId = parseAgentId(sessionKey);
|
|
121
|
+
if (!agentId) {
|
|
122
|
+
return NextResponse.json(
|
|
123
|
+
{ error: "Could not parse agentId from sessionKey" },
|
|
124
|
+
{ status: 400 }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const entry = loadSessionEntry(sessionKey, agentId);
|
|
129
|
+
if (!entry) {
|
|
130
|
+
console.log(`[Session Status] No session data for key=${sessionKey} agent=${agentId}`);
|
|
131
|
+
return new NextResponse(null, { status: 204 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Map the raw SessionEntry to our SessionStatus type
|
|
135
|
+
const inputTokens = (entry.inputTokens as number) ?? 0;
|
|
136
|
+
const outputTokens = (entry.outputTokens as number) ?? 0;
|
|
137
|
+
const totalTokens = (entry.totalTokens as number) ?? 0;
|
|
138
|
+
|
|
139
|
+
// The session entry's contextTokens is the MODEL's max (e.g. 1M for Sonnet 4.5).
|
|
140
|
+
// The real operating limit comes from config or defaults to 200k.
|
|
141
|
+
const modelMaxContext = (entry.contextTokens as number) ?? 0;
|
|
142
|
+
const effectiveLimit = resolveEffectiveContextLimit(agentId);
|
|
143
|
+
const percentage = effectiveLimit > 0
|
|
144
|
+
? Math.round((totalTokens / effectiveLimit) * 100)
|
|
145
|
+
: 0;
|
|
146
|
+
|
|
147
|
+
// Extract system prompt report if available
|
|
148
|
+
const spr = entry.systemPromptReport as Record<string, unknown> | undefined;
|
|
149
|
+
let systemPrompt: SessionStatus["systemPrompt"] = undefined;
|
|
150
|
+
|
|
151
|
+
if (spr) {
|
|
152
|
+
const sp = spr.systemPrompt as Record<string, number> | undefined;
|
|
153
|
+
const skills = spr.skills as Record<string, unknown> | undefined;
|
|
154
|
+
const tools = spr.tools as Record<string, unknown> | undefined;
|
|
155
|
+
const files = spr.injectedWorkspaceFiles as Array<Record<string, unknown>> | undefined;
|
|
156
|
+
|
|
157
|
+
systemPrompt = {
|
|
158
|
+
totalChars: sp?.chars ?? 0,
|
|
159
|
+
projectContextChars: sp?.projectContextChars ?? 0,
|
|
160
|
+
nonProjectContextChars: sp?.nonProjectContextChars ?? 0,
|
|
161
|
+
skills: {
|
|
162
|
+
promptChars: (skills?.promptChars as number) ?? 0,
|
|
163
|
+
count: Array.isArray(skills?.entries) ? (skills.entries as unknown[]).length : 0,
|
|
164
|
+
},
|
|
165
|
+
tools: {
|
|
166
|
+
listChars: (tools?.listChars as number) ?? 0,
|
|
167
|
+
schemaChars: (tools?.schemaChars as number) ?? 0,
|
|
168
|
+
count: Array.isArray(tools?.entries) ? (tools.entries as unknown[]).length : 0,
|
|
169
|
+
},
|
|
170
|
+
workspaceFiles: (files ?? []).map((f) => ({
|
|
171
|
+
name: (f.name as string) ?? "",
|
|
172
|
+
injectedChars: (f.injectedChars as number) ?? 0,
|
|
173
|
+
truncated: (f.truncated as boolean) ?? false,
|
|
174
|
+
})),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result: SessionStatus = {
|
|
24
179
|
sessionKey,
|
|
25
|
-
|
|
180
|
+
sessionId: (entry.sessionId as string) ?? "",
|
|
181
|
+
agentId,
|
|
182
|
+
model: (entry.model as string) ?? "unknown",
|
|
183
|
+
modelProvider: (entry.modelProvider as string) ?? "unknown",
|
|
184
|
+
tokens: {
|
|
185
|
+
input: inputTokens,
|
|
186
|
+
output: outputTokens,
|
|
187
|
+
total: totalTokens,
|
|
188
|
+
},
|
|
189
|
+
context: {
|
|
190
|
+
used: totalTokens,
|
|
191
|
+
limit: effectiveLimit,
|
|
192
|
+
modelMax: modelMaxContext,
|
|
193
|
+
percentage: Math.min(percentage, 100),
|
|
194
|
+
},
|
|
195
|
+
compactions: (entry.compactionCount as number) ?? 0,
|
|
196
|
+
thinkingLevel: (entry.thinkingLevel as string) ?? null,
|
|
197
|
+
updatedAt: (entry.updatedAt as number) ?? 0,
|
|
198
|
+
systemPrompt,
|
|
199
|
+
};
|
|
26
200
|
|
|
27
201
|
return NextResponse.json(result);
|
|
28
202
|
} catch (err) {
|
|
29
203
|
const message = (err as Error).message;
|
|
30
|
-
|
|
31
|
-
// Session not found is not an error — just return empty
|
|
32
|
-
if (message.includes("not found") || message.includes("unknown")) {
|
|
33
|
-
return new NextResponse(null, { status: 204 });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
204
|
console.error("[Session Status] Failed:", message);
|
|
37
205
|
return NextResponse.json(
|
|
38
206
|
{ error: sanitizeForApi(message) },
|
|
@@ -52,8 +52,10 @@ export async function GET() {
|
|
|
52
52
|
// Sort by most recently modified
|
|
53
53
|
sessions.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
|
|
54
54
|
|
|
55
|
+
console.log(`[Sessions API] GET OK — ${sessions.length} sessions`);
|
|
55
56
|
return NextResponse.json({ sessions });
|
|
56
57
|
} catch (err) {
|
|
58
|
+
console.error("[Sessions API] GET FAILED:", err instanceof Error ? err.message : "Unknown error");
|
|
57
59
|
return NextResponse.json(
|
|
58
60
|
{ error: err instanceof Error ? err.message : "Failed to read sessions" },
|
|
59
61
|
{ status: 500 }
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { use, useState, useEffect } from "react";
|
|
3
|
+
import { use, useState, useEffect, useCallback, useRef } from "react";
|
|
4
4
|
import { useSearchParams } from "next/navigation";
|
|
5
|
-
import { WifiOff, X, AlertCircle } from "lucide-react";
|
|
5
|
+
import { WifiOff, X, AlertCircle, Zap } from "lucide-react";
|
|
6
6
|
import { cn } from "@/lib/utils";
|
|
7
7
|
import { useOpenClaw } from "@/lib/hooks/use-openclaw";
|
|
8
8
|
import { useChat } from "@/lib/hooks/use-chat";
|
|
9
9
|
import { useSessionStats } from "@/lib/hooks/use-session-stats";
|
|
10
|
+
import { useCompactionEvents } from "@/lib/hooks/use-compaction-events";
|
|
11
|
+
import { useContextBoundary } from "@/lib/hooks/use-context-boundary";
|
|
10
12
|
import { useUserSettings } from "@/lib/hooks/use-user-settings";
|
|
11
13
|
import { MessageList } from "@/components/chat/message-list";
|
|
12
14
|
import { ChatInput } from "@/components/chat/chat-input";
|
|
13
|
-
import {
|
|
15
|
+
import { SessionStatsIndicator } from "@/components/chat/session-stats-panel";
|
|
16
|
+
import { SearchTrigger } from "@/components/providers/search-provider";
|
|
14
17
|
import { ChatErrorBoundary } from "./error-boundary";
|
|
15
18
|
import type { AgentInfo } from "@/components/chat/agent-mention-popup";
|
|
16
19
|
|
|
@@ -62,7 +65,7 @@ function ChatSkeleton() {
|
|
|
62
65
|
</div>
|
|
63
66
|
|
|
64
67
|
{/* Messages skeleton */}
|
|
65
|
-
<div className="flex-1 overflow-hidden py-[20px] pr-[20px]">
|
|
68
|
+
<div className="flex-1 overflow-hidden py-[20px] pr-[20px] flex flex-col justify-end">
|
|
66
69
|
<div className="flex flex-col gap-6">
|
|
67
70
|
<SkeletonMessage lines={3} offset={0} />
|
|
68
71
|
<SkeletonMessage lines={1} short offset={3} />
|
|
@@ -184,10 +187,31 @@ function ChannelChatContent({ channelId }: { channelId: string }) {
|
|
|
184
187
|
clearSendError,
|
|
185
188
|
} = useChat({ channelId, defaultAgentId, anchorMessageId: highlightMessageId });
|
|
186
189
|
|
|
187
|
-
const { stats, isLoading: statsLoading } = useSessionStats({
|
|
190
|
+
const { stats, isLoading: statsLoading, refresh: refreshStats } = useSessionStats({
|
|
188
191
|
sessionKey: currentSessionKey,
|
|
189
192
|
});
|
|
190
193
|
|
|
194
|
+
const { boundaryMessageId, refresh: refreshBoundary } = useContextBoundary({
|
|
195
|
+
sessionKey: currentSessionKey,
|
|
196
|
+
channelId,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const { isCompacting, showBanner: showCompactionBanner, dismissBanner, compactionCount: liveCompactionCount } = useCompactionEvents({
|
|
200
|
+
sessionKey: currentSessionKey,
|
|
201
|
+
onCompactionComplete: () => {
|
|
202
|
+
refreshStats();
|
|
203
|
+
refreshBoundary();
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Ref to the navigate-between-messages function exposed by MessageList
|
|
208
|
+
const navigateRef = useRef<((direction: "up" | "down") => void) | null>(null);
|
|
209
|
+
|
|
210
|
+
// Handle Shift+ArrowUp/Down to navigate between messages
|
|
211
|
+
const handleNavigate = useCallback((direction: "up" | "down") => {
|
|
212
|
+
navigateRef.current?.(direction);
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
191
215
|
// Don't render until channel name, agents, and user settings have all loaded.
|
|
192
216
|
const channelReady = channelName !== null && !agentsLoading && !userSettingsLoading;
|
|
193
217
|
|
|
@@ -201,34 +225,72 @@ function ChannelChatContent({ channelId }: { channelId: string }) {
|
|
|
201
225
|
}
|
|
202
226
|
|
|
203
227
|
return (
|
|
204
|
-
<div className=
|
|
205
|
-
{/* Channel header —
|
|
206
|
-
<div className="pb-
|
|
207
|
-
<div>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{
|
|
211
|
-
|
|
228
|
+
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
|
229
|
+
{/* Channel header — always visible, never fades */}
|
|
230
|
+
<div className="pt-[7px] pb-[30px] border-b border-border shrink-0">
|
|
231
|
+
<div className="flex items-center justify-between gap-4">
|
|
232
|
+
{/* Left: channel name + participants */}
|
|
233
|
+
<div className="min-w-0 min-h-[45px]">
|
|
234
|
+
{channelName ? (
|
|
235
|
+
<>
|
|
236
|
+
<h2 className="text-lg font-semibold text-foreground leading-tight">
|
|
237
|
+
{channelName}
|
|
238
|
+
{channelArchived && (
|
|
239
|
+
<span className="ml-2 text-sm font-normal text-foreground-secondary">(Archived)</span>
|
|
240
|
+
)}
|
|
241
|
+
</h2>
|
|
242
|
+
{(displayName || channelAgentIds.length > 0) && agents.length > 0 && (
|
|
243
|
+
<p className="text-sm text-foreground-secondary mt-0.5">
|
|
244
|
+
with{" "}
|
|
245
|
+
{(() => {
|
|
246
|
+
const names = [
|
|
247
|
+
displayName,
|
|
248
|
+
...channelAgentIds.map(
|
|
249
|
+
(id) => agents.find((a) => a.id === id)?.name || id
|
|
250
|
+
),
|
|
251
|
+
].filter(Boolean);
|
|
252
|
+
if (names.length <= 2) return names.join(" & ");
|
|
253
|
+
return names.slice(0, -1).join(", ") + " & " + names[names.length - 1];
|
|
254
|
+
})()}
|
|
255
|
+
</p>
|
|
256
|
+
)}
|
|
257
|
+
</>
|
|
258
|
+
) : (
|
|
259
|
+
<>
|
|
260
|
+
<div className="skeleton h-5 w-40 rounded mb-1.5" />
|
|
261
|
+
<div className="skeleton h-3.5 w-28 rounded" />
|
|
262
|
+
</>
|
|
212
263
|
)}
|
|
213
|
-
</
|
|
214
|
-
{
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (names.length <= 2) return names.join(" & ");
|
|
225
|
-
return names.slice(0, -1).join(", ") + " & " + names[names.length - 1];
|
|
226
|
-
})()}
|
|
227
|
-
</p>
|
|
228
|
-
)}
|
|
264
|
+
</div>
|
|
265
|
+
{/* Right: session stats + search */}
|
|
266
|
+
<div className="flex items-center gap-5 shrink-0">
|
|
267
|
+
<SessionStatsIndicator
|
|
268
|
+
stats={stats}
|
|
269
|
+
isLoading={statsLoading}
|
|
270
|
+
isCompacting={isCompacting}
|
|
271
|
+
liveCompactionCount={liveCompactionCount}
|
|
272
|
+
/>
|
|
273
|
+
<SearchTrigger />
|
|
274
|
+
</div>
|
|
229
275
|
</div>
|
|
230
276
|
</div>
|
|
231
277
|
|
|
278
|
+
{/* Content area */}
|
|
279
|
+
{!channelReady ? (
|
|
280
|
+
/* Skeleton messages while loading */
|
|
281
|
+
<div className="flex-1 overflow-hidden py-[20px] pr-[20px] flex flex-col justify-end">
|
|
282
|
+
<div className="flex flex-col gap-6">
|
|
283
|
+
<SkeletonMessage lines={3} offset={0} />
|
|
284
|
+
<SkeletonMessage lines={1} short offset={3} />
|
|
285
|
+
<SkeletonMessage lines={2} offset={4} />
|
|
286
|
+
<SkeletonMessage lines={1} short offset={6} />
|
|
287
|
+
<SkeletonMessage lines={2} offset={7} />
|
|
288
|
+
<SkeletonMessage lines={3} offset={1} />
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
) : (
|
|
292
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
293
|
+
|
|
232
294
|
{/* Connection warning banner — sticky */}
|
|
233
295
|
{!isConnected && !gatewayLoading && (
|
|
234
296
|
<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">
|
|
@@ -237,10 +299,21 @@ function ChannelChatContent({ channelId }: { channelId: string }) {
|
|
|
237
299
|
</div>
|
|
238
300
|
)}
|
|
239
301
|
|
|
240
|
-
{/*
|
|
241
|
-
|
|
242
|
-
<
|
|
243
|
-
|
|
302
|
+
{/* Compaction banner */}
|
|
303
|
+
{showCompactionBanner && (
|
|
304
|
+
<div className="px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/20 flex items-center justify-between text-sm text-yellow-600 dark:text-yellow-400 shrink-0">
|
|
305
|
+
<div className="flex items-center gap-2">
|
|
306
|
+
<Zap className="h-4 w-4" />
|
|
307
|
+
<span>Context compacted — older messages have been summarized</span>
|
|
308
|
+
</div>
|
|
309
|
+
<button
|
|
310
|
+
onClick={dismissBanner}
|
|
311
|
+
className="p-1 hover:bg-yellow-500/20 rounded"
|
|
312
|
+
>
|
|
313
|
+
<X className="h-3 w-3" />
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
244
317
|
|
|
245
318
|
{/* Messages — this is the ONLY scrollable area */}
|
|
246
319
|
<MessageList
|
|
@@ -259,6 +332,9 @@ function ChannelChatContent({ channelId }: { channelId: string }) {
|
|
|
259
332
|
channelName={channelName}
|
|
260
333
|
channelCreatedAt={channelCreatedAt}
|
|
261
334
|
highlightMessageId={highlightMessageId}
|
|
335
|
+
navigateRef={navigateRef}
|
|
336
|
+
compactionBoundaryMessageId={boundaryMessageId}
|
|
337
|
+
compactionCount={stats?.compactions ?? 0}
|
|
262
338
|
/>
|
|
263
339
|
|
|
264
340
|
{/* Error toast — sticky above input */}
|
|
@@ -277,17 +353,21 @@ function ChannelChatContent({ channelId }: { channelId: string }) {
|
|
|
277
353
|
</div>
|
|
278
354
|
)}
|
|
279
355
|
|
|
280
|
-
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
{/* Input — always visible, disabled until channel is ready */}
|
|
281
360
|
<div className="shrink-0">
|
|
282
361
|
<ChatInput
|
|
283
362
|
onSend={sendMessage}
|
|
284
363
|
onAbort={abortResponse}
|
|
285
364
|
sending={sending}
|
|
286
365
|
streaming={isStreaming}
|
|
287
|
-
disabled={!isConnected && !gatewayLoading}
|
|
366
|
+
disabled={!channelReady || (!isConnected && !gatewayLoading)}
|
|
288
367
|
agents={chatAgents}
|
|
289
368
|
defaultAgentId={defaultAgentId}
|
|
290
369
|
channelId={channelId}
|
|
370
|
+
onNavigate={handleNavigate}
|
|
291
371
|
/>
|
|
292
372
|
</div>
|
|
293
373
|
</div>
|
package/src/app/globals.css
CHANGED
|
@@ -350,6 +350,16 @@ img.emoji {
|
|
|
350
350
|
animation: highlight-flash 2s ease-out;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/* Typing indicator dot bounce */
|
|
354
|
+
@keyframes dot-bounce {
|
|
355
|
+
0%, 80%, 100% {
|
|
356
|
+
transform: translateY(0);
|
|
357
|
+
}
|
|
358
|
+
40% {
|
|
359
|
+
transform: translateY(-5px);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
353
363
|
/* Skeleton shimmer */
|
|
354
364
|
@keyframes shimmer {
|
|
355
365
|
0% {
|
package/src/app/page.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
|
11
11
|
import { cn } from "@/lib/utils";
|
|
12
12
|
import { useOpenClaw, type OpenClawAgent } from "@/lib/hooks/use-openclaw";
|
|
13
13
|
import { useAgentStatus, type AgentStatus } from "@/lib/hooks/use-agent-status";
|
|
14
|
+
import { SearchTrigger } from "@/components/providers/search-provider";
|
|
14
15
|
|
|
15
16
|
function getInitials(name: string) {
|
|
16
17
|
return name.slice(0, 2).toUpperCase();
|
|
@@ -329,15 +330,16 @@ export default function HomePage() {
|
|
|
329
330
|
<Sidebar variant="solid" />
|
|
330
331
|
|
|
331
332
|
<main className="min-h-screen ml-[80px]">
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
333
|
+
{/* Header — full width */}
|
|
334
|
+
<div className="px-8 py-5 flex items-center justify-between gap-4 border-b border-border">
|
|
335
|
+
<PageHeader
|
|
336
|
+
title="Castle"
|
|
337
|
+
subtitle="The multi-agent workspace"
|
|
338
|
+
/>
|
|
339
|
+
<SearchTrigger />
|
|
340
|
+
</div>
|
|
340
341
|
|
|
342
|
+
<div className="p-8 max-w-4xl">
|
|
341
343
|
{/* OpenClaw Connection Status */}
|
|
342
344
|
<ConnectionCard
|
|
343
345
|
isConnected={isConnected}
|
|
@@ -16,6 +16,8 @@ interface ChatInputProps {
|
|
|
16
16
|
defaultAgentId?: string;
|
|
17
17
|
channelId?: string;
|
|
18
18
|
className?: string;
|
|
19
|
+
/** Called when user presses Shift+ArrowUp/Down to navigate between messages */
|
|
20
|
+
onNavigate?: (direction: "up" | "down") => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export function ChatInput({
|
|
@@ -28,6 +30,7 @@ export function ChatInput({
|
|
|
28
30
|
defaultAgentId,
|
|
29
31
|
channelId,
|
|
30
32
|
className,
|
|
33
|
+
onNavigate,
|
|
31
34
|
}: ChatInputProps) {
|
|
32
35
|
const [showMentions, setShowMentions] = useState(false);
|
|
33
36
|
const [mentionFilter, setMentionFilter] = useState("");
|
|
@@ -218,6 +221,19 @@ export function ChatInput({
|
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
223
|
|
|
224
|
+
// Shift+ArrowUp/Down — navigate between messages
|
|
225
|
+
if (
|
|
226
|
+
(e.key === "ArrowUp" || e.key === "ArrowDown") &&
|
|
227
|
+
e.shiftKey &&
|
|
228
|
+
!e.ctrlKey &&
|
|
229
|
+
!e.metaKey &&
|
|
230
|
+
!e.altKey
|
|
231
|
+
) {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
onNavigate?.(e.key === "ArrowUp" ? "up" : "down");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
221
237
|
// Regular Enter to send
|
|
222
238
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
223
239
|
e.preventDefault();
|
|
@@ -230,15 +246,17 @@ export function ChatInput({
|
|
|
230
246
|
return;
|
|
231
247
|
}
|
|
232
248
|
},
|
|
233
|
-
[handleSubmit, showMentions, agents, mentionFilter, mentionHighlightIndex, insertMention]
|
|
249
|
+
[handleSubmit, showMentions, agents, mentionFilter, mentionHighlightIndex, insertMention, onNavigate]
|
|
234
250
|
);
|
|
235
251
|
|
|
236
252
|
|
|
237
253
|
|
|
238
|
-
// Focus editor on
|
|
254
|
+
// Focus editor on channel change or when it becomes enabled
|
|
239
255
|
useEffect(() => {
|
|
240
|
-
|
|
241
|
-
|
|
256
|
+
if (!disabled) {
|
|
257
|
+
editorRef.current?.focus();
|
|
258
|
+
}
|
|
259
|
+
}, [channelId, disabled]);
|
|
242
260
|
|
|
243
261
|
return (
|
|
244
262
|
<div className={cn("space-y-3", className)}>
|
|
@@ -266,7 +284,7 @@ export function ChatInput({
|
|
|
266
284
|
onPaste={handlePaste}
|
|
267
285
|
data-placeholder="Message (Enter to send, Shift+Enter for new line, @ to mention)"
|
|
268
286
|
className={cn(
|
|
269
|
-
"w-full px-4 py-3 rounded-[var(--radius-sm)] bg-surface border border-border resize-none min-h-[48px] max-h-[200px] overflow-y-auto text-sm focus:outline-none focus:border-accent/50 break-
|
|
287
|
+
"w-full px-4 py-3 rounded-[var(--radius-sm)] bg-surface border border-border resize-none min-h-[48px] max-h-[200px] overflow-y-auto text-sm focus:outline-none focus:border-accent/50 break-words",
|
|
270
288
|
"empty:before:content-[attr(data-placeholder)] empty:before:text-foreground-secondary/50 empty:before:pointer-events-none",
|
|
271
289
|
(sending || streaming || disabled) && "opacity-50 pointer-events-none"
|
|
272
290
|
)}
|
|
@@ -25,6 +25,8 @@ interface MessageBubbleProps {
|
|
|
25
25
|
agentStatus?: AgentStatus;
|
|
26
26
|
userStatus?: AgentStatus;
|
|
27
27
|
highlighted?: boolean;
|
|
28
|
+
/** When true, shows bouncing dots instead of message content (typing indicator) */
|
|
29
|
+
isTypingIndicator?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function MessageBubble({
|
|
@@ -39,6 +41,7 @@ export function MessageBubble({
|
|
|
39
41
|
agentStatus,
|
|
40
42
|
userStatus,
|
|
41
43
|
highlighted,
|
|
44
|
+
isTypingIndicator,
|
|
42
45
|
}: MessageBubbleProps) {
|
|
43
46
|
const formattedTime = formatTime(message.createdAt);
|
|
44
47
|
const fullDateTime = formatDateTime(message.createdAt);
|
|
@@ -88,7 +91,7 @@ export function MessageBubble({
|
|
|
88
91
|
{displayName}
|
|
89
92
|
</span>
|
|
90
93
|
<Tooltip content={fullDateTime} side="top" delay={800}>
|
|
91
|
-
<span className="text-xs text-foreground-secondary hover:text-foreground cursor-pointer transition-colors">
|
|
94
|
+
<span className={cn("text-xs text-foreground-secondary hover:text-foreground cursor-pointer transition-colors", isTypingIndicator && "opacity-0")}>
|
|
92
95
|
{formattedTime}
|
|
93
96
|
</span>
|
|
94
97
|
</Tooltip>
|
|
@@ -96,7 +99,7 @@ export function MessageBubble({
|
|
|
96
99
|
)}
|
|
97
100
|
|
|
98
101
|
{/* Attachments */}
|
|
99
|
-
{hasAttachments && (
|
|
102
|
+
{!isTypingIndicator && hasAttachments && (
|
|
100
103
|
<div className="flex gap-2 flex-wrap mb-1">
|
|
101
104
|
{message.attachments.map((att) => (
|
|
102
105
|
<img
|
|
@@ -110,18 +113,31 @@ export function MessageBubble({
|
|
|
110
113
|
)}
|
|
111
114
|
|
|
112
115
|
{/* Message text — no bubble, just plain text */}
|
|
113
|
-
<div className="text-[15px] text-foreground leading-[26px] break-words">
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
<div className="text-[15px] text-foreground leading-[26px] break-words relative">
|
|
117
|
+
{isTypingIndicator ? (
|
|
118
|
+
<>
|
|
119
|
+
<span className="opacity-0" aria-hidden>Xxxxx</span>
|
|
120
|
+
<span className="absolute left-0 top-0 h-[26px] inline-flex items-center gap-1">
|
|
121
|
+
<span className="w-1.5 h-1.5 bg-foreground-secondary/60 rounded-full" style={{ animation: "dot-bounce 1.4s ease-in-out infinite", animationDelay: "0s" }} />
|
|
122
|
+
<span className="w-1.5 h-1.5 bg-foreground-secondary/60 rounded-full" style={{ animation: "dot-bounce 1.4s ease-in-out infinite", animationDelay: "0.16s" }} />
|
|
123
|
+
<span className="w-1.5 h-1.5 bg-foreground-secondary/60 rounded-full" style={{ animation: "dot-bounce 1.4s ease-in-out infinite", animationDelay: "0.32s" }} />
|
|
124
|
+
</span>
|
|
125
|
+
</>
|
|
126
|
+
) : (
|
|
127
|
+
<>
|
|
128
|
+
<TwemojiText>
|
|
129
|
+
{isAgent && message.content ? (
|
|
130
|
+
<MarkdownContent content={message.content} />
|
|
131
|
+
) : (
|
|
132
|
+
<span className="whitespace-pre-wrap">{message.content}</span>
|
|
133
|
+
)}
|
|
134
|
+
</TwemojiText>
|
|
121
135
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
{/* Streaming cursor */}
|
|
137
|
+
{isStreaming && (
|
|
138
|
+
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5 align-text-bottom" />
|
|
139
|
+
)}
|
|
140
|
+
</>
|
|
125
141
|
)}
|
|
126
142
|
</div>
|
|
127
143
|
|