@castlekit/castle 0.1.6 → 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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/drizzle.config.ts +7 -0
  3. package/install.sh +20 -1
  4. package/next.config.ts +1 -0
  5. package/package.json +35 -3
  6. package/src/app/api/avatars/[id]/route.ts +57 -7
  7. package/src/app/api/openclaw/agents/route.ts +7 -1
  8. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  9. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  10. package/src/app/api/openclaw/chat/channels/route.ts +217 -0
  11. package/src/app/api/openclaw/chat/route.ts +283 -0
  12. package/src/app/api/openclaw/chat/search/route.ts +150 -0
  13. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  14. package/src/app/api/openclaw/config/route.ts +2 -0
  15. package/src/app/api/openclaw/events/route.ts +23 -8
  16. package/src/app/api/openclaw/logs/route.ts +17 -3
  17. package/src/app/api/openclaw/ping/route.ts +5 -0
  18. package/src/app/api/openclaw/restart/route.ts +6 -1
  19. package/src/app/api/openclaw/session/context/route.ts +163 -0
  20. package/src/app/api/openclaw/session/status/route.ts +210 -0
  21. package/src/app/api/openclaw/sessions/route.ts +2 -0
  22. package/src/app/api/settings/avatar/route.ts +190 -0
  23. package/src/app/api/settings/route.ts +88 -0
  24. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  25. package/src/app/chat/[channelId]/page.tsx +385 -0
  26. package/src/app/chat/layout.tsx +96 -0
  27. package/src/app/chat/page.tsx +52 -0
  28. package/src/app/globals.css +99 -2
  29. package/src/app/layout.tsx +7 -1
  30. package/src/app/page.tsx +59 -25
  31. package/src/app/settings/page.tsx +300 -0
  32. package/src/components/chat/agent-mention-popup.tsx +89 -0
  33. package/src/components/chat/archived-channels.tsx +190 -0
  34. package/src/components/chat/channel-list.tsx +140 -0
  35. package/src/components/chat/chat-input.tsx +328 -0
  36. package/src/components/chat/create-channel-dialog.tsx +171 -0
  37. package/src/components/chat/markdown-content.tsx +205 -0
  38. package/src/components/chat/message-bubble.tsx +168 -0
  39. package/src/components/chat/message-list.tsx +666 -0
  40. package/src/components/chat/message-queue.tsx +68 -0
  41. package/src/components/chat/session-divider.tsx +61 -0
  42. package/src/components/chat/session-stats-panel.tsx +444 -0
  43. package/src/components/chat/storage-indicator.tsx +76 -0
  44. package/src/components/layout/sidebar.tsx +126 -45
  45. package/src/components/layout/user-menu.tsx +29 -4
  46. package/src/components/providers/presence-provider.tsx +8 -0
  47. package/src/components/providers/search-provider.tsx +110 -0
  48. package/src/components/search/search-dialog.tsx +269 -0
  49. package/src/components/ui/avatar.tsx +11 -9
  50. package/src/components/ui/dialog.tsx +10 -4
  51. package/src/components/ui/tooltip.tsx +25 -8
  52. package/src/components/ui/twemoji-text.tsx +37 -0
  53. package/src/lib/api-security.ts +125 -0
  54. package/src/lib/date-utils.ts +79 -0
  55. package/src/lib/db/index.ts +652 -0
  56. package/src/lib/db/queries.ts +1144 -0
  57. package/src/lib/db/schema.ts +164 -0
  58. package/src/lib/gateway-connection.ts +24 -3
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +753 -0
  61. package/src/lib/hooks/use-compaction-events.ts +132 -0
  62. package/src/lib/hooks/use-context-boundary.ts +82 -0
  63. package/src/lib/hooks/use-openclaw.ts +122 -100
  64. package/src/lib/hooks/use-search.ts +114 -0
  65. package/src/lib/hooks/use-session-stats.ts +60 -0
  66. package/src/lib/hooks/use-user-settings.ts +46 -0
  67. package/src/lib/sse-singleton.ts +184 -0
  68. package/src/lib/types/chat.ts +202 -0
  69. package/src/lib/types/search.ts +60 -0
  70. package/src/middleware.ts +52 -0
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { useState, useRef } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Loader2, ChevronDown } from "lucide-react";
6
+ import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Checkbox } from "@/components/ui/checkbox";
10
+ import { useOpenClaw } from "@/lib/hooks/use-openclaw";
11
+ import type { Channel } from "@/lib/types/chat";
12
+
13
+ interface CreateChannelDialogProps {
14
+ open: boolean;
15
+ onOpenChange: (open: boolean) => void;
16
+ onCreated?: (channel: Channel) => void;
17
+ }
18
+
19
+ export function CreateChannelDialog({ open, onOpenChange, onCreated }: CreateChannelDialogProps) {
20
+ const router = useRouter();
21
+ const { agents } = useOpenClaw();
22
+ const [name, setName] = useState("");
23
+ const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
24
+ const [defaultAgentId, setDefaultAgentId] = useState("");
25
+ const [loading, setLoading] = useState(false);
26
+ const [error, setError] = useState("");
27
+
28
+ // Default to first agent when agents load (once only)
29
+ const didInit = useRef(false);
30
+ if (agents.length > 0 && !didInit.current) {
31
+ didInit.current = true;
32
+ setDefaultAgentId(agents[0].id);
33
+ setSelectedAgents([agents[0].id]);
34
+ }
35
+
36
+ const handleSubmit = async (e: React.FormEvent) => {
37
+ e.preventDefault();
38
+ if (!name.trim() || !defaultAgentId) {
39
+ setError("Please provide a name and select a default agent");
40
+ return;
41
+ }
42
+
43
+ setLoading(true);
44
+ setError("");
45
+
46
+ try {
47
+ const res = await fetch("/api/openclaw/chat/channels", {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify({
51
+ name: name.trim(),
52
+ defaultAgentId,
53
+ agents: [...new Set([defaultAgentId, ...selectedAgents])],
54
+ }),
55
+ });
56
+
57
+ const data = await res.json();
58
+
59
+ if (res.ok && data.channel) {
60
+ onCreated?.(data.channel);
61
+ router.push(`/chat/${data.channel.id}`);
62
+ setName("");
63
+ setSelectedAgents([]);
64
+ setDefaultAgentId("");
65
+ onOpenChange(false);
66
+ } else {
67
+ setError(data.error || "Failed to create channel");
68
+ }
69
+ } catch (err) {
70
+ console.error("Failed to create channel:", err);
71
+ setError("Failed to create channel");
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ };
76
+
77
+ const toggleAgent = (agentId: string) => {
78
+ setSelectedAgents((prev) => {
79
+ const next = prev.includes(agentId)
80
+ ? prev.filter((id) => id !== agentId)
81
+ : [...prev, agentId];
82
+
83
+ if (next.length === 1) {
84
+ setDefaultAgentId(next[0]);
85
+ } else {
86
+ setDefaultAgentId("");
87
+ }
88
+
89
+ return next;
90
+ });
91
+ };
92
+
93
+ return (
94
+ <Dialog open={open} onClose={() => onOpenChange(false)}>
95
+ <DialogHeader>
96
+ <DialogTitle>Create Channel</DialogTitle>
97
+ </DialogHeader>
98
+
99
+ <form onSubmit={handleSubmit} className="space-y-4">
100
+ {/* Name */}
101
+ <div>
102
+ <label className="block text-sm font-medium mb-1.5">Channel Name</label>
103
+ <Input
104
+ value={name}
105
+ onChange={(e) => setName(e.target.value)}
106
+ placeholder="e.g., Project Discussion"
107
+ autoFocus
108
+ />
109
+ </div>
110
+
111
+ {/* Agent Selection */}
112
+ <div>
113
+ <label className="block text-sm font-medium mb-2">Agents</label>
114
+ <div className="selectable-list max-h-48 overflow-y-auto">
115
+ {agents.map((agent) => (
116
+ <div
117
+ key={agent.id}
118
+ className="selectable-list-item cursor-pointer"
119
+ onClick={() => toggleAgent(agent.id)}
120
+ >
121
+ <Checkbox
122
+ checked={selectedAgents.includes(agent.id)}
123
+ />
124
+ <span className="text-sm select-none">{agent.name}</span>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </div>
129
+
130
+ {/* Default Agent */}
131
+ <div>
132
+ <label className="block text-sm font-medium mb-1.5">Default Agent</label>
133
+ <div className="relative">
134
+ <select
135
+ value={defaultAgentId}
136
+ onChange={(e) => setDefaultAgentId(e.target.value)}
137
+ className="input-base appearance-none pr-10 cursor-pointer"
138
+ >
139
+ <option value="">Select an agent</option>
140
+ {agents.map((agent) => (
141
+ <option key={agent.id} value={agent.id} disabled={!selectedAgents.includes(agent.id)}>
142
+ {agent.name}
143
+ </option>
144
+ ))}
145
+ </select>
146
+ <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-foreground-secondary pointer-events-none" />
147
+ </div>
148
+ </div>
149
+
150
+ {/* Error */}
151
+ {error && <p className="text-sm text-error">{error}</p>}
152
+
153
+ {/* Actions */}
154
+ <div className="flex justify-end gap-2 pt-2">
155
+ <Button
156
+ type="button"
157
+ variant="ghost"
158
+ onClick={() => onOpenChange(false)}
159
+ disabled={loading}
160
+ >
161
+ Cancel
162
+ </Button>
163
+ <Button type="submit" disabled={loading || !name.trim() || !defaultAgentId}>
164
+ {loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
165
+ Create Channel
166
+ </Button>
167
+ </div>
168
+ </form>
169
+ </Dialog>
170
+ );
171
+ }
@@ -0,0 +1,205 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
7
+ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
8
+ import { Copy, Check } from "lucide-react";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ interface MarkdownContentProps {
12
+ content: string;
13
+ className?: string;
14
+ }
15
+
16
+ function CopyButton({ text }: { text: string }) {
17
+ const [copied, setCopied] = useState(false);
18
+
19
+ const handleCopy = useCallback(async () => {
20
+ try {
21
+ await navigator.clipboard.writeText(text);
22
+ setCopied(true);
23
+ setTimeout(() => setCopied(false), 2000);
24
+ } catch {
25
+ // Fallback for non-HTTPS
26
+ const textarea = document.createElement("textarea");
27
+ textarea.value = text;
28
+ document.body.appendChild(textarea);
29
+ textarea.select();
30
+ document.execCommand("copy");
31
+ document.body.removeChild(textarea);
32
+ setCopied(true);
33
+ setTimeout(() => setCopied(false), 2000);
34
+ }
35
+ }, [text]);
36
+
37
+ return (
38
+ <button
39
+ type="button"
40
+ onClick={handleCopy}
41
+ className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 hover:bg-white/20 transition-colors text-white/60 hover:text-white/90"
42
+ title={copied ? "Copied!" : "Copy code"}
43
+ >
44
+ {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
45
+ </button>
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Renders markdown content with syntax highlighting for code blocks.
51
+ * HTML rendering is disabled for XSS safety.
52
+ */
53
+ export function MarkdownContent({ content, className }: MarkdownContentProps) {
54
+ return (
55
+ <div className={cn("prose prose-sm dark:prose-invert max-w-none [&>p]:my-0", className)}>
56
+ <ReactMarkdown
57
+ remarkPlugins={[remarkGfm]}
58
+ components={{
59
+ // Code blocks with syntax highlighting
60
+ code({ className: codeClassName, children, ...props }) {
61
+ const match = /language-(\w+)/.exec(codeClassName || "");
62
+ const codeString = String(children).replace(/\n$/, "");
63
+
64
+ // Block code (with language tag)
65
+ if (match) {
66
+ return (
67
+ <div className="relative group not-prose my-3">
68
+ <div className="flex items-center justify-between px-4 py-1.5 bg-[#1e1e2e] rounded-t-lg border-b border-white/10">
69
+ <span className="text-xs text-white/40 font-mono">{match[1]}</span>
70
+ <CopyButton text={codeString} />
71
+ </div>
72
+ <SyntaxHighlighter
73
+ style={oneDark}
74
+ language={match[1]}
75
+ PreTag="div"
76
+ customStyle={{
77
+ margin: 0,
78
+ borderTopLeftRadius: 0,
79
+ borderTopRightRadius: 0,
80
+ borderBottomLeftRadius: "0.5rem",
81
+ borderBottomRightRadius: "0.5rem",
82
+ fontSize: "0.8125rem",
83
+ lineHeight: "1.5",
84
+ }}
85
+ >
86
+ {codeString}
87
+ </SyntaxHighlighter>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ // Multi-line code without language
93
+ if (codeString.includes("\n")) {
94
+ return (
95
+ <div className="relative group not-prose my-3">
96
+ <CopyButton text={codeString} />
97
+ <SyntaxHighlighter
98
+ style={oneDark}
99
+ PreTag="div"
100
+ customStyle={{
101
+ borderRadius: "0.5rem",
102
+ fontSize: "0.8125rem",
103
+ lineHeight: "1.5",
104
+ }}
105
+ >
106
+ {codeString}
107
+ </SyntaxHighlighter>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ // Inline code
113
+ return (
114
+ <code
115
+ className="px-1.5 py-0.5 rounded bg-surface-hover font-mono text-[0.8125rem]"
116
+ {...props}
117
+ >
118
+ {children}
119
+ </code>
120
+ );
121
+ },
122
+
123
+ // Links
124
+ a({ href, children }) {
125
+ return (
126
+ <a
127
+ href={href}
128
+ target="_blank"
129
+ rel="noopener noreferrer"
130
+ className="text-accent hover:underline"
131
+ >
132
+ {children}
133
+ </a>
134
+ );
135
+ },
136
+
137
+ // Tables
138
+ table({ children }) {
139
+ return (
140
+ <div className="overflow-x-auto my-3 not-prose">
141
+ <table className="min-w-full text-sm border-collapse">
142
+ {children}
143
+ </table>
144
+ </div>
145
+ );
146
+ },
147
+ th({ children }) {
148
+ return (
149
+ <th className="px-3 py-2 text-left text-xs font-medium text-foreground-secondary border-b border-border bg-surface-hover">
150
+ {children}
151
+ </th>
152
+ );
153
+ },
154
+ td({ children }) {
155
+ return (
156
+ <td className="px-3 py-2 text-sm border-b border-border/50">
157
+ {children}
158
+ </td>
159
+ );
160
+ },
161
+
162
+ // Blockquotes
163
+ blockquote({ children }) {
164
+ return (
165
+ <blockquote className="border-l-2 border-accent/50 pl-4 my-3 text-foreground-secondary italic">
166
+ {children}
167
+ </blockquote>
168
+ );
169
+ },
170
+
171
+ // Lists
172
+ ul({ children }) {
173
+ return <ul className="list-disc pl-5 my-2 space-y-1">{children}</ul>;
174
+ },
175
+ ol({ children }) {
176
+ return <ol className="list-decimal pl-5 my-2 space-y-1">{children}</ol>;
177
+ },
178
+
179
+ // Horizontal rule
180
+ hr() {
181
+ return <hr className="my-4 border-border" />;
182
+ },
183
+
184
+ // Paragraphs
185
+ p({ children }) {
186
+ return <p>{children}</p>;
187
+ },
188
+
189
+ // Headings
190
+ h1({ children }) {
191
+ return <h1 className="text-lg font-bold mt-4 mb-2">{children}</h1>;
192
+ },
193
+ h2({ children }) {
194
+ return <h2 className="text-base font-bold mt-3 mb-1.5">{children}</h2>;
195
+ },
196
+ h3({ children }) {
197
+ return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
198
+ },
199
+ }}
200
+ >
201
+ {content}
202
+ </ReactMarkdown>
203
+ </div>
204
+ );
205
+ }
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Bot, User, AlertTriangle, StopCircle } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import { formatTime, formatDateTime } from "@/lib/date-utils";
7
+ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
8
+ import { Tooltip } from "@/components/ui/tooltip";
9
+ import { MarkdownContent } from "./markdown-content";
10
+ import { TwemojiText } from "@/components/ui/twemoji-text";
11
+ import type { ChatMessage } from "@/lib/types/chat";
12
+ import type { AgentInfo } from "./agent-mention-popup";
13
+ import type { AgentStatus } from "@/lib/hooks/use-agent-status";
14
+
15
+ interface MessageBubbleProps {
16
+ message: ChatMessage;
17
+ isAgent: boolean;
18
+ agentName?: string;
19
+ agentAvatar?: string | null;
20
+ userAvatar?: string | null;
21
+ agents: AgentInfo[];
22
+ isStreaming?: boolean;
23
+ /** Whether to show avatar and name (false for consecutive messages from same sender) */
24
+ showHeader?: boolean;
25
+ agentStatus?: AgentStatus;
26
+ userStatus?: AgentStatus;
27
+ highlighted?: boolean;
28
+ /** When true, shows bouncing dots instead of message content (typing indicator) */
29
+ isTypingIndicator?: boolean;
30
+ }
31
+
32
+ export function MessageBubble({
33
+ message,
34
+ isAgent,
35
+ agentName,
36
+ agentAvatar,
37
+ userAvatar,
38
+ agents,
39
+ isStreaming,
40
+ showHeader = true,
41
+ agentStatus,
42
+ userStatus,
43
+ highlighted,
44
+ isTypingIndicator,
45
+ }: MessageBubbleProps) {
46
+ const formattedTime = formatTime(message.createdAt);
47
+ const fullDateTime = formatDateTime(message.createdAt);
48
+
49
+ // Prefer the name stored on the message (always available from DB, no FOUC).
50
+ // Fall back to the live agent name from the gateway, then a generic label.
51
+ const displayName = isAgent
52
+ ? (message.senderName || agentName || "Unknown Agent")
53
+ : "You";
54
+ const avatarSrc = isAgent ? agentAvatar : userAvatar;
55
+ const hasAttachments = message.attachments && message.attachments.length > 0;
56
+
57
+ // Map status to Avatar status prop
58
+ const avatarStatus = isAgent && agentStatus
59
+ ? ({ thinking: "away", active: "online", idle: "offline" } as const)[agentStatus]
60
+ : !isAgent && userStatus
61
+ ? ({ active: "online", idle: "offline", thinking: "away" } as const)[userStatus]
62
+ : undefined;
63
+
64
+ return (
65
+ <div className={cn("flex gap-3", showHeader ? "mb-[4px] first:mt-0" : "mt-0.5 pl-[48px]", highlighted && "bg-accent/10 -mx-2 px-2 py-1")}>
66
+ {/* Avatar — only shown on first message in a group */}
67
+ {showHeader && (
68
+ <div className="mt-0.5">
69
+ <Avatar size="sm" status={avatarStatus} statusPulse={agentStatus === "thinking"}>
70
+ {avatarSrc ? (
71
+ <AvatarImage src={avatarSrc} alt={displayName} />
72
+ ) : (
73
+ <AvatarFallback
74
+ className={cn(
75
+ isAgent ? "bg-accent/20 text-accent" : "bg-foreground/10 text-foreground"
76
+ )}
77
+ >
78
+ {isAgent ? <Bot className="w-4 h-4" /> : <User className="w-4 h-4" />}
79
+ </AvatarFallback>
80
+ )}
81
+ </Avatar>
82
+ </div>
83
+ )}
84
+
85
+ {/* Message content */}
86
+ <div className="flex flex-col min-w-0">
87
+ {/* Name + time header — only on first message in a group */}
88
+ {showHeader && (
89
+ <div className="flex items-baseline gap-2">
90
+ <span className="font-bold text-[15px] text-foreground">
91
+ {displayName}
92
+ </span>
93
+ <Tooltip content={fullDateTime} side="top" delay={800}>
94
+ <span className={cn("text-xs text-foreground-secondary hover:text-foreground cursor-pointer transition-colors", isTypingIndicator && "opacity-0")}>
95
+ {formattedTime}
96
+ </span>
97
+ </Tooltip>
98
+ </div>
99
+ )}
100
+
101
+ {/* Attachments */}
102
+ {!isTypingIndicator && hasAttachments && (
103
+ <div className="flex gap-2 flex-wrap mb-1">
104
+ {message.attachments.map((att) => (
105
+ <img
106
+ key={att.id}
107
+ src={`/api/openclaw/chat/attachments?path=${encodeURIComponent(att.filePath)}`}
108
+ alt={att.originalName || "Attachment"}
109
+ className="max-w-[200px] max-h-[200px] rounded-lg object-cover border border-border"
110
+ />
111
+ ))}
112
+ </div>
113
+ )}
114
+
115
+ {/* Message text — no bubble, just plain text */}
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>
135
+
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
+ </>
141
+ )}
142
+ </div>
143
+
144
+ {/* Status badges */}
145
+ {message.status === "interrupted" && (
146
+ <div className="flex items-center gap-1 text-xs text-warning mt-1">
147
+ <AlertTriangle className="h-3 w-3" />
148
+ <span>Response interrupted</span>
149
+ </div>
150
+ )}
151
+ {message.status === "aborted" && (
152
+ <div className="flex items-center gap-1 text-xs text-foreground-secondary mt-1">
153
+ <StopCircle className="h-3 w-3" />
154
+ <span>Response stopped</span>
155
+ </div>
156
+ )}
157
+
158
+ {/* Token info for agent messages */}
159
+ {isAgent && !isStreaming && (message.inputTokens || message.outputTokens) && (
160
+ <div className="text-xs text-foreground-secondary/60 flex items-center gap-2 mt-1">
161
+ {message.inputTokens != null && <span>{message.inputTokens.toLocaleString()} in</span>}
162
+ {message.outputTokens != null && <span>{message.outputTokens.toLocaleString()} out</span>}
163
+ </div>
164
+ )}
165
+ </div>
166
+ </div>
167
+ );
168
+ }