@elizaos/client 1.5.5-alpha.10

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 (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/dist/assets/empty-module-CLMscLYw.js +1 -0
  4. package/dist/assets/main-BBZ_3lkn.css +5999 -0
  5. package/dist/assets/main-C5zNUkXH.js +7 -0
  6. package/dist/assets/main-Dz64ENQg.js +614 -0
  7. package/dist/assets/react-vendor-DM5m98rr.js +545 -0
  8. package/dist/assets/ui-vendor-BQCqNqg0.js +1 -0
  9. package/dist/elizaos-avatar.png +0 -0
  10. package/dist/elizaos-icon.png +0 -0
  11. package/dist/elizaos-logo-light.png +0 -0
  12. package/dist/elizaos.webp +0 -0
  13. package/dist/favicon.ico +0 -0
  14. package/dist/images/agents/agent1.png +0 -0
  15. package/dist/images/agents/agent2.png +0 -0
  16. package/dist/images/agents/agent3.png +0 -0
  17. package/dist/images/agents/agent4.png +0 -0
  18. package/dist/images/agents/agent5.png +0 -0
  19. package/dist/index.html +14 -0
  20. package/index.html +24 -0
  21. package/package.json +159 -0
  22. package/postcss.config.js +3 -0
  23. package/public/elizaos-avatar.png +0 -0
  24. package/public/elizaos-icon.png +0 -0
  25. package/public/elizaos-logo-light.png +0 -0
  26. package/public/elizaos.webp +0 -0
  27. package/public/favicon.ico +0 -0
  28. package/public/images/agents/agent1.png +0 -0
  29. package/public/images/agents/agent2.png +0 -0
  30. package/public/images/agents/agent3.png +0 -0
  31. package/public/images/agents/agent4.png +0 -0
  32. package/public/images/agents/agent5.png +0 -0
  33. package/src/App.tsx +222 -0
  34. package/src/components/AgentDetailsPanel.tsx +147 -0
  35. package/src/components/ChatInputArea.tsx +196 -0
  36. package/src/components/ChatMessageListComponent.tsx +139 -0
  37. package/src/components/actionTool.tsx +186 -0
  38. package/src/components/add-agent-card.tsx +77 -0
  39. package/src/components/agent-action-viewer.tsx +816 -0
  40. package/src/components/agent-avatar-stack.tsx +121 -0
  41. package/src/components/agent-card.cy.tsx +259 -0
  42. package/src/components/agent-card.tsx +177 -0
  43. package/src/components/agent-creator.tsx +142 -0
  44. package/src/components/agent-log-viewer.tsx +645 -0
  45. package/src/components/agent-memory-edit-overlay.tsx +461 -0
  46. package/src/components/agent-memory-viewer.tsx +504 -0
  47. package/src/components/agent-settings.tsx +270 -0
  48. package/src/components/agent-sidebar.tsx +178 -0
  49. package/src/components/api-key-dialog.tsx +113 -0
  50. package/src/components/app-sidebar.tsx +685 -0
  51. package/src/components/array-input.tsx +116 -0
  52. package/src/components/audio-recorder.tsx +292 -0
  53. package/src/components/avatar-panel.tsx +141 -0
  54. package/src/components/character-form.tsx +1138 -0
  55. package/src/components/chat.tsx +1813 -0
  56. package/src/components/combobox.tsx +187 -0
  57. package/src/components/confirmation-dialog.tsx +59 -0
  58. package/src/components/connection-error-banner.tsx +101 -0
  59. package/src/components/connection-status.cy.tsx +73 -0
  60. package/src/components/connection-status.tsx +155 -0
  61. package/src/components/copy-button.tsx +35 -0
  62. package/src/components/delete-button.tsx +24 -0
  63. package/src/components/env-settings.tsx +261 -0
  64. package/src/components/group-card.tsx +160 -0
  65. package/src/components/group-panel.tsx +543 -0
  66. package/src/components/input-copy.tsx +21 -0
  67. package/src/components/logs-page.tsx +41 -0
  68. package/src/components/media-content.tsx +385 -0
  69. package/src/components/memory-graph.tsx +170 -0
  70. package/src/components/missing-secrets-dialog.tsx +72 -0
  71. package/src/components/onboarding-tour.tsx +247 -0
  72. package/src/components/page-title.tsx +8 -0
  73. package/src/components/plugins-panel.tsx +383 -0
  74. package/src/components/profile-card.tsx +66 -0
  75. package/src/components/profile-overlay.tsx +283 -0
  76. package/src/components/retry-button.tsx +28 -0
  77. package/src/components/secret-panel.tsx +1505 -0
  78. package/src/components/server-management.tsx +264 -0
  79. package/src/components/split-button.tsx +148 -0
  80. package/src/components/stop-agent-button.tsx +99 -0
  81. package/src/components/ui/alert-dialog.cy.tsx +333 -0
  82. package/src/components/ui/alert-dialog.tsx +115 -0
  83. package/src/components/ui/alert.tsx +49 -0
  84. package/src/components/ui/avatar.cy.tsx +180 -0
  85. package/src/components/ui/avatar.tsx +57 -0
  86. package/src/components/ui/badge.cy.tsx +146 -0
  87. package/src/components/ui/badge.tsx +43 -0
  88. package/src/components/ui/button.cy.tsx +177 -0
  89. package/src/components/ui/button.tsx +56 -0
  90. package/src/components/ui/card.cy.tsx +160 -0
  91. package/src/components/ui/card.tsx +73 -0
  92. package/src/components/ui/chat/animated-markdown.tsx +59 -0
  93. package/src/components/ui/chat/chat-bubble.tsx +178 -0
  94. package/src/components/ui/chat/chat-container.tsx +51 -0
  95. package/src/components/ui/chat/chat-input.cy.tsx +169 -0
  96. package/src/components/ui/chat/chat-input.tsx +47 -0
  97. package/src/components/ui/chat/chat-message-list.tsx +61 -0
  98. package/src/components/ui/chat/chat-tts-button.tsx +199 -0
  99. package/src/components/ui/chat/code-block.tsx +79 -0
  100. package/src/components/ui/chat/expandable-chat.tsx +131 -0
  101. package/src/components/ui/chat/hooks/useAutoScroll.ts +86 -0
  102. package/src/components/ui/chat/markdown.tsx +209 -0
  103. package/src/components/ui/chat/message-loading.tsx +48 -0
  104. package/src/components/ui/checkbox.cy.tsx +170 -0
  105. package/src/components/ui/checkbox.tsx +30 -0
  106. package/src/components/ui/collapsible.cy.tsx +283 -0
  107. package/src/components/ui/collapsible.tsx +9 -0
  108. package/src/components/ui/command.cy.tsx +313 -0
  109. package/src/components/ui/command.tsx +143 -0
  110. package/src/components/ui/dialog.cy.tsx +279 -0
  111. package/src/components/ui/dialog.tsx +104 -0
  112. package/src/components/ui/dropdown-menu.cy.tsx +273 -0
  113. package/src/components/ui/dropdown-menu.tsx +281 -0
  114. package/src/components/ui/input.cy.tsx +82 -0
  115. package/src/components/ui/input.tsx +27 -0
  116. package/src/components/ui/label.cy.tsx +157 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/resizable.tsx +42 -0
  119. package/src/components/ui/scroll-area.cy.tsx +242 -0
  120. package/src/components/ui/scroll-area.tsx +46 -0
  121. package/src/components/ui/select.cy.tsx +277 -0
  122. package/src/components/ui/select.tsx +155 -0
  123. package/src/components/ui/separator.cy.tsx +145 -0
  124. package/src/components/ui/separator.tsx +29 -0
  125. package/src/components/ui/sheet.cy.tsx +324 -0
  126. package/src/components/ui/sheet.tsx +119 -0
  127. package/src/components/ui/sidebar.tsx +734 -0
  128. package/src/components/ui/skeleton.cy.tsx +149 -0
  129. package/src/components/ui/skeleton.tsx +17 -0
  130. package/src/components/ui/split-button.cy.tsx +274 -0
  131. package/src/components/ui/split-button.tsx +112 -0
  132. package/src/components/ui/switch.tsx +28 -0
  133. package/src/components/ui/tabs.cy.tsx +271 -0
  134. package/src/components/ui/tabs.tsx +53 -0
  135. package/src/components/ui/textarea.cy.tsx +136 -0
  136. package/src/components/ui/textarea.tsx +26 -0
  137. package/src/components/ui/toast.cy.tsx +209 -0
  138. package/src/components/ui/toast.tsx +126 -0
  139. package/src/components/ui/toaster.tsx +29 -0
  140. package/src/components/ui/tooltip.cy.tsx +244 -0
  141. package/src/components/ui/tooltip.tsx +30 -0
  142. package/src/config/agent-templates.ts +349 -0
  143. package/src/config/voice-models.ts +181 -0
  144. package/src/constants.ts +23 -0
  145. package/src/context/AuthContext.tsx +44 -0
  146. package/src/context/ConnectionContext.tsx +194 -0
  147. package/src/entry.tsx +9 -0
  148. package/src/hooks/__tests__/use-agent-tab-state.test.ts +137 -0
  149. package/src/hooks/__tests__/use-agent-update.test.tsx +250 -0
  150. package/src/hooks/__tests__/use-character-convert.test.ts +102 -0
  151. package/src/hooks/__tests__/use-panel-width-state.test.ts +243 -0
  152. package/src/hooks/__tests__/use-sidebar-state.test.ts +117 -0
  153. package/src/hooks/use-agent-management.ts +130 -0
  154. package/src/hooks/use-agent-tab-state.ts +74 -0
  155. package/src/hooks/use-agent-update.ts +469 -0
  156. package/src/hooks/use-character-convert.ts +138 -0
  157. package/src/hooks/use-confirmation.ts +55 -0
  158. package/src/hooks/use-delete-agent.ts +123 -0
  159. package/src/hooks/use-dm-channels.ts +198 -0
  160. package/src/hooks/use-elevenlabs-voices.ts +83 -0
  161. package/src/hooks/use-file-upload.ts +224 -0
  162. package/src/hooks/use-mobile.tsx +19 -0
  163. package/src/hooks/use-onboarding.tsx +49 -0
  164. package/src/hooks/use-panel-width-state.ts +147 -0
  165. package/src/hooks/use-partial-update.ts +288 -0
  166. package/src/hooks/use-plugin-details.ts +462 -0
  167. package/src/hooks/use-plugins.ts +119 -0
  168. package/src/hooks/use-query-hooks.ts +1263 -0
  169. package/src/hooks/use-server-agents.ts +62 -0
  170. package/src/hooks/use-server-version.tsx +47 -0
  171. package/src/hooks/use-sidebar-state.ts +50 -0
  172. package/src/hooks/use-socket-chat.ts +264 -0
  173. package/src/hooks/use-toast.ts +260 -0
  174. package/src/hooks/use-version.tsx +64 -0
  175. package/src/index.css +146 -0
  176. package/src/lib/api-client-config.ts +53 -0
  177. package/src/lib/api-type-mappers.ts +196 -0
  178. package/src/lib/export-utils.ts +123 -0
  179. package/src/lib/logger.ts +19 -0
  180. package/src/lib/media-utils.ts +170 -0
  181. package/src/lib/pca.test.ts +17 -0
  182. package/src/lib/pca.ts +52 -0
  183. package/src/lib/socketio-manager.ts +664 -0
  184. package/src/lib/utils.ts +168 -0
  185. package/src/main.tsx +16 -0
  186. package/src/mocks/empty-module.ts +12 -0
  187. package/src/mocks/node-module.ts +57 -0
  188. package/src/polyfills.ts +37 -0
  189. package/src/routes/agent-detail.tsx +30 -0
  190. package/src/routes/agent-list.tsx +27 -0
  191. package/src/routes/agent-settings.tsx +48 -0
  192. package/src/routes/character-detail.tsx +52 -0
  193. package/src/routes/character-form.tsx +79 -0
  194. package/src/routes/character-list.tsx +38 -0
  195. package/src/routes/chat.tsx +128 -0
  196. package/src/routes/createAgent.tsx +13 -0
  197. package/src/routes/group-new.tsx +50 -0
  198. package/src/routes/group.tsx +29 -0
  199. package/src/routes/home.tsx +218 -0
  200. package/src/routes/not-found.tsx +71 -0
  201. package/src/test/setup.ts +154 -0
  202. package/src/types/crypto-browserify.d.ts +4 -0
  203. package/src/types/index.ts +13 -0
  204. package/src/types/rooms.ts +8 -0
  205. package/src/types.ts +84 -0
  206. package/src/vite-env.d.ts +40 -0
  207. package/tailwind.config.ts +90 -0
  208. package/tsconfig.json +10 -0
  209. package/vite.config.ts +102 -0
@@ -0,0 +1,1813 @@
1
+ import CopyButton from '@/components/copy-button';
2
+ import DeleteButton from '@/components/delete-button';
3
+ import RetryButton from '@/components/retry-button';
4
+ import MediaContent from '@/components/media-content';
5
+ import ProfileOverlay from '@/components/profile-overlay';
6
+ import { Avatar, AvatarImage } from '@/components/ui/avatar';
7
+ import { Badge } from '@/components/ui/badge';
8
+ import { Button } from '@/components/ui/button';
9
+ import ConfirmationDialog from '@/components/confirmation-dialog';
10
+ import { useConfirmation } from '@/hooks/use-confirmation';
11
+ import { ChatBubbleMessage, ChatBubbleTimestamp } from '@/components/ui/chat/chat-bubble';
12
+ import ChatTtsButton from '@/components/ui/chat/chat-tts-button';
13
+ import { Markdown } from '@/components/ui/chat/markdown';
14
+ import { AnimatedMarkdown } from '@/components/ui/chat/animated-markdown';
15
+ import { useAutoScroll } from '@/components/ui/chat/hooks/useAutoScroll';
16
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
17
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
18
+ import { SplitButton } from '@/components/ui/split-button';
19
+ import { Tool, type ToolPart } from '@/components/actionTool';
20
+ import { CHAT_SOURCE, GROUP_CHAT_SOURCE, USER_NAME } from '@/constants';
21
+ import { useFileUpload } from '@/hooks/use-file-upload';
22
+ import {
23
+ useAgent,
24
+ useAgentsWithDetails,
25
+ useChannelDetails,
26
+ useChannelMessages,
27
+ useChannelParticipants,
28
+ useClearChannelMessages,
29
+ useDeleteChannelMessage,
30
+ type UiMessage,
31
+ } from '@/hooks/use-query-hooks';
32
+ import { useSocketChat } from '@/hooks/use-socket-chat';
33
+ import { useToast } from '@/hooks/use-toast';
34
+ import { createElizaClient } from '@/lib/api-client-config';
35
+ import clientLogger from '@/lib/logger';
36
+ import { parseMediaFromText, removeMediaUrlsFromText, type MediaInfo } from '@/lib/media-utils';
37
+ import {
38
+ cn,
39
+ generateGroupName,
40
+ getAgentAvatar,
41
+ getEntityId,
42
+ moment,
43
+ randomUUID,
44
+ } from '@/lib/utils';
45
+ import type { Agent, Media, UUID } from '@elizaos/core';
46
+ import {
47
+ AgentStatus,
48
+ ChannelType,
49
+ ContentType as CoreContentType,
50
+ validateUuid,
51
+ } from '@elizaos/core';
52
+ import { useQueryClient } from '@tanstack/react-query';
53
+ import {
54
+ Trash,
55
+ StopCircle,
56
+ ArrowUpFromLine,
57
+ Edit,
58
+ Eraser,
59
+ Clock,
60
+ ChevronDown,
61
+ Loader2,
62
+ PanelRight,
63
+ PanelRightClose,
64
+ Plus,
65
+ Trash2,
66
+ } from 'lucide-react';
67
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
68
+ import { AgentSidebar } from './agent-sidebar';
69
+ import { ChatInputArea } from './ChatInputArea';
70
+ import { ChatMessageListComponent } from './ChatMessageListComponent';
71
+ import GroupPanel from './group-panel';
72
+
73
+ import {
74
+ DropdownMenu,
75
+ DropdownMenuContent,
76
+ DropdownMenuItem,
77
+ DropdownMenuLabel,
78
+ DropdownMenuSeparator,
79
+ DropdownMenuTrigger,
80
+ } from '@/components/ui/dropdown-menu';
81
+ import { useCreateDmChannel, useDmChannelsForAgent } from '@/hooks/use-dm-channels';
82
+ import { useSidebarState } from '@/hooks/use-sidebar-state';
83
+ import { usePanelWidthState } from '@/hooks/use-panel-width-state';
84
+ import relativeTime from 'dayjs/plugin/relativeTime';
85
+ import type { MessageChannel } from '@/types';
86
+ import { useLocation, useNavigate } from 'react-router-dom';
87
+ import { useAgentManagement } from '@/hooks/use-agent-management';
88
+ import { useDeleteAgent } from '@/hooks/use-delete-agent';
89
+ import { exportCharacterAsJson } from '@/lib/export-utils';
90
+ moment.extend(relativeTime);
91
+
92
+ const DEFAULT_SERVER_ID = '00000000-0000-0000-0000-000000000000' as UUID;
93
+
94
+ // Helper function to convert action message to ToolPart format
95
+ const convertActionMessageToToolPart = (message: UiMessage): ToolPart => {
96
+ const rawMessage = message.rawMessage as any; // Type assertion to access raw message properties
97
+
98
+ // Map actionStatus to ToolPart state
99
+ const mapActionStatusToState = (status: string): ToolPart['state'] => {
100
+ switch (status) {
101
+ case 'pending':
102
+ case 'executing':
103
+ case 'running':
104
+ return 'input-streaming';
105
+ case 'completed':
106
+ case 'success':
107
+ return 'output-available';
108
+ case 'failed':
109
+ case 'error':
110
+ return 'output-error';
111
+ default:
112
+ return 'input-available';
113
+ }
114
+ };
115
+
116
+ // Get the primary action name (first action or fallback to message type)
117
+ const actionName = rawMessage.actions?.[0] || rawMessage.action || 'ACTION';
118
+ const actionStatus = rawMessage.actionStatus || 'completed';
119
+ const actionId = rawMessage.actionId;
120
+
121
+ // Create input data from available action properties
122
+ const inputData: Record<string, unknown> = {};
123
+ if (rawMessage.actions) inputData.actions = rawMessage.actions;
124
+ if (rawMessage.action) inputData.action = rawMessage.action;
125
+ if (rawMessage.thought) inputData.thought = rawMessage.thought;
126
+
127
+ // Create output data based on status and content
128
+ const outputData: Record<string, unknown> = {};
129
+ if (rawMessage.text) outputData.result = rawMessage.text;
130
+ if (actionStatus) outputData.status = actionStatus;
131
+ if (rawMessage.thought) outputData.thought = rawMessage.thought;
132
+ if (rawMessage.actionResult) outputData.actionResult = rawMessage.actionResult;
133
+
134
+ // Handle error cases
135
+ const isError = actionStatus === 'failed' || actionStatus === 'error';
136
+ const errorText = isError ? rawMessage.text || 'Action failed' : undefined;
137
+
138
+ return {
139
+ type: actionName,
140
+ state: mapActionStatusToState(actionStatus),
141
+ toolCallId: actionId,
142
+ input: Object.keys(inputData).length > 0 ? inputData : undefined,
143
+ output: Object.keys(outputData).length > 0 ? outputData : undefined,
144
+ errorText,
145
+ };
146
+ };
147
+
148
+ interface UnifiedChatViewProps {
149
+ chatType: ChannelType.DM | ChannelType.GROUP;
150
+ contextId: UUID; // agentId for DM, channelId for GROUP
151
+ serverId?: UUID; // Required for GROUP, optional for DM
152
+ initialDmChannelId?: UUID; // New prop for specific DM channel from URL
153
+ }
154
+
155
+ // Consolidated chat state type
156
+ interface ChatUIState {
157
+ showGroupEditPanel: boolean;
158
+ showProfileOverlay: boolean;
159
+ input: string;
160
+ inputDisabled: boolean;
161
+ selectedGroupAgentId: UUID | null;
162
+ currentDmChannelId: UUID | null;
163
+ isCreatingDM: boolean;
164
+ isMobile: boolean; // Add mobile state
165
+ }
166
+
167
+ export interface ChatLocationState {
168
+ forceNew?: boolean;
169
+ }
170
+
171
+ // Message content component - exported for use in ChatMessageListComponent
172
+ export const MemoizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
173
+ // Only re-render if the message content, animation state, or other key props change
174
+ return (
175
+ prevProps.message.id === nextProps.message.id &&
176
+ prevProps.message.text === nextProps.message.text &&
177
+ prevProps.message.isLoading === nextProps.message.isLoading &&
178
+ prevProps.shouldAnimate === nextProps.shouldAnimate &&
179
+ prevProps.isUser === nextProps.isUser
180
+ );
181
+ });
182
+
183
+ export function MessageContent({
184
+ message,
185
+ agentForTts,
186
+ shouldAnimate,
187
+ onDelete,
188
+ onRetry,
189
+ isUser,
190
+ getAgentInMessage,
191
+ agentAvatarMap,
192
+ chatType,
193
+ }: {
194
+ message: UiMessage;
195
+ agentForTts?: Agent | Partial<Agent> | null;
196
+ shouldAnimate?: boolean;
197
+ onDelete: (id: string) => void;
198
+ onRetry?: (messageText: string) => void;
199
+ isUser: boolean;
200
+ getAgentInMessage?: (agentId: UUID) => Partial<Agent> | undefined;
201
+ agentAvatarMap?: Record<UUID, string | null>;
202
+ chatType?: ChannelType;
203
+ }) {
204
+ const isActionMessage = message.type === 'agent_action' || message.source === 'agent_action';
205
+ return (
206
+ <div className="flex flex-col w-full">
207
+ <ChatBubbleMessage
208
+ isLoading={message.isLoading}
209
+ {...(isUser ? { variant: 'sent' } : {})}
210
+ {...(!message.text && !message.attachments?.length ? { className: 'bg-transparent' } : {})}
211
+ >
212
+ {isActionMessage ? (
213
+ <Tool
214
+ toolPart={convertActionMessageToToolPart(message)}
215
+ defaultOpen={false}
216
+ className="max-w-none"
217
+ />
218
+ ) : (
219
+ <div>
220
+ {(() => {
221
+ if (!message.text) return null;
222
+
223
+ const mediaInfos = parseMediaFromText(message.text);
224
+ const attachmentUrls = new Set(
225
+ message.attachments?.map((att) => att.url).filter(Boolean) || []
226
+ );
227
+ const uniqueMediaInfos = mediaInfos.filter((media) => !attachmentUrls.has(media.url));
228
+ const textWithoutUrls = removeMediaUrlsFromText(message.text, mediaInfos);
229
+
230
+ return (
231
+ <div className="space-y-3">
232
+ {textWithoutUrls.trim() && (
233
+ <div>
234
+ {isUser ? (
235
+ <Markdown className="prose-sm max-w-none" variant="user">
236
+ {textWithoutUrls}
237
+ </Markdown>
238
+ ) : (
239
+ <AnimatedMarkdown
240
+ className="prose-sm max-w-none"
241
+ variant="agent"
242
+ shouldAnimate={shouldAnimate}
243
+ messageId={message.id}
244
+ >
245
+ {textWithoutUrls}
246
+ </AnimatedMarkdown>
247
+ )}
248
+ </div>
249
+ )}
250
+
251
+ {uniqueMediaInfos.length > 0 && (
252
+ <div className="space-y-2">
253
+ {uniqueMediaInfos.map((media, index) => (
254
+ <div key={`${media.url}-${index}`}>
255
+ <MediaContent url={media.url} title="Shared media" />
256
+ </div>
257
+ ))}
258
+ </div>
259
+ )}
260
+ </div>
261
+ );
262
+ })()}
263
+ </div>
264
+ )}
265
+
266
+ {message.attachments
267
+ ?.filter((attachment) => attachment.url && attachment.url.trim() !== '')
268
+ .map((attachment: Media) => (
269
+ <MediaContent
270
+ key={`${attachment.url}-${attachment.title}`}
271
+ url={attachment.url}
272
+ title={attachment.title || 'Attachment'}
273
+ />
274
+ ))}
275
+ </ChatBubbleMessage>
276
+
277
+ <div className="flex items-center justify-between w-full py-1">
278
+ <div>
279
+ {!isUser && (message.text || message.attachments?.length) && message.createdAt && (
280
+ <ChatBubbleTimestamp
281
+ className="text-muted-foreground"
282
+ timestamp={moment(message.createdAt).format('LT')}
283
+ />
284
+ )}
285
+ </div>
286
+ <div
287
+ className={`flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
288
+ >
289
+ {!isUser && message.text && !message.isLoading && agentForTts?.id && (
290
+ <>
291
+ <CopyButton text={message.text} />
292
+ <ChatTtsButton agentId={agentForTts.id} text={message.text} />
293
+ </>
294
+ )}
295
+ {isUser && message.text && !message.isLoading && onRetry && (
296
+ <RetryButton onClick={() => onRetry(message.text || '')} />
297
+ )}
298
+ <DeleteButton onClick={() => onDelete(message.id as string)} />
299
+ </div>
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ export default function Chat({
306
+ chatType,
307
+ contextId,
308
+ serverId,
309
+ initialDmChannelId,
310
+ }: UnifiedChatViewProps) {
311
+ const { toast } = useToast();
312
+ const queryClient = useQueryClient();
313
+
314
+ // Use persistent sidebar state
315
+ const { isVisible: showSidebar, setSidebarVisible, toggleSidebar } = useSidebarState();
316
+ const {
317
+ mainPanelSize,
318
+ sidebarPanelSize,
319
+ isFloatingMode: isFloatingModeFromWidth,
320
+ setMainPanelSize,
321
+ setSidebarPanelSize,
322
+ } = usePanelWidthState();
323
+
324
+ // Consolidate all chat UI state into a single object (excluding showSidebar which is now managed separately)
325
+ const [chatState, setChatState] = useState<ChatUIState>({
326
+ showGroupEditPanel: false,
327
+ showProfileOverlay: false,
328
+ input: '',
329
+ inputDisabled: false,
330
+ selectedGroupAgentId: null,
331
+ currentDmChannelId: null,
332
+ isCreatingDM: false,
333
+ isMobile: false,
334
+ });
335
+
336
+ const location = useLocation();
337
+ const state = location.state as ChatLocationState | null;
338
+ const forceNew = state?.forceNew || false;
339
+
340
+ const navigate = useNavigate();
341
+
342
+ const [shouldForceNew, setShouldForceNew] = useState(forceNew);
343
+
344
+ // Determine if we should use floating mode - either from width detection OR mobile
345
+ const isFloatingMode = isFloatingModeFromWidth || chatState.isMobile;
346
+
347
+ // Confirmation dialogs
348
+ const { confirm, isOpen, onOpenChange, onConfirm, options } = useConfirmation();
349
+
350
+ const { stopAgent, isAgentStopping } = useAgentManagement();
351
+
352
+ // Helper to update chat state
353
+ const updateChatState = useCallback((updates: Partial<ChatUIState>) => {
354
+ setChatState((prev) => ({ ...prev, ...updates }));
355
+ }, []);
356
+
357
+ const currentClientEntityId = getEntityId();
358
+
359
+ const inputRef = useRef<HTMLTextAreaElement>(null);
360
+ const fileInputRef = useRef<HTMLInputElement>(null);
361
+ const formRef = useRef<HTMLFormElement>(null);
362
+ const inputDisabledRef = useRef<boolean>(false);
363
+ const chatTitleRef = useRef<string>('');
364
+
365
+ // For DM, we need agent data. For GROUP, we need channel data
366
+ const { data: agentDataResponse, isLoading: isLoadingAgent } = useAgent(
367
+ chatType === ChannelType.DM ? contextId : undefined,
368
+ { enabled: chatType === ChannelType.DM }
369
+ );
370
+
371
+ // Convert AgentWithStatus to Agent, ensuring required fields have defaults
372
+ const targetAgentData: Agent | undefined = agentDataResponse?.data
373
+ ? ({
374
+ ...agentDataResponse.data,
375
+ createdAt: agentDataResponse.data.createdAt || Date.now(),
376
+ updatedAt: agentDataResponse.data.updatedAt || Date.now(),
377
+ } as Agent)
378
+ : undefined;
379
+
380
+ const { handleDelete: handleDeleteAgent, isDeleting: isDeletingAgent } = useDeleteAgent(
381
+ targetAgentData || ({} as Agent) // Provide safe default if undefined
382
+ );
383
+
384
+ // Use the new hooks for DM channel management
385
+ const { data: agentDmChannels = [], isLoading: isLoadingAgentDmChannels } = useDmChannelsForAgent(
386
+ chatType === ChannelType.DM ? contextId : undefined
387
+ );
388
+
389
+ const createDmChannelMutation = useCreateDmChannel();
390
+
391
+ // Group chat specific data
392
+ const { data: channelDetailsData } = useChannelDetails(
393
+ chatType === ChannelType.GROUP ? contextId : undefined
394
+ );
395
+ const { data: participantsData } = useChannelParticipants(
396
+ chatType === ChannelType.GROUP ? contextId : undefined
397
+ );
398
+ const participants = participantsData?.data;
399
+
400
+ const { data: agentsResponse } = useAgentsWithDetails();
401
+ const allAgents = agentsResponse?.agents || [];
402
+
403
+ const latestChannel = agentDmChannels[0]; // adjust sorting if needed
404
+
405
+ // Get the final channel ID for hooks
406
+ const finalChannelIdForHooks: UUID | undefined =
407
+ chatType === ChannelType.DM
408
+ ? chatState.currentDmChannelId || undefined
409
+ : contextId || undefined;
410
+
411
+ const finalServerIdForHooks: UUID | undefined = useMemo(() => {
412
+ return chatType === ChannelType.DM ? DEFAULT_SERVER_ID : serverId || undefined;
413
+ }, [chatType, serverId]);
414
+
415
+ const { data: latestChannelMessages = [], isLoading: isLoadingLatestChannelMessages } =
416
+ useChannelMessages(latestChannel?.id, finalServerIdForHooks);
417
+
418
+ const {
419
+ data: messages = [],
420
+ isLoading: isLoadingMessages,
421
+ addMessage,
422
+ updateMessage,
423
+ removeMessage,
424
+ clearMessages,
425
+ } = useChannelMessages(finalChannelIdForHooks, finalServerIdForHooks);
426
+
427
+ // Get agents in the current group
428
+ const groupAgents = useMemo(() => {
429
+ if (chatType !== ChannelType.GROUP || !participants) return [];
430
+ return participants
431
+ .map((pId) => allAgents.find((a) => a.id === pId))
432
+ .filter(Boolean) as Agent[];
433
+ }, [chatType, participants, allAgents]);
434
+
435
+ const agentAvatarMap = useMemo(
436
+ () =>
437
+ allAgents.reduce(
438
+ (acc, agent) => {
439
+ if (agent.id && typeof agent.settings?.avatar === 'string') {
440
+ acc[agent.id] = agent.settings.avatar;
441
+ }
442
+ return acc;
443
+ },
444
+ {} as Record<UUID, string | null>
445
+ ),
446
+ [allAgents]
447
+ );
448
+
449
+ const getAgentInMessage = useCallback(
450
+ (agentId: UUID) => {
451
+ return allAgents.find((a) => a.id === agentId);
452
+ },
453
+ [allAgents]
454
+ );
455
+
456
+ const {
457
+ scrollRef,
458
+ contentRef,
459
+ isAtBottom,
460
+ scrollToBottom,
461
+ disableAutoScroll,
462
+ autoScrollEnabled,
463
+ } = useAutoScroll({ smooth: true });
464
+ const prevMessageCountRef = useRef(0);
465
+ const safeScrollToBottom = useCallback(() => {
466
+ if (scrollRef.current) {
467
+ setTimeout(() => scrollToBottom(), 0);
468
+ }
469
+ }, [scrollToBottom, scrollRef]);
470
+
471
+ // Prevent repeated auto-creation of a DM channel when none exist
472
+ const autoCreatedDmRef = useRef(false);
473
+ const autoCreateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
474
+
475
+ // Handle DM channel creation
476
+ const handleNewDmChannel = useCallback(
477
+ async (agentIdForNewChannel: UUID | undefined) => {
478
+ if (!agentIdForNewChannel || chatType !== 'DM') return;
479
+
480
+ if (latestChannel) {
481
+ try {
482
+ const elizaClient = createElizaClient();
483
+ const latestMessages = await elizaClient.messaging.getChannelMessages(latestChannel.id, {
484
+ limit: 30,
485
+ });
486
+
487
+ const hasAutoName = isAutoGeneratedChatName(latestChannel.name);
488
+ const isEmpty = (latestMessages?.messages?.length ?? 0) === 0;
489
+
490
+ if (hasAutoName && isEmpty) {
491
+ const isAlreadyInLatest = chatState.currentDmChannelId === latestChannel.id;
492
+
493
+ if (isAlreadyInLatest) {
494
+ toast({
495
+ title: `Already in a fresh chat`,
496
+ description: `You're already in a new chat with ${targetAgentData?.name || 'the agent'}.`,
497
+ });
498
+ } else {
499
+ updateChatState({ currentDmChannelId: latestChannel.id });
500
+ toast({
501
+ title: `Chat opened`,
502
+ description: `You can now start chatting with ${targetAgentData?.name || 'the agent'}.`,
503
+ });
504
+ }
505
+
506
+ updateChatState({ currentDmChannelId: latestChannel.id });
507
+ return;
508
+ } else {
509
+ clientLogger.info(
510
+ '[Chat] Latest DM channel has messages or customized name, proceeding to create a new one.'
511
+ );
512
+ }
513
+ } catch (error) {
514
+ clientLogger.error('[Chat] Failed to fetch latest DM channel messages:', error);
515
+ }
516
+ }
517
+
518
+ const newChatName = `Chat - ${moment().format('MMM D, HH:mm:ss')}`;
519
+ clientLogger.info(
520
+ `[Chat] Creating new distinct DM channel with agent ${agentIdForNewChannel}, name: "${newChatName}"`
521
+ );
522
+ updateChatState({ isCreatingDM: true });
523
+ try {
524
+ // Mark as auto-created so the effect doesn't attempt a duplicate.
525
+ autoCreatedDmRef.current = true;
526
+
527
+ await createDmChannelMutation.mutateAsync({
528
+ agentId: agentIdForNewChannel,
529
+ channelName: newChatName, // Provide a unique name
530
+ });
531
+ updateChatState({ input: '' });
532
+ setTimeout(() => safeScrollToBottom(), 150);
533
+ } catch (error) {
534
+ clientLogger.error('[Chat] Error creating new distinct DM channel:', error);
535
+ // Toast is handled by the mutation hook
536
+ updateChatState({ currentDmChannelId: null, input: '' });
537
+ } finally {
538
+ updateChatState({ isCreatingDM: false });
539
+ toast({
540
+ title: `Chat opened`,
541
+ description: `You can now start chatting with ${targetAgentData?.name || 'the agent'}.`,
542
+ });
543
+ }
544
+ },
545
+ [chatType, createDmChannelMutation, updateChatState, safeScrollToBottom, latestChannel]
546
+ );
547
+
548
+ // Handle DM channel selection
549
+ const handleSelectDmRoom = useCallback(
550
+ (channelIdToSelect: UUID) => {
551
+ const selectedChannel = agentDmChannels.find((channel) => channel.id === channelIdToSelect);
552
+ if (selectedChannel) {
553
+ clientLogger.info(
554
+ `[Chat] DM Channel selected: ${selectedChannel.name} (Channel ID: ${selectedChannel.id})`
555
+ );
556
+ updateChatState({ currentDmChannelId: selectedChannel.id, input: '' });
557
+ setTimeout(() => safeScrollToBottom(), 150);
558
+ }
559
+ },
560
+ [agentDmChannels, updateChatState, safeScrollToBottom]
561
+ );
562
+
563
+ // Handle DM channel deletion
564
+ const handleDeleteCurrentDmChannel = useCallback(() => {
565
+ if (chatType !== ChannelType.DM || !chatState.currentDmChannelId || !targetAgentData?.id)
566
+ return;
567
+ const channelToDelete = agentDmChannels.find((ch) => ch.id === chatState.currentDmChannelId);
568
+ if (!channelToDelete) return;
569
+
570
+ confirm(
571
+ {
572
+ title: 'Delete Chat',
573
+ description: `Are you sure you want to delete the chat "${channelToDelete.name}" with ${targetAgentData.name}? This action cannot be undone.`,
574
+ confirmText: 'Delete',
575
+ variant: 'destructive',
576
+ },
577
+ async () => {
578
+ clientLogger.info(`[Chat] Deleting DM channel ${channelToDelete.id}`);
579
+ try {
580
+ const elizaClient = createElizaClient();
581
+ await elizaClient.messaging.deleteChannel(channelToDelete.id);
582
+
583
+ // --- Optimistically update the React-Query cache so UI refreshes instantly ---
584
+ queryClient.setQueryData<MessageChannel[] | undefined>(
585
+ ['dmChannels', targetAgentData.id, currentClientEntityId],
586
+ (old) => old?.filter((ch) => ch.id !== channelToDelete.id)
587
+ );
588
+
589
+ // Force a refetch to stay in sync with the server
590
+ queryClient.invalidateQueries({
591
+ queryKey: ['dmChannels', targetAgentData.id, currentClientEntityId],
592
+ });
593
+ // Also keep the broader channels cache in sync
594
+ queryClient.invalidateQueries({ queryKey: ['channels'] });
595
+
596
+ toast({ title: 'Chat Deleted', description: `"${channelToDelete.name}" was deleted.` });
597
+
598
+ const remainingChannels =
599
+ (queryClient.getQueryData(['dmChannels', targetAgentData.id, currentClientEntityId]) as
600
+ | MessageChannel[]
601
+ | undefined) || [];
602
+
603
+ if (remainingChannels.length > 0) {
604
+ updateChatState({ currentDmChannelId: remainingChannels[0].id });
605
+ clientLogger.info('[Chat] Switched to DM channel:', remainingChannels[0].id);
606
+ } else {
607
+ clientLogger.info(
608
+ '[Chat] No DM channels left after deletion. Will create a fresh chat once.'
609
+ );
610
+ // Clear the current DM so the effect can handle creating exactly one new chat
611
+ updateChatState({ currentDmChannelId: null });
612
+ // Allow the auto-create logic to run again
613
+ autoCreatedDmRef.current = false;
614
+ await handleNewDmChannel(targetAgentData.id);
615
+ }
616
+ } catch (error) {
617
+ clientLogger.error('[Chat] Error deleting DM channel:', error);
618
+ toast({
619
+ title: 'Error',
620
+ description: 'Could not delete chat. The server might not support this action yet.',
621
+ variant: 'destructive',
622
+ });
623
+ }
624
+ }
625
+ );
626
+ }, [
627
+ chatType,
628
+ chatState.currentDmChannelId,
629
+ targetAgentData,
630
+ agentDmChannels,
631
+ confirm,
632
+ toast,
633
+ updateChatState,
634
+ handleNewDmChannel,
635
+ queryClient,
636
+ currentClientEntityId,
637
+ ]);
638
+
639
+ useEffect(() => {
640
+ if (
641
+ latestChannel &&
642
+ !isLoadingLatestChannelMessages &&
643
+ latestChannelMessages &&
644
+ targetAgentData?.id &&
645
+ shouldForceNew
646
+ ) {
647
+ handleNewDmChannel(targetAgentData.id);
648
+ setShouldForceNew(false);
649
+
650
+ navigate(location.pathname, { replace: true });
651
+ }
652
+ }, [
653
+ shouldForceNew,
654
+ setShouldForceNew,
655
+ handleNewDmChannel,
656
+ targetAgentData?.id,
657
+ latestChannel,
658
+ latestChannelMessages,
659
+ isLoadingLatestChannelMessages,
660
+ ]);
661
+
662
+ useEffect(() => {
663
+ inputDisabledRef.current = chatState.inputDisabled;
664
+ }, [chatState.inputDisabled]);
665
+
666
+ useEffect(() => {
667
+ const currentChannel = agentDmChannels.find((c) => c.id === chatState.currentDmChannelId);
668
+ if (currentChannel?.name) {
669
+ chatTitleRef.current = currentChannel.name;
670
+ }
671
+ }, [agentDmChannels, chatState.currentDmChannelId]);
672
+
673
+ useEffect(() => {
674
+ if (!isLoadingAgentDmChannels && agentDmChannels.length > 0) {
675
+ clientLogger.info('[Chat] Selecting first available DM channel:', agentDmChannels[0].id);
676
+ updateChatState({ currentDmChannelId: agentDmChannels[0].id });
677
+ }
678
+ }, [agentDmChannels, isLoadingAgentDmChannels, updateChatState]);
679
+
680
+ // Effect to handle initial DM channel selection or creation
681
+ useEffect(() => {
682
+ if (chatType === ChannelType.DM && targetAgentData?.id) {
683
+ // First, check if current channel belongs to the current agent
684
+ // If not, clear it immediately (handles agent switching)
685
+ const currentChannelBelongsToAgent =
686
+ !chatState.currentDmChannelId ||
687
+ agentDmChannels.some((c) => c.id === chatState.currentDmChannelId);
688
+
689
+ if (!currentChannelBelongsToAgent && !isLoadingAgentDmChannels) {
690
+ clientLogger.info(
691
+ `[Chat] Current DM channel ${chatState.currentDmChannelId} doesn't belong to agent ${targetAgentData.id}, clearing it`
692
+ );
693
+ updateChatState({ currentDmChannelId: null });
694
+ return; // Exit early, let the effect run again with cleared state
695
+ }
696
+
697
+ if (
698
+ !isLoadingAgentDmChannels &&
699
+ agentDmChannels.length === 0 &&
700
+ !initialDmChannelId &&
701
+ !autoCreatedDmRef.current &&
702
+ !chatState.isCreatingDM &&
703
+ !createDmChannelMutation.isPending
704
+ ) {
705
+ // No channels at all and none expected via URL -> create exactly one
706
+ clientLogger.info('[Chat] No existing DM channels found; auto-creating a fresh one.');
707
+ autoCreatedDmRef.current = true;
708
+ handleNewDmChannel(targetAgentData.id);
709
+ }
710
+ } else if (chatType !== ChannelType.DM && chatState.currentDmChannelId !== null) {
711
+ // Only reset if necessary
712
+ updateChatState({ currentDmChannelId: null });
713
+ }
714
+ }, [
715
+ chatType,
716
+ targetAgentData?.id,
717
+ agentDmChannels,
718
+ isLoadingAgentDmChannels,
719
+ createDmChannelMutation.isPending,
720
+ chatState.isCreatingDM,
721
+ chatState.currentDmChannelId,
722
+ initialDmChannelId,
723
+ updateChatState,
724
+ handleNewDmChannel,
725
+ ]);
726
+
727
+ // Cleanup timeout on unmount or when agentDmChannels appears
728
+ useEffect(() => {
729
+ if (agentDmChannels.length > 0 && autoCreateTimeoutRef.current) {
730
+ clearTimeout(autoCreateTimeoutRef.current);
731
+ autoCreateTimeoutRef.current = null;
732
+ }
733
+ return () => {
734
+ if (autoCreateTimeoutRef.current) {
735
+ clearTimeout(autoCreateTimeoutRef.current);
736
+ autoCreateTimeoutRef.current = null;
737
+ }
738
+ };
739
+ }, [agentDmChannels]);
740
+
741
+ // Auto-select single agent in group
742
+ useEffect(() => {
743
+ if (
744
+ chatType === ChannelType.GROUP &&
745
+ groupAgents.length === 1 &&
746
+ !chatState.selectedGroupAgentId
747
+ ) {
748
+ updateChatState({
749
+ selectedGroupAgentId: groupAgents[0].id as UUID,
750
+ });
751
+ if (!showSidebar) {
752
+ setSidebarVisible(true);
753
+ }
754
+ }
755
+ }, [
756
+ chatType,
757
+ groupAgents,
758
+ chatState.selectedGroupAgentId,
759
+ updateChatState,
760
+ showSidebar,
761
+ setSidebarVisible,
762
+ ]);
763
+
764
+ const { mutate: deleteMessageCentral } = useDeleteChannelMessage();
765
+ const { mutate: clearMessagesCentral } = useClearChannelMessages();
766
+
767
+ // Auto-scroll handling
768
+ useEffect(() => {
769
+ const isInitialLoadWithMessages = prevMessageCountRef.current === 0 && messages.length > 0;
770
+ const hasNewMessages =
771
+ messages.length !== prevMessageCountRef.current && prevMessageCountRef.current !== 0;
772
+
773
+ if (isInitialLoadWithMessages) {
774
+ clientLogger.debug('[chat] Initial messages loaded, scrolling to bottom.', {
775
+ count: messages.length,
776
+ });
777
+ safeScrollToBottom();
778
+ } else if (hasNewMessages) {
779
+ if (autoScrollEnabled) {
780
+ clientLogger.debug('[chat] New messages and autoScroll enabled, scrolling.');
781
+ safeScrollToBottom();
782
+ } else {
783
+ clientLogger.debug('[chat] New messages, but autoScroll is disabled (user scrolled up).');
784
+ }
785
+ }
786
+ prevMessageCountRef.current = messages.length;
787
+ }, [messages, autoScrollEnabled, safeScrollToBottom, finalChannelIdForHooks]);
788
+
789
+ function isAutoGeneratedChatName(name: string | undefined): boolean {
790
+ return !!name?.match(/^Chat - [A-Z][a-z]{2} \d{1,2}, \d{2}:\d{2}(:\d{2})?$/);
791
+ }
792
+
793
+ const updateChatTitle = async () => {
794
+ const shouldUpdate: boolean =
795
+ !!chatTitleRef.current &&
796
+ isAutoGeneratedChatName(chatTitleRef.current) &&
797
+ chatType === ChannelType.DM;
798
+
799
+ if (!shouldUpdate) {
800
+ return;
801
+ }
802
+
803
+ // Guard against undefined channel IDs
804
+ if (!finalChannelIdForHooks || !chatState.currentDmChannelId) {
805
+ clientLogger.warn('Cannot update chat title: missing channel ID');
806
+ return;
807
+ }
808
+
809
+ const elizaClient = createElizaClient();
810
+ const data = await elizaClient.messaging.generateChannelTitle(
811
+ finalChannelIdForHooks,
812
+ contextId
813
+ );
814
+
815
+ const title = data?.title;
816
+ const participants = await elizaClient.messaging.getChannelParticipants(
817
+ chatState.currentDmChannelId
818
+ );
819
+ if (title && participants) {
820
+ // Handle different possible response formats for participants
821
+ let participantIds = [];
822
+ if (participants && Array.isArray(participants.participants)) {
823
+ participantIds = participants.participants.map((p) => p.userId);
824
+ } else if (participants && Array.isArray(participants)) {
825
+ participantIds = participants.map((p) => p.userId || p.id || p);
826
+ }
827
+
828
+ await elizaClient.messaging.updateChannel(finalChannelIdForHooks, {
829
+ name: title,
830
+ participantCentralUserIds: participantIds,
831
+ });
832
+
833
+ const currentUserId = getEntityId();
834
+ queryClient.invalidateQueries({
835
+ queryKey: ['dmChannels', contextId, currentUserId],
836
+ });
837
+ }
838
+ };
839
+
840
+ const { sendMessage, animatedMessageId } = useSocketChat({
841
+ channelId: finalChannelIdForHooks,
842
+ currentUserId: currentClientEntityId,
843
+ contextId,
844
+ chatType,
845
+ allAgents,
846
+ messages,
847
+ onAddMessage: (message: UiMessage) => {
848
+ addMessage(message);
849
+ updateChatTitle();
850
+ if (message.isAgent) safeScrollToBottom();
851
+ },
852
+ onUpdateMessage: (messageId: string, updates: Partial<UiMessage>) => {
853
+ updateMessage(messageId as UUID, updates);
854
+ if (!updates.isLoading && updates.isLoading !== undefined) safeScrollToBottom();
855
+ },
856
+ onDeleteMessage: (messageId: string) => {
857
+ removeMessage(messageId as UUID);
858
+ },
859
+ onClearMessages: () => {
860
+ // Clear the local message list immediately for instant UI response
861
+ clearMessages();
862
+ },
863
+ onInputDisabledChange: (disabled: boolean) => updateChatState({ inputDisabled: disabled }),
864
+ });
865
+
866
+ const {
867
+ selectedFiles,
868
+ handleFileChange,
869
+ removeFile,
870
+ createBlobUrls,
871
+ uploadFiles,
872
+ cleanupBlobUrls,
873
+ clearFiles,
874
+ } = useFileUpload({
875
+ agentId: targetAgentData?.id,
876
+ channelId: finalChannelIdForHooks,
877
+ chatType,
878
+ });
879
+
880
+ // Handlers
881
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
882
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
883
+ e.preventDefault();
884
+ handleSendMessage(e as unknown as React.FormEvent<HTMLFormElement>);
885
+ }
886
+ };
887
+
888
+ const handleSendMessage = async (e: React.FormEvent<HTMLFormElement>) => {
889
+ e.preventDefault();
890
+
891
+ // For DM chats, ensure we have a channel before sending
892
+ let channelIdToUse = finalChannelIdForHooks;
893
+ if (chatType === ChannelType.DM && !channelIdToUse && targetAgentData?.id) {
894
+ // If a DM channel is already being (auto) created, abort to prevent duplicate creations.
895
+ if (chatState.isCreatingDM || createDmChannelMutation.isPending) {
896
+ clientLogger.info(
897
+ '[Chat] DM channel creation already in progress; will wait for it to finish instead of creating another.'
898
+ );
899
+ // Early return so the user can try sending again once the channel is ready.
900
+ return;
901
+ }
902
+
903
+ clientLogger.info('[Chat] No DM channel selected, creating one before sending message');
904
+ try {
905
+ // Mark as auto-created so the effect doesn't attempt a duplicate.
906
+ autoCreatedDmRef.current = true;
907
+
908
+ const newChannel = await createDmChannelMutation.mutateAsync({
909
+ agentId: targetAgentData.id,
910
+ channelName: `Chat - ${moment().format('MMM D, HH:mm')}`,
911
+ });
912
+ updateChatState({ currentDmChannelId: newChannel.id });
913
+ channelIdToUse = newChannel.id;
914
+ // Wait a moment for state to propagate
915
+ await new Promise((resolve) => setTimeout(resolve, 100));
916
+ } catch (error) {
917
+ clientLogger.error('[Chat] Failed to create DM channel before sending message:', error);
918
+ toast({
919
+ title: 'Error',
920
+ description: 'Failed to create chat channel. Please try again.',
921
+ variant: 'destructive',
922
+ });
923
+ updateChatState({ inputDisabled: false });
924
+ return;
925
+ }
926
+ }
927
+
928
+ if (
929
+ (!chatState.input.trim() && selectedFiles.length === 0) ||
930
+ inputDisabledRef.current ||
931
+ !channelIdToUse ||
932
+ !finalServerIdForHooks ||
933
+ !currentClientEntityId ||
934
+ (chatType === ChannelType.DM && !targetAgentData?.id)
935
+ )
936
+ return;
937
+
938
+ const tempMessageId = randomUUID() as UUID;
939
+ let messageText = chatState.input.trim();
940
+ const currentInputVal = chatState.input;
941
+ updateChatState({ input: '', inputDisabled: true });
942
+ const currentSelectedFiles = [...selectedFiles];
943
+ clearFiles();
944
+ formRef.current?.reset();
945
+ const optimisticAttachments = createBlobUrls(currentSelectedFiles);
946
+ const optimisticUiMessage: UiMessage = {
947
+ id: tempMessageId,
948
+ text: messageText,
949
+ name: USER_NAME,
950
+ createdAt: Date.now(),
951
+ senderId: currentClientEntityId,
952
+ isAgent: false,
953
+ isLoading: true,
954
+ channelId: channelIdToUse,
955
+ serverId: finalServerIdForHooks,
956
+ source: chatType === ChannelType.DM ? CHAT_SOURCE : GROUP_CHAT_SOURCE,
957
+ attachments: optimisticAttachments,
958
+ };
959
+ if (messageText || currentSelectedFiles.length > 0) addMessage(optimisticUiMessage);
960
+ safeScrollToBottom();
961
+ try {
962
+ let processedUiAttachments: Media[] = [];
963
+ if (currentSelectedFiles.length > 0) {
964
+ const { uploaded, failed, blobUrls } = await uploadFiles(currentSelectedFiles);
965
+ processedUiAttachments = uploaded;
966
+ if (failed.length > 0)
967
+ updateMessage(tempMessageId, {
968
+ attachments: optimisticUiMessage.attachments?.filter(
969
+ (att) => !failed.some((f) => f.file.id === att.id)
970
+ ),
971
+ });
972
+ cleanupBlobUrls(blobUrls);
973
+ if (!messageText.trim() && processedUiAttachments.length > 0)
974
+ messageText = `Shared ${processedUiAttachments.length} file(s).`;
975
+ }
976
+ const mediaInfosFromText = parseMediaFromText(currentInputVal);
977
+ const textMediaAttachments: Media[] = mediaInfosFromText.map(
978
+ (media: MediaInfo, index: number): Media => ({
979
+ id: `textmedia-${tempMessageId}-${index}`,
980
+ url: media.url,
981
+ title: media.type === 'image' ? 'Image' : media.type === 'video' ? 'Video' : 'Media Link',
982
+ source: 'user_input_url',
983
+ contentType:
984
+ media.type === 'image'
985
+ ? CoreContentType.IMAGE
986
+ : media.type === 'video'
987
+ ? CoreContentType.VIDEO
988
+ : undefined,
989
+ })
990
+ );
991
+ const finalAttachments = [...processedUiAttachments, ...textMediaAttachments];
992
+ const finalTextContent =
993
+ messageText || (finalAttachments.length > 0 ? `Shared content.` : '');
994
+ if (!finalTextContent.trim() && finalAttachments.length === 0) {
995
+ updateChatState({ inputDisabled: false });
996
+ removeMessage(tempMessageId);
997
+ return;
998
+ }
999
+ await sendMessage(
1000
+ finalTextContent,
1001
+ finalServerIdForHooks,
1002
+ chatType === ChannelType.DM ? CHAT_SOURCE : GROUP_CHAT_SOURCE,
1003
+ finalAttachments.length > 0 ? finalAttachments : undefined,
1004
+ tempMessageId,
1005
+ undefined,
1006
+ channelIdToUse
1007
+ );
1008
+ } catch (error) {
1009
+ clientLogger.error('Error sending message or uploading files:', error);
1010
+ toast({
1011
+ title: 'Error Sending Message',
1012
+ description: error instanceof Error ? error.message : 'Could not send message.',
1013
+ variant: 'destructive',
1014
+ });
1015
+ updateMessage(tempMessageId, {
1016
+ isLoading: false,
1017
+ text: `${optimisticUiMessage.text || 'Attachment(s)'} (Failed to send)`,
1018
+ });
1019
+ // Re-enable input on error
1020
+ updateChatState({ inputDisabled: false });
1021
+ } finally {
1022
+ // Let the server control input state via control messages
1023
+ // Only focus the input, don't re-enable it
1024
+ inputRef.current?.focus();
1025
+ }
1026
+ };
1027
+
1028
+ const handleDeleteMessage = (messageId: string) => {
1029
+ if (!finalChannelIdForHooks || !messageId) return;
1030
+ const validMessageId = validateUuid(messageId);
1031
+ if (validMessageId) {
1032
+ // Immediately remove message from UI for optimistic update
1033
+ removeMessage(messageId as UUID);
1034
+ // Call server mutation to delete on backend
1035
+ deleteMessageCentral({ channelId: finalChannelIdForHooks, messageId: validMessageId });
1036
+ }
1037
+ };
1038
+
1039
+ const handleRetryMessage = async (message: UiMessage) => {
1040
+ if (inputDisabledRef.current || (!message.text?.trim() && message.attachments?.length === 0)) {
1041
+ return;
1042
+ }
1043
+ updateChatState({ inputDisabled: true });
1044
+ const retryMessageId = randomUUID() as UUID;
1045
+ const finalTextContent =
1046
+ message.text?.trim() || `Shared ${message.attachments?.length} file(s).`;
1047
+
1048
+ const optimisticUiMessage: UiMessage = {
1049
+ id: retryMessageId,
1050
+ text: message.text,
1051
+ name: USER_NAME,
1052
+ createdAt: Date.now(),
1053
+ senderId: currentClientEntityId,
1054
+ isAgent: false,
1055
+ isLoading: true,
1056
+ channelId: message.channelId,
1057
+ serverId: finalServerIdForHooks,
1058
+ source: chatType === ChannelType.DM ? CHAT_SOURCE : GROUP_CHAT_SOURCE,
1059
+ attachments: message.attachments,
1060
+ };
1061
+
1062
+ addMessage(optimisticUiMessage);
1063
+ safeScrollToBottom();
1064
+
1065
+ // Guard against undefined IDs
1066
+ if (!finalServerIdForHooks || !finalChannelIdForHooks) {
1067
+ clientLogger.error('Cannot retry message: missing server or channel ID');
1068
+ toast({
1069
+ title: 'Error Sending Message',
1070
+ description: 'Missing required channel information.',
1071
+ variant: 'destructive',
1072
+ });
1073
+ updateChatState({ inputDisabled: false });
1074
+ removeMessage(retryMessageId);
1075
+ return;
1076
+ }
1077
+
1078
+ try {
1079
+ await sendMessage(
1080
+ finalTextContent,
1081
+ finalServerIdForHooks,
1082
+ chatType === ChannelType.DM ? CHAT_SOURCE : GROUP_CHAT_SOURCE,
1083
+ message.attachments,
1084
+ retryMessageId,
1085
+ undefined,
1086
+ finalChannelIdForHooks
1087
+ );
1088
+ } catch (error) {
1089
+ clientLogger.error('Error sending message or uploading files:', error);
1090
+ toast({
1091
+ title: 'Error Sending Message',
1092
+ description: error instanceof Error ? error.message : 'Could not send message.',
1093
+ variant: 'destructive',
1094
+ });
1095
+ updateMessage(retryMessageId, {
1096
+ isLoading: false,
1097
+ text: `${optimisticUiMessage.text || 'Attachment(s)'} (Failed to send)`,
1098
+ });
1099
+ updateChatState({ inputDisabled: false });
1100
+ }
1101
+ };
1102
+
1103
+ const handleClearChat = () => {
1104
+ if (!finalChannelIdForHooks) return;
1105
+ const confirmMessage =
1106
+ chatType === ChannelType.DM
1107
+ ? `Clear all messages in this chat with ${targetAgentData?.name}?`
1108
+ : 'Clear all messages in this group chat?';
1109
+
1110
+ confirm(
1111
+ {
1112
+ title: 'Clear Chat',
1113
+ description: `${confirmMessage} This action cannot be undone.`,
1114
+ confirmText: 'Clear',
1115
+ variant: 'destructive',
1116
+ },
1117
+ () => {
1118
+ clearMessagesCentral(finalChannelIdForHooks);
1119
+ }
1120
+ );
1121
+ };
1122
+
1123
+ // Handle mobile detection and window resize
1124
+ useEffect(() => {
1125
+ const checkMobile = () => {
1126
+ const isMobile = window.innerWidth < 768;
1127
+ updateChatState({ isMobile });
1128
+ // Note: Don't auto-hide sidebar on mobile - let floating mode handle it
1129
+ };
1130
+
1131
+ // Initial check
1132
+ checkMobile();
1133
+
1134
+ // Add resize listener
1135
+ window.addEventListener('resize', checkMobile);
1136
+ return () => window.removeEventListener('resize', checkMobile);
1137
+ }, [updateChatState]);
1138
+
1139
+ if (
1140
+ chatType === ChannelType.DM &&
1141
+ (isLoadingAgent || (!targetAgentData && contextId) || isLoadingAgentDmChannels)
1142
+ ) {
1143
+ return (
1144
+ <div className="flex items-center justify-center h-full">
1145
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
1146
+ </div>
1147
+ );
1148
+ }
1149
+
1150
+ if (
1151
+ !finalChannelIdForHooks ||
1152
+ !finalServerIdForHooks ||
1153
+ (chatType === ChannelType.DM && !targetAgentData)
1154
+ ) {
1155
+ return (
1156
+ <div className="flex flex-1 justify-center items-center">
1157
+ <p>Loading chat context...</p>
1158
+ </div>
1159
+ );
1160
+ }
1161
+
1162
+ const onDeleteAgent = () => {
1163
+ if (isDeletingAgent) return;
1164
+ confirm(
1165
+ {
1166
+ title: 'Delete Agent',
1167
+ description: `Are you sure you want to delete the agent "${targetAgentData?.name}"? This action cannot be undone.`,
1168
+ confirmText: 'Delete',
1169
+ variant: 'destructive',
1170
+ },
1171
+ handleDeleteAgent
1172
+ );
1173
+ };
1174
+
1175
+ // Chat header
1176
+ const renderChatHeader = () => {
1177
+ if (chatType === ChannelType.DM && targetAgentData) {
1178
+ return (
1179
+ <div className="flex items-center justify-between mb-4 p-3">
1180
+ <div className="flex items-center gap-3 min-w-0 flex-1">
1181
+ <DropdownMenu>
1182
+ <DropdownMenuTrigger asChild>
1183
+ <Button variant="ghost" size="sm" className="py-6 px-2 flex-shrink-0">
1184
+ <div className="relative flex-shrink-0">
1185
+ <Avatar className="size-4 sm:size-10 border rounded-full">
1186
+ <AvatarImage src={getAgentAvatar(targetAgentData)} />
1187
+ </Avatar>
1188
+ {targetAgentData?.status === AgentStatus.ACTIVE ? (
1189
+ <Tooltip>
1190
+ <TooltipTrigger asChild>
1191
+ <span className="absolute bottom-0 right-0 size-2 sm:size-[10px] rounded-full border border-white bg-green-500" />
1192
+ </TooltipTrigger>
1193
+ <TooltipContent side="right">
1194
+ <p>Agent is active</p>
1195
+ </TooltipContent>
1196
+ </Tooltip>
1197
+ ) : (
1198
+ <Tooltip>
1199
+ <TooltipTrigger asChild>
1200
+ <span className="absolute bottom-0 right-0 size-2 sm:size-[10px] rounded-full border border-white bg-muted-foreground" />
1201
+ </TooltipTrigger>
1202
+ <TooltipContent side="right">
1203
+ <p>Agent is inactive</p>
1204
+ </TooltipContent>
1205
+ </Tooltip>
1206
+ )}
1207
+ </div>
1208
+ <div>
1209
+ <h2 className="font-semibold text-lg truncate max-w-[80px] sm:max-w-none">
1210
+ {targetAgentData?.name || 'Agent'}
1211
+ </h2>
1212
+ </div>
1213
+ <ChevronDown className="size-4" />
1214
+ </Button>
1215
+ </DropdownMenuTrigger>
1216
+
1217
+ <DropdownMenuContent
1218
+ align="start"
1219
+ side="bottom"
1220
+ className="min-w-36 w-[var(--radix-dropdown-menu-trigger-width)]"
1221
+ >
1222
+ <DropdownMenuItem
1223
+ onClick={() => {
1224
+ exportCharacterAsJson(targetAgentData, toast);
1225
+ }}
1226
+ >
1227
+ <ArrowUpFromLine className="h-4 w-4 mr-2" />
1228
+ Export
1229
+ </DropdownMenuItem>
1230
+ <DropdownMenuSeparator />
1231
+
1232
+ <DropdownMenuItem
1233
+ onClick={() => {
1234
+ if (targetAgentData && !isAgentStopping(targetAgentData.id)) {
1235
+ stopAgent(targetAgentData);
1236
+ }
1237
+ }}
1238
+ >
1239
+ <StopCircle className="h-4 w-4 mr-2" />
1240
+ Stop Agent
1241
+ </DropdownMenuItem>
1242
+
1243
+ <DropdownMenuItem
1244
+ onClick={() => {
1245
+ if (targetAgentData && !isDeletingAgent) {
1246
+ onDeleteAgent();
1247
+ }
1248
+ }}
1249
+ className="text-destructive focus:text-destructive hover:bg-red-50 dark:hover:bg-red-950/50"
1250
+ >
1251
+ <Trash className="h-4 w-4 mr-2" />
1252
+ Delete Agent
1253
+ </DropdownMenuItem>
1254
+ </DropdownMenuContent>
1255
+ </DropdownMenu>
1256
+ </div>
1257
+
1258
+ <div className="flex gap-1 sm:gap-2 items-center flex-shrink-0">
1259
+ {chatType === ChannelType.DM && (
1260
+ <div className="flex items-center gap-2">
1261
+ {agentDmChannels.length > 0 && (
1262
+ <DropdownMenu>
1263
+ <DropdownMenuTrigger asChild>
1264
+ <Button
1265
+ variant="outline"
1266
+ size="sm"
1267
+ className="w-8 h-9 sm:max-w-[300px] sm:w-auto rounded-[12px]"
1268
+ >
1269
+ <Clock className="size-4 flex-shrink-0 text-muted-foreground" />
1270
+ <span className="hidden md:inline truncate text-xs sm:text-sm">
1271
+ {agentDmChannels.find((c) => c.id === chatState.currentDmChannelId)
1272
+ ?.name || 'Select Chat'}
1273
+ </span>
1274
+ <ChevronDown className="hidden md:inline-flex size-4 text-muted-foreground" />
1275
+ </Button>
1276
+ </DropdownMenuTrigger>
1277
+ <DropdownMenuContent align="end" className="w-[280px] sm:w-[320px]">
1278
+ <DropdownMenuLabel className="font-medium">
1279
+ Chat History with {targetAgentData.name}
1280
+ </DropdownMenuLabel>
1281
+ <DropdownMenuSeparator />
1282
+ <div className="max-h-[300px] overflow-y-auto">
1283
+ {agentDmChannels.map((channel) => (
1284
+ <DropdownMenuItem
1285
+ key={channel.id}
1286
+ onClick={() => handleSelectDmRoom(channel.id)}
1287
+ className={cn(
1288
+ 'cursor-pointer',
1289
+ channel.id === chatState.currentDmChannelId && 'bg-muted'
1290
+ )}
1291
+ >
1292
+ <div className="flex items-center justify-between w-full">
1293
+ <div className="flex flex-col min-w-0 flex-1">
1294
+ <span
1295
+ className={cn(
1296
+ 'text-sm truncate',
1297
+ channel.id === chatState.currentDmChannelId && 'font-medium'
1298
+ )}
1299
+ >
1300
+ {channel.name}
1301
+ </span>
1302
+ <span className="text-xs text-muted-foreground">
1303
+ {moment(
1304
+ (typeof channel.metadata?.createdAt === 'string' ||
1305
+ typeof channel.metadata?.createdAt === 'number'
1306
+ ? channel.metadata.createdAt
1307
+ : null) ||
1308
+ channel.updatedAt ||
1309
+ channel.createdAt
1310
+ ).fromNow()}
1311
+ </span>
1312
+ </div>
1313
+ {channel.id === chatState.currentDmChannelId && (
1314
+ <Badge variant="default" className="text-xs flex-shrink-0 ml-2">
1315
+ Current
1316
+ </Badge>
1317
+ )}
1318
+ </div>
1319
+ </DropdownMenuItem>
1320
+ ))}
1321
+ </div>
1322
+ </DropdownMenuContent>
1323
+ </DropdownMenu>
1324
+ )}
1325
+
1326
+ {/* Chat Actions Split Button */}
1327
+ <SplitButton
1328
+ mainAction={{
1329
+ label: chatState.isCreatingDM ? (
1330
+ 'Creating...'
1331
+ ) : (
1332
+ <>
1333
+ <span className="sm:hidden">New</span>
1334
+ <span className="hidden sm:inline">New Chat</span>
1335
+ </>
1336
+ ),
1337
+ onClick: () => handleNewDmChannel(targetAgentData?.id),
1338
+ icon: chatState.isCreatingDM ? (
1339
+ <Loader2 className="size-4 animate-spin" />
1340
+ ) : (
1341
+ <Plus className="size-4" />
1342
+ ),
1343
+ disabled: chatState.isCreatingDM || isLoadingAgentDmChannels,
1344
+ }}
1345
+ actions={[
1346
+ {
1347
+ label: 'Clear Chat',
1348
+ onClick: handleClearChat,
1349
+ icon: <Eraser className="size-4" />,
1350
+ disabled: !messages || messages.length === 0,
1351
+ },
1352
+ {
1353
+ label: 'Delete Chat',
1354
+ onClick: handleDeleteCurrentDmChannel,
1355
+ icon: <Trash2 className="size-4" />,
1356
+ disabled: !chatState.currentDmChannelId,
1357
+ variant: 'destructive',
1358
+ },
1359
+ ]}
1360
+ variant="outline"
1361
+ size="sm"
1362
+ mainButtonClassName="rounded-l-[12px] h-9"
1363
+ dropdownButtonClassName="rounded-r-[12px] h-9"
1364
+ />
1365
+
1366
+ <Tooltip>
1367
+ <TooltipTrigger asChild>
1368
+ <Button
1369
+ className="w-9 h-9 rounded-[12px]"
1370
+ variant={'outline'}
1371
+ onClick={toggleSidebar}
1372
+ >
1373
+ {showSidebar ? (
1374
+ <PanelRightClose className="size-4" />
1375
+ ) : (
1376
+ <PanelRight className="size-4" />
1377
+ )}
1378
+ </Button>
1379
+ </TooltipTrigger>
1380
+ <TooltipContent side="bottom">
1381
+ <p>{showSidebar ? 'Close SidePanel' : 'Open SidePanel'}</p>
1382
+ </TooltipContent>
1383
+ </Tooltip>
1384
+ </div>
1385
+ )}
1386
+ </div>
1387
+ </div>
1388
+ );
1389
+ } else if (chatType === ChannelType.GROUP) {
1390
+ const groupDisplayName = generateGroupName(
1391
+ channelDetailsData?.data || undefined,
1392
+ groupAgents,
1393
+ currentClientEntityId
1394
+ );
1395
+
1396
+ return (
1397
+ <div className="flex flex-col gap-3 mb-4">
1398
+ <div className="flex items-center justify-between p-3 bg-card rounded-lg border">
1399
+ <div className="flex items-center gap-3 min-w-0 flex-1">
1400
+ <h2 className="font-semibold text-lg truncate" title={groupDisplayName}>
1401
+ {groupDisplayName}
1402
+ </h2>
1403
+ </div>
1404
+ <div className="flex gap-1 sm:gap-2 items-center flex-shrink-0">
1405
+ {/* Group Actions Split Button */}
1406
+ <SplitButton
1407
+ mainAction={{
1408
+ label: 'Edit Group',
1409
+ onClick: () => updateChatState({ showGroupEditPanel: true }),
1410
+ icon: <Edit className="size-4" />,
1411
+ }}
1412
+ actions={[
1413
+ {
1414
+ label: 'Clear Messages',
1415
+ onClick: handleClearChat,
1416
+ icon: <Eraser className="size-4" />,
1417
+ disabled: !messages || messages.length === 0,
1418
+ },
1419
+ {
1420
+ label: 'Delete Group',
1421
+ onClick: () => {
1422
+ if (!finalChannelIdForHooks || !finalServerIdForHooks) return;
1423
+ // Capture the channel ID to use in the async callback
1424
+ const channelIdToDelete = finalChannelIdForHooks;
1425
+ confirm(
1426
+ {
1427
+ title: 'Delete Group',
1428
+ description:
1429
+ 'Are you sure you want to delete this group? This action cannot be undone.',
1430
+ confirmText: 'Delete',
1431
+ variant: 'destructive',
1432
+ },
1433
+ async () => {
1434
+ try {
1435
+ const elizaClient = createElizaClient();
1436
+ await elizaClient.messaging.deleteChannel(channelIdToDelete);
1437
+ toast({
1438
+ title: 'Group Deleted',
1439
+ description: 'The group has been successfully deleted.',
1440
+ });
1441
+ // Navigate back to home after deletion
1442
+ window.location.href = '/';
1443
+ } catch (error) {
1444
+ clientLogger.error('[Chat] Error deleting group:', error);
1445
+ toast({
1446
+ title: 'Error',
1447
+ description: 'Could not delete group.',
1448
+ variant: 'destructive',
1449
+ });
1450
+ }
1451
+ }
1452
+ );
1453
+ },
1454
+ icon: <Trash2 className="size-4" />,
1455
+ disabled: !finalChannelIdForHooks || !finalServerIdForHooks,
1456
+ variant: 'destructive',
1457
+ },
1458
+ ]}
1459
+ variant="outline"
1460
+ size="sm"
1461
+ className="px-2 sm:px-3"
1462
+ />
1463
+ <Button
1464
+ variant="ghost"
1465
+ size="sm"
1466
+ className="px-2 sm:px-3 h-8 w-8 sm:w-auto"
1467
+ onClick={toggleSidebar}
1468
+ >
1469
+ {showSidebar ? (
1470
+ <PanelRightClose className="h-4 w-4" />
1471
+ ) : (
1472
+ <PanelRight className="h-4 w-4" />
1473
+ )}
1474
+ </Button>
1475
+ </div>
1476
+ </div>
1477
+
1478
+ {groupAgents.length > 0 && (
1479
+ <div className="flex items-center gap-2 p-2 bg-card rounded-lg border overflow-x-auto">
1480
+ <span className="text-sm text-muted-foreground whitespace-nowrap flex-shrink-0">
1481
+ Agents:
1482
+ </span>
1483
+ <div className="flex gap-2 min-w-0">
1484
+ <Button
1485
+ variant={!chatState.selectedGroupAgentId ? 'default' : 'ghost'}
1486
+ size="sm"
1487
+ onClick={() => updateChatState({ selectedGroupAgentId: null })}
1488
+ className="flex items-center gap-2 flex-shrink-0"
1489
+ >
1490
+ <span>All</span>
1491
+ </Button>
1492
+ {groupAgents.map((agent) => (
1493
+ <Button
1494
+ key={agent?.id}
1495
+ variant={chatState.selectedGroupAgentId === agent?.id ? 'default' : 'ghost'}
1496
+ size="sm"
1497
+ onClick={() => {
1498
+ updateChatState({
1499
+ selectedGroupAgentId: agent?.id || null,
1500
+ });
1501
+ if (agent?.id && !showSidebar) {
1502
+ setSidebarVisible(true);
1503
+ }
1504
+ }}
1505
+ className="flex items-center gap-2 flex-shrink-0"
1506
+ >
1507
+ <Avatar className="size-5">
1508
+ <AvatarImage src={getAgentAvatar(agent)} />
1509
+ </Avatar>
1510
+ <span className="truncate max-w-[100px] sm:max-w-none">{agent?.name}</span>
1511
+ </Button>
1512
+ ))}
1513
+ </div>
1514
+ </div>
1515
+ )}
1516
+ </div>
1517
+ );
1518
+ }
1519
+ return null;
1520
+ };
1521
+
1522
+ return (
1523
+ <>
1524
+ <div className="h-full flex flex-col relative overflow-hidden">
1525
+ {/* Conditional layout based on floating mode */}
1526
+ {isFloatingMode ? (
1527
+ /* Single panel layout for floating mode */
1528
+ <div className="h-full flex flex-col overflow-hidden">
1529
+ <div className="flex-shrink-0 p-2 sm:p-4 pb-0">{renderChatHeader()}</div>
1530
+
1531
+ <div
1532
+ className={cn(
1533
+ 'flex flex-col transition-all duration-300 w-full flex-1 min-h-0 overflow-hidden p-2 sm:p-4 pt-0'
1534
+ )}
1535
+ >
1536
+ <div className="flex-1 min-h-0 overflow-hidden px-5 py-2">
1537
+ <ChatMessageListComponent
1538
+ messages={messages}
1539
+ isLoadingMessages={isLoadingMessages}
1540
+ chatType={chatType}
1541
+ currentClientEntityId={currentClientEntityId}
1542
+ targetAgentData={targetAgentData}
1543
+ allAgents={allAgents}
1544
+ animatedMessageId={animatedMessageId}
1545
+ scrollRef={scrollRef as unknown as React.RefObject<HTMLDivElement>}
1546
+ contentRef={contentRef as unknown as React.RefObject<HTMLDivElement>}
1547
+ isAtBottom={isAtBottom}
1548
+ scrollToBottom={scrollToBottom}
1549
+ disableAutoScroll={disableAutoScroll}
1550
+ finalChannelId={finalChannelIdForHooks}
1551
+ getAgentInMessage={getAgentInMessage}
1552
+ agentAvatarMap={agentAvatarMap}
1553
+ onDeleteMessage={handleDeleteMessage}
1554
+ onRetryMessage={(messageText) => {
1555
+ // Ensure we have required IDs before retrying
1556
+ if (!finalChannelIdForHooks) {
1557
+ toast({
1558
+ title: 'Error',
1559
+ description: 'Cannot retry message: missing channel information.',
1560
+ variant: 'destructive',
1561
+ });
1562
+ return;
1563
+ }
1564
+ const message: UiMessage = {
1565
+ id: randomUUID() as UUID,
1566
+ text: messageText,
1567
+ name: USER_NAME,
1568
+ senderId: currentClientEntityId as UUID,
1569
+ isAgent: false,
1570
+ createdAt: Date.now(),
1571
+ channelId: finalChannelIdForHooks,
1572
+ serverId: finalServerIdForHooks,
1573
+ };
1574
+ handleRetryMessage(message);
1575
+ }}
1576
+ selectedGroupAgentId={chatState.selectedGroupAgentId}
1577
+ />
1578
+ </div>
1579
+
1580
+ <div className="flex-shrink-0">
1581
+ <ChatInputArea
1582
+ input={chatState.input}
1583
+ setInput={(value) => updateChatState({ input: value })}
1584
+ inputDisabled={chatState.inputDisabled}
1585
+ selectedFiles={selectedFiles}
1586
+ removeFile={removeFile}
1587
+ handleFileChange={handleFileChange}
1588
+ handleSendMessage={handleSendMessage}
1589
+ handleKeyDown={handleKeyDown}
1590
+ chatType={chatType}
1591
+ targetAgentData={targetAgentData}
1592
+ formRef={formRef}
1593
+ inputRef={inputRef}
1594
+ fileInputRef={fileInputRef}
1595
+ />
1596
+ </div>
1597
+ </div>
1598
+ </div>
1599
+ ) : (
1600
+ /* Resizable panel layout for desktop mode */
1601
+ <ResizablePanelGroup
1602
+ direction="horizontal"
1603
+ className="h-full flex-1 overflow-hidden"
1604
+ onLayout={(sizes) => {
1605
+ if (sizes.length >= 2 && showSidebar && !chatState.isMobile) {
1606
+ setMainPanelSize(sizes[0]);
1607
+ setSidebarPanelSize(sizes[1]);
1608
+ }
1609
+ }}
1610
+ >
1611
+ <ResizablePanel
1612
+ defaultSize={showSidebar && !chatState.isMobile ? mainPanelSize : 100}
1613
+ minSize={chatState.isMobile ? 100 : 50}
1614
+ >
1615
+ <div className="relative h-full overflow-hidden">
1616
+ {/* Main chat content */}
1617
+ <div className="h-full flex flex-col overflow-hidden">
1618
+ <div className="flex-shrink-0 p-2 sm:p-4 pb-0">{renderChatHeader()}</div>
1619
+
1620
+ <div
1621
+ className={cn(
1622
+ 'flex flex-col transition-all duration-300 w-full flex-1 min-h-0 overflow-hidden p-2 sm:p-4 pt-0'
1623
+ )}
1624
+ >
1625
+ <div className="flex-1 min-h-0 overflow-hidden px-5 py-2">
1626
+ <ChatMessageListComponent
1627
+ messages={messages}
1628
+ isLoadingMessages={isLoadingMessages}
1629
+ chatType={chatType}
1630
+ currentClientEntityId={currentClientEntityId}
1631
+ targetAgentData={targetAgentData}
1632
+ allAgents={allAgents}
1633
+ animatedMessageId={animatedMessageId}
1634
+ scrollRef={scrollRef as unknown as React.RefObject<HTMLDivElement>}
1635
+ contentRef={contentRef as unknown as React.RefObject<HTMLDivElement>}
1636
+ isAtBottom={isAtBottom}
1637
+ scrollToBottom={scrollToBottom}
1638
+ disableAutoScroll={disableAutoScroll}
1639
+ finalChannelId={finalChannelIdForHooks}
1640
+ getAgentInMessage={getAgentInMessage}
1641
+ agentAvatarMap={agentAvatarMap}
1642
+ onDeleteMessage={handleDeleteMessage}
1643
+ onRetryMessage={(messageText) => {
1644
+ // Ensure we have required IDs before retrying
1645
+ if (!finalChannelIdForHooks) {
1646
+ toast({
1647
+ title: 'Error',
1648
+ description: 'Cannot retry message: missing channel information.',
1649
+ variant: 'destructive',
1650
+ });
1651
+ return;
1652
+ }
1653
+ const message: UiMessage = {
1654
+ id: randomUUID() as UUID,
1655
+ text: messageText,
1656
+ name: USER_NAME,
1657
+ senderId: currentClientEntityId as UUID,
1658
+ isAgent: false,
1659
+ createdAt: Date.now(),
1660
+ channelId: finalChannelIdForHooks,
1661
+ serverId: finalServerIdForHooks,
1662
+ };
1663
+ handleRetryMessage(message);
1664
+ }}
1665
+ selectedGroupAgentId={chatState.selectedGroupAgentId}
1666
+ />
1667
+ </div>
1668
+
1669
+ <div className="flex-shrink-0">
1670
+ <ChatInputArea
1671
+ input={chatState.input}
1672
+ setInput={(value) => updateChatState({ input: value })}
1673
+ inputDisabled={chatState.inputDisabled}
1674
+ selectedFiles={selectedFiles}
1675
+ removeFile={removeFile}
1676
+ handleFileChange={handleFileChange}
1677
+ handleSendMessage={handleSendMessage}
1678
+ handleKeyDown={handleKeyDown}
1679
+ chatType={chatType}
1680
+ targetAgentData={targetAgentData}
1681
+ formRef={formRef}
1682
+ inputRef={inputRef}
1683
+ fileInputRef={fileInputRef}
1684
+ />
1685
+ </div>
1686
+ </div>
1687
+ </div>
1688
+ </div>
1689
+ </ResizablePanel>
1690
+
1691
+ {/* Right panel / sidebar */}
1692
+ {(() => {
1693
+ let sidebarAgentId: UUID | undefined = undefined;
1694
+ let sidebarAgentName: string = 'Agent';
1695
+ let sidebarChannelId: UUID | undefined = undefined;
1696
+
1697
+ if (chatType === ChannelType.DM) {
1698
+ sidebarAgentId = contextId; // This is agentId for DM
1699
+ sidebarAgentName = targetAgentData?.name || 'Agent';
1700
+ sidebarChannelId = chatState.currentDmChannelId || undefined;
1701
+ } else if (chatType === ChannelType.GROUP && chatState.selectedGroupAgentId) {
1702
+ sidebarAgentId = chatState.selectedGroupAgentId;
1703
+ const selectedAgent = allAgents.find(
1704
+ (a) => a.id === chatState.selectedGroupAgentId
1705
+ );
1706
+ sidebarAgentName = selectedAgent?.name || 'Group Member';
1707
+ sidebarChannelId = contextId; // contextId is the channelId for GROUP
1708
+ } else if (chatType === ChannelType.GROUP && !chatState.selectedGroupAgentId) {
1709
+ sidebarAgentName = 'Group';
1710
+ sidebarChannelId = contextId; // contextId is the channelId for GROUP
1711
+ }
1712
+
1713
+ return (
1714
+ showSidebar &&
1715
+ !chatState.isMobile && (
1716
+ <>
1717
+ <ResizableHandle withHandle />
1718
+ <ResizablePanel defaultSize={sidebarPanelSize} minSize={20} maxSize={50}>
1719
+ <AgentSidebar
1720
+ agentId={sidebarAgentId}
1721
+ agentName={sidebarAgentName}
1722
+ channelId={sidebarChannelId}
1723
+ />
1724
+ </ResizablePanel>
1725
+ </>
1726
+ )
1727
+ );
1728
+ })()}
1729
+ </ResizablePanelGroup>
1730
+ )}
1731
+
1732
+ {/* Floating sidebar overlay for narrow screens */}
1733
+ {(() => {
1734
+ let sidebarAgentId: UUID | undefined = undefined;
1735
+ let sidebarAgentName: string = 'Agent';
1736
+ let sidebarChannelId: UUID | undefined = undefined;
1737
+
1738
+ if (chatType === ChannelType.DM) {
1739
+ sidebarAgentId = contextId; // This is agentId for DM
1740
+ sidebarAgentName = targetAgentData?.name || 'Agent';
1741
+ sidebarChannelId = chatState.currentDmChannelId || undefined;
1742
+ } else if (chatType === ChannelType.GROUP && chatState.selectedGroupAgentId) {
1743
+ sidebarAgentId = chatState.selectedGroupAgentId;
1744
+ const selectedAgent = allAgents.find((a) => a.id === chatState.selectedGroupAgentId);
1745
+ sidebarAgentName = selectedAgent?.name || 'Group Member';
1746
+ sidebarChannelId = contextId; // contextId is the channelId for GROUP
1747
+ } else if (chatType === ChannelType.GROUP && !chatState.selectedGroupAgentId) {
1748
+ sidebarAgentName = 'Group';
1749
+ sidebarChannelId = contextId; // contextId is the channelId for GROUP
1750
+ }
1751
+
1752
+ return (
1753
+ showSidebar &&
1754
+ isFloatingMode && (
1755
+ <div className="absolute inset-0 z-50 bg-background/80 backdrop-blur-sm">
1756
+ <div className="absolute inset-0 bg-background shadow-lg">
1757
+ <div className="h-full flex flex-col">
1758
+ {/* Close button for floating sidebar */}
1759
+ <div className="flex items-center justify-between p-4 border-b">
1760
+ <h3 className="font-semibold text-lg">{sidebarAgentName}</h3>
1761
+ <Button
1762
+ variant="ghost"
1763
+ size="sm"
1764
+ onClick={() => setSidebarVisible(false)}
1765
+ className="h-8 w-8 p-0"
1766
+ >
1767
+ <PanelRightClose className="h-4 w-4" />
1768
+ </Button>
1769
+ </div>
1770
+ <div className="flex-1 overflow-hidden">
1771
+ <AgentSidebar
1772
+ agentId={sidebarAgentId}
1773
+ agentName={sidebarAgentName}
1774
+ channelId={sidebarChannelId}
1775
+ />
1776
+ </div>
1777
+ </div>
1778
+ </div>
1779
+ </div>
1780
+ )
1781
+ );
1782
+ })()}
1783
+ </div>
1784
+
1785
+ {chatState.showGroupEditPanel && chatType === ChannelType.GROUP && (
1786
+ <GroupPanel
1787
+ onClose={() => updateChatState({ showGroupEditPanel: false })}
1788
+ channelId={contextId}
1789
+ />
1790
+ )}
1791
+
1792
+ {chatState.showProfileOverlay && chatType === ChannelType.DM && targetAgentData?.id && (
1793
+ <ProfileOverlay
1794
+ isOpen={chatState.showProfileOverlay}
1795
+ onClose={() => updateChatState({ showProfileOverlay: false })}
1796
+ agentId={targetAgentData.id}
1797
+ />
1798
+ )}
1799
+
1800
+ {/* Confirmation Dialogs */}
1801
+ <ConfirmationDialog
1802
+ open={isOpen}
1803
+ onOpenChange={onOpenChange}
1804
+ title={options?.title || ''}
1805
+ description={options?.description || ''}
1806
+ confirmText={options?.confirmText}
1807
+ cancelText={options?.cancelText}
1808
+ variant={options?.variant}
1809
+ onConfirm={onConfirm}
1810
+ />
1811
+ </>
1812
+ );
1813
+ }