@castlekit/castle 0.3.0 → 0.3.2

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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/install.sh +20 -1
  3. package/package.json +18 -2
  4. package/src/app/api/openclaw/agents/route.ts +7 -1
  5. package/src/app/api/openclaw/chat/channels/route.ts +6 -3
  6. package/src/app/api/openclaw/chat/route.ts +17 -6
  7. package/src/app/api/openclaw/chat/search/route.ts +2 -1
  8. package/src/app/api/openclaw/config/route.ts +2 -0
  9. package/src/app/api/openclaw/events/route.ts +23 -8
  10. package/src/app/api/openclaw/ping/route.ts +5 -0
  11. package/src/app/api/openclaw/session/context/route.ts +163 -0
  12. package/src/app/api/openclaw/session/status/route.ts +179 -11
  13. package/src/app/api/openclaw/sessions/route.ts +2 -0
  14. package/src/app/chat/[channelId]/page.tsx +115 -35
  15. package/src/app/globals.css +10 -0
  16. package/src/app/page.tsx +10 -8
  17. package/src/components/chat/chat-input.tsx +23 -5
  18. package/src/components/chat/message-bubble.tsx +29 -13
  19. package/src/components/chat/message-list.tsx +238 -80
  20. package/src/components/chat/session-stats-panel.tsx +391 -86
  21. package/src/components/providers/search-provider.tsx +33 -4
  22. package/src/lib/db/index.ts +12 -2
  23. package/src/lib/db/queries.ts +199 -72
  24. package/src/lib/db/schema.ts +4 -0
  25. package/src/lib/gateway-connection.ts +24 -3
  26. package/src/lib/hooks/use-chat.ts +219 -241
  27. package/src/lib/hooks/use-compaction-events.ts +132 -0
  28. package/src/lib/hooks/use-context-boundary.ts +82 -0
  29. package/src/lib/hooks/use-openclaw.ts +44 -57
  30. package/src/lib/hooks/use-search.ts +1 -0
  31. package/src/lib/hooks/use-session-stats.ts +4 -1
  32. package/src/lib/sse-singleton.ts +184 -0
  33. package/src/lib/types/chat.ts +22 -6
  34. package/src/lib/db/__tests__/queries.test.ts +0 -318
  35. package/vitest.config.ts +0 -13
@@ -1,12 +1,110 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { ensureGateway } from "@/lib/gateway-connection";
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 — Session stats
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 gateway = ensureGateway();
23
- const result = await gateway.request<SessionStatus>("session.status", {
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 { SessionStatsPanel } from "@/components/chat/session-stats-panel";
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={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>
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
- </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
- )}
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
- {/* Session stats — sticky */}
241
- <div className="shrink-0">
242
- <SessionStatsPanel stats={stats} isLoading={statsLoading} />
243
- </div>
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
- {/* Input — sticky at bottom */}
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>
@@ -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
- <div className="p-8 max-w-4xl">
333
- {/* Header */}
334
- <div className="mb-8">
335
- <PageHeader
336
- title="Castle"
337
- subtitle="The multi-agent workspace"
338
- />
339
- </div>
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 mount
254
+ // Focus editor on channel change or when it becomes enabled
239
255
  useEffect(() => {
240
- editorRef.current?.focus();
241
- }, [channelId]);
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-all",
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
- <TwemojiText>
115
- {isAgent && message.content ? (
116
- <MarkdownContent content={message.content} />
117
- ) : (
118
- <span className="whitespace-pre-wrap">{message.content}</span>
119
- )}
120
- </TwemojiText>
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
- {/* Streaming cursor */}
123
- {isStreaming && (
124
- <span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5 align-text-bottom" />
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