@desplega.ai/agent-swarm 1.2.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.claude/settings.local.json +20 -1
  2. package/.env.docker.example +22 -1
  3. package/.env.example +17 -0
  4. package/.github/workflows/docker-publish.yml +92 -0
  5. package/CONTRIBUTING.md +270 -0
  6. package/DEPLOYMENT.md +391 -0
  7. package/Dockerfile.worker +29 -1
  8. package/FAQ.md +19 -0
  9. package/LICENSE +21 -0
  10. package/MCP.md +249 -0
  11. package/README.md +103 -207
  12. package/assets/agent-swarm-logo-orange.png +0 -0
  13. package/assets/agent-swarm-logo.png +0 -0
  14. package/docker-compose.example.yml +137 -0
  15. package/docker-entrypoint.sh +223 -7
  16. package/package.json +8 -3
  17. package/{cc-plugin → plugin}/.claude-plugin/plugin.json +1 -1
  18. package/plugin/README.md +1 -0
  19. package/plugin/agents/.gitkeep +0 -0
  20. package/plugin/agents/codebase-analyzer.md +143 -0
  21. package/plugin/agents/codebase-locator.md +122 -0
  22. package/plugin/agents/codebase-pattern-finder.md +227 -0
  23. package/plugin/agents/web-search-researcher.md +109 -0
  24. package/plugin/commands/create-plan.md +415 -0
  25. package/plugin/commands/implement-plan.md +89 -0
  26. package/plugin/commands/research.md +200 -0
  27. package/plugin/commands/start-leader.md +101 -0
  28. package/plugin/commands/start-worker.md +56 -0
  29. package/plugin/commands/swarm-chat.md +78 -0
  30. package/plugin/commands/todos.md +66 -0
  31. package/plugin/commands/work-on-task.md +44 -0
  32. package/plugin/skills/.gitkeep +0 -0
  33. package/scripts/generate-mcp-docs.ts +415 -0
  34. package/slack-manifest.json +69 -0
  35. package/src/be/db.ts +1431 -25
  36. package/src/cli.tsx +135 -11
  37. package/src/commands/lead.ts +13 -0
  38. package/src/commands/runner.ts +255 -0
  39. package/src/commands/worker.ts +8 -220
  40. package/src/hooks/hook.ts +102 -14
  41. package/src/http.ts +361 -5
  42. package/src/prompts/base-prompt.ts +131 -0
  43. package/src/server.ts +56 -0
  44. package/src/slack/app.ts +73 -0
  45. package/src/slack/commands.ts +88 -0
  46. package/src/slack/handlers.ts +281 -0
  47. package/src/slack/index.ts +3 -0
  48. package/src/slack/responses.ts +175 -0
  49. package/src/slack/router.ts +170 -0
  50. package/src/slack/types.ts +20 -0
  51. package/src/slack/watcher.ts +119 -0
  52. package/src/tools/create-channel.ts +80 -0
  53. package/src/tools/get-tasks.ts +54 -21
  54. package/src/tools/join-swarm.ts +28 -4
  55. package/src/tools/list-channels.ts +37 -0
  56. package/src/tools/list-services.ts +110 -0
  57. package/src/tools/poll-task.ts +46 -3
  58. package/src/tools/post-message.ts +87 -0
  59. package/src/tools/read-messages.ts +192 -0
  60. package/src/tools/register-service.ts +118 -0
  61. package/src/tools/send-task.ts +80 -7
  62. package/src/tools/store-progress.ts +9 -3
  63. package/src/tools/task-action.ts +211 -0
  64. package/src/tools/unregister-service.ts +110 -0
  65. package/src/tools/update-profile.ts +105 -0
  66. package/src/tools/update-service-status.ts +118 -0
  67. package/src/types.ts +110 -3
  68. package/src/utils/pretty-print.ts +224 -0
  69. package/thoughts/shared/plans/.gitkeep +0 -0
  70. package/thoughts/shared/plans/2025-12-18-inverse-teleport.md +1142 -0
  71. package/thoughts/shared/plans/2025-12-18-slack-integration.md +1195 -0
  72. package/thoughts/shared/plans/2025-12-19-agent-log-streaming.md +732 -0
  73. package/thoughts/shared/plans/2025-12-19-role-based-swarm-plugin.md +361 -0
  74. package/thoughts/shared/plans/2025-12-20-mobile-responsive-ui.md +501 -0
  75. package/thoughts/shared/plans/2025-12-20-startup-team-swarm.md +560 -0
  76. package/thoughts/shared/research/.gitkeep +0 -0
  77. package/thoughts/shared/research/2025-12-18-slack-integration.md +442 -0
  78. package/thoughts/shared/research/2025-12-19-agent-log-streaming.md +339 -0
  79. package/thoughts/shared/research/2025-12-19-agent-secrets-cli-research.md +390 -0
  80. package/thoughts/shared/research/2025-12-21-gemini-cli-integration.md +376 -0
  81. package/thoughts/shared/research/2025-12-22-setup-experience-improvements.md +264 -0
  82. package/tsconfig.json +3 -1
  83. package/ui/bun.lock +692 -0
  84. package/ui/index.html +22 -0
  85. package/ui/package.json +32 -0
  86. package/ui/pnpm-lock.yaml +3034 -0
  87. package/ui/postcss.config.js +6 -0
  88. package/ui/public/logo.png +0 -0
  89. package/ui/src/App.tsx +43 -0
  90. package/ui/src/components/ActivityFeed.tsx +415 -0
  91. package/ui/src/components/AgentDetailPanel.tsx +534 -0
  92. package/ui/src/components/AgentsPanel.tsx +549 -0
  93. package/ui/src/components/ChatPanel.tsx +1820 -0
  94. package/ui/src/components/ConfigModal.tsx +232 -0
  95. package/ui/src/components/Dashboard.tsx +534 -0
  96. package/ui/src/components/Header.tsx +168 -0
  97. package/ui/src/components/ServicesPanel.tsx +612 -0
  98. package/ui/src/components/StatsBar.tsx +288 -0
  99. package/ui/src/components/StatusBadge.tsx +124 -0
  100. package/ui/src/components/TaskDetailPanel.tsx +807 -0
  101. package/ui/src/components/TasksPanel.tsx +575 -0
  102. package/ui/src/hooks/queries.ts +170 -0
  103. package/ui/src/index.css +235 -0
  104. package/ui/src/lib/api.ts +161 -0
  105. package/ui/src/lib/config.ts +35 -0
  106. package/ui/src/lib/theme.ts +214 -0
  107. package/ui/src/lib/utils.ts +48 -0
  108. package/ui/src/main.tsx +32 -0
  109. package/ui/src/types/api.ts +164 -0
  110. package/ui/src/vite-env.d.ts +1 -0
  111. package/ui/tailwind.config.js +35 -0
  112. package/ui/tsconfig.json +31 -0
  113. package/ui/vite.config.ts +22 -0
  114. package/cc-plugin/README.md +0 -49
  115. package/cc-plugin/commands/setup-leader.md +0 -73
  116. package/cc-plugin/commands/start-worker.md +0 -64
  117. package/docker-compose.worker.yml +0 -35
  118. package/example-req-meta.json +0 -24
  119. /package/{cc-plugin → plugin}/hooks/hooks.json +0 -0
@@ -0,0 +1,1820 @@
1
+ import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
2
+ import Box from "@mui/joy/Box";
3
+ import Typography from "@mui/joy/Typography";
4
+ import Textarea from "@mui/joy/Textarea";
5
+ import IconButton from "@mui/joy/IconButton";
6
+ import Card from "@mui/joy/Card";
7
+ import Link from "@mui/joy/Link";
8
+ import Tooltip from "@mui/joy/Tooltip";
9
+ import Drawer from "@mui/joy/Drawer";
10
+ import { useColorScheme } from "@mui/joy/styles";
11
+ import { useChannels, useInfiniteMessages, useThreadMessages, usePostMessage, useAgents } from "../hooks/queries";
12
+ import type { ChannelMessage, Agent } from "../types/api";
13
+ import { formatSmartTime } from "@/lib/utils";
14
+ import ReactMarkdown from "react-markdown";
15
+ import remarkGfm from "remark-gfm";
16
+
17
+ interface MentionInputProps {
18
+ value: string;
19
+ onChange: (value: string) => void;
20
+ onSend: () => void;
21
+ onMentionsChange?: (mentions: string[]) => void;
22
+ placeholder: string;
23
+ agents: Agent[];
24
+ inputStyles: object;
25
+ sendButtonStyles: object;
26
+ sendLabel: string;
27
+ disabled?: boolean;
28
+ colors: Record<string, string>;
29
+ isDark: boolean;
30
+ }
31
+
32
+ const MentionInput = React.memo(function MentionInput({
33
+ value,
34
+ onChange,
35
+ onSend,
36
+ onMentionsChange,
37
+ placeholder,
38
+ agents,
39
+ inputStyles,
40
+ sendButtonStyles,
41
+ sendLabel,
42
+ disabled,
43
+ colors,
44
+ isDark,
45
+ }: MentionInputProps) {
46
+ const [showMentionPopup, setShowMentionPopup] = useState(false);
47
+ const [mentionQuery, setMentionQuery] = useState("");
48
+ const [mentionStartPos, setMentionStartPos] = useState(0);
49
+ const [selectedIndex, setSelectedIndex] = useState(0);
50
+ const [mentions, setMentions] = useState<string[]>([]);
51
+ const inputRef = useRef<HTMLTextAreaElement>(null);
52
+ const popupRef = useRef<HTMLDivElement>(null);
53
+
54
+ // Filter agents based on query
55
+ const filteredAgents = useMemo(() => {
56
+ if (!mentionQuery) return agents;
57
+ const query = mentionQuery.toLowerCase();
58
+ return agents.filter(
59
+ (agent) =>
60
+ agent.name.toLowerCase().includes(query) ||
61
+ (agent.role && agent.role.toLowerCase().includes(query))
62
+ );
63
+ }, [agents, mentionQuery]);
64
+
65
+ // Reset selected index when filtered list changes
66
+ useEffect(() => {
67
+ setSelectedIndex(0);
68
+ }, [filteredAgents.length]);
69
+
70
+ // Notify parent of mention changes
71
+ useEffect(() => {
72
+ if (onMentionsChange) {
73
+ onMentionsChange(mentions);
74
+ }
75
+ }, [mentions, onMentionsChange]);
76
+
77
+ // Reset mentions when input is cleared
78
+ useEffect(() => {
79
+ if (!value) {
80
+ setMentions([]);
81
+ }
82
+ }, [value]);
83
+
84
+ const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
85
+ const newValue = e.target.value;
86
+ const cursorPos = e.target.selectionStart || 0;
87
+ onChange(newValue);
88
+
89
+ // Check if we should show mention popup
90
+ // Find the last @ before cursor that isn't followed by a space
91
+ const textBeforeCursor = newValue.slice(0, cursorPos);
92
+ const lastAtIndex = textBeforeCursor.lastIndexOf("@");
93
+
94
+ if (lastAtIndex >= 0) {
95
+ const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
96
+ // Show popup if @ is at start or preceded by space, and no space after @
97
+ const charBeforeAt = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : " ";
98
+ if ((charBeforeAt === " " || lastAtIndex === 0) && !textAfterAt.includes(" ")) {
99
+ setShowMentionPopup(true);
100
+ setMentionQuery(textAfterAt);
101
+ setMentionStartPos(lastAtIndex);
102
+ return;
103
+ }
104
+ }
105
+
106
+ setShowMentionPopup(false);
107
+ setMentionQuery("");
108
+ };
109
+
110
+ const handleSelectAgent = (agent: Agent) => {
111
+ // Replace @query with @agentName
112
+ const beforeMention = value.slice(0, mentionStartPos);
113
+ const afterMention = value.slice(mentionStartPos + 1 + mentionQuery.length);
114
+ const newValue = `${beforeMention}@${agent.name} ${afterMention}`;
115
+
116
+ onChange(newValue);
117
+ setMentions((prev) => [...new Set([...prev, agent.id])]);
118
+ setShowMentionPopup(false);
119
+ setMentionQuery("");
120
+
121
+ // Focus input
122
+ setTimeout(() => inputRef.current?.focus(), 0);
123
+ };
124
+
125
+ const handleKeyDown = (e: React.KeyboardEvent) => {
126
+ if (showMentionPopup && filteredAgents.length > 0) {
127
+ switch (e.key) {
128
+ case "ArrowDown":
129
+ e.preventDefault();
130
+ setSelectedIndex((prev) => (prev + 1) % filteredAgents.length);
131
+ break;
132
+ case "ArrowUp":
133
+ e.preventDefault();
134
+ setSelectedIndex((prev) => (prev - 1 + filteredAgents.length) % filteredAgents.length);
135
+ break;
136
+ case "Enter":
137
+ e.preventDefault();
138
+ if (filteredAgents[selectedIndex]) {
139
+ handleSelectAgent(filteredAgents[selectedIndex]);
140
+ }
141
+ break;
142
+ case "Escape":
143
+ e.preventDefault();
144
+ setShowMentionPopup(false);
145
+ break;
146
+ case "Tab":
147
+ e.preventDefault();
148
+ if (filteredAgents[selectedIndex]) {
149
+ handleSelectAgent(filteredAgents[selectedIndex]);
150
+ }
151
+ break;
152
+ }
153
+ } else if (e.key === "Enter" && !e.shiftKey) {
154
+ e.preventDefault();
155
+ onSend();
156
+ }
157
+ };
158
+
159
+ // Close popup when clicking outside
160
+ useEffect(() => {
161
+ const handleClickOutside = (e: MouseEvent) => {
162
+ if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
163
+ setShowMentionPopup(false);
164
+ }
165
+ };
166
+ document.addEventListener("mousedown", handleClickOutside);
167
+ return () => document.removeEventListener("mousedown", handleClickOutside);
168
+ }, []);
169
+
170
+ return (
171
+ <Box sx={{ position: "relative", display: "flex", gap: 1.5, flex: 1, alignItems: "flex-end" }}>
172
+ <Textarea
173
+ slotProps={{
174
+ textarea: {
175
+ ref: inputRef,
176
+ },
177
+ }}
178
+ placeholder={placeholder}
179
+ value={value}
180
+ onChange={handleInputChange}
181
+ onKeyDown={handleKeyDown}
182
+ minRows={1}
183
+ maxRows={5}
184
+ sx={{ ...inputStyles, flex: 1 }}
185
+ />
186
+
187
+ {/* Mention autocomplete popup */}
188
+ {showMentionPopup && filteredAgents.length > 0 && (
189
+ <Box
190
+ ref={popupRef}
191
+ sx={{
192
+ position: "absolute",
193
+ bottom: "100%",
194
+ left: 0,
195
+ right: 80,
196
+ mb: 0.5,
197
+ bgcolor: isDark ? "#1A130E" : "#FFFFFF",
198
+ border: "1px solid",
199
+ borderColor: colors.amberBorder,
200
+ borderRadius: "8px",
201
+ boxShadow: isDark
202
+ ? "0 4px 20px rgba(0, 0, 0, 0.5)"
203
+ : "0 4px 20px rgba(0, 0, 0, 0.15)",
204
+ maxHeight: 200,
205
+ overflow: "auto",
206
+ zIndex: 1000,
207
+ }}
208
+ >
209
+ {filteredAgents.map((agent, index) => (
210
+ <Box
211
+ key={agent.id}
212
+ onClick={() => handleSelectAgent(agent)}
213
+ sx={{
214
+ px: 2,
215
+ py: 1.5,
216
+ cursor: "pointer",
217
+ display: "flex",
218
+ alignItems: "center",
219
+ gap: 1.5,
220
+ bgcolor: index === selectedIndex ? colors.selectedBg : "transparent",
221
+ borderBottom: index < filteredAgents.length - 1 ? "1px solid" : "none",
222
+ borderColor: "neutral.outlinedBorder",
223
+ transition: "background-color 0.1s ease",
224
+ "&:hover": {
225
+ bgcolor: colors.hoverBg,
226
+ },
227
+ }}
228
+ >
229
+ {/* Status dot */}
230
+ <Box
231
+ sx={{
232
+ width: 8,
233
+ height: 8,
234
+ borderRadius: "50%",
235
+ bgcolor:
236
+ agent.status === "busy"
237
+ ? colors.amber
238
+ : agent.status === "idle"
239
+ ? colors.gold
240
+ : colors.dormant || "#6B5344",
241
+ boxShadow:
242
+ agent.status === "busy"
243
+ ? isDark
244
+ ? "0 0 6px rgba(245, 166, 35, 0.4)"
245
+ : "0 0 4px rgba(212, 136, 6, 0.3)"
246
+ : "none",
247
+ }}
248
+ />
249
+ <Box sx={{ flex: 1, minWidth: 0 }}>
250
+ <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
251
+ <Typography
252
+ sx={{
253
+ fontFamily: "'Space Grotesk', sans-serif",
254
+ fontWeight: 600,
255
+ fontSize: "0.85rem",
256
+ color: agent.isLead ? colors.honey : colors.amber,
257
+ whiteSpace: "nowrap",
258
+ }}
259
+ >
260
+ {agent.name}
261
+ </Typography>
262
+ {agent.isLead && (
263
+ <Typography
264
+ sx={{
265
+ fontFamily: "'JetBrains Mono', monospace",
266
+ fontSize: "0.55rem",
267
+ fontWeight: 700,
268
+ color: colors.honey,
269
+ bgcolor: isDark ? "rgba(255, 184, 77, 0.15)" : "rgba(184, 115, 0, 0.1)",
270
+ px: 0.75,
271
+ py: 0.2,
272
+ borderRadius: "4px",
273
+ border: "1px solid",
274
+ borderColor: isDark ? "rgba(255, 184, 77, 0.3)" : "rgba(184, 115, 0, 0.25)",
275
+ letterSpacing: "0.05em",
276
+ }}
277
+ >
278
+ LEAD
279
+ </Typography>
280
+ )}
281
+ </Box>
282
+ {agent.role && (
283
+ <Typography
284
+ sx={{
285
+ fontFamily: "'JetBrains Mono', monospace",
286
+ fontSize: "0.7rem",
287
+ color: "text.tertiary",
288
+ }}
289
+ >
290
+ {agent.role}
291
+ </Typography>
292
+ )}
293
+ </Box>
294
+ </Box>
295
+ ))}
296
+ </Box>
297
+ )}
298
+
299
+ <Box
300
+ component="button"
301
+ onClick={onSend}
302
+ disabled={disabled}
303
+ sx={sendButtonStyles}
304
+ >
305
+ {sendLabel}
306
+ </Box>
307
+ </Box>
308
+ );
309
+ });
310
+
311
+ // Helper to format date for dividers
312
+ function formatDateDivider(dateStr: string): string {
313
+ const date = new Date(dateStr);
314
+ const now = new Date();
315
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
316
+ const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
317
+
318
+ if (messageDate.getTime() === today.getTime()) {
319
+ return `Today (${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })})`;
320
+ }
321
+
322
+ const yesterday = new Date(today);
323
+ yesterday.setDate(yesterday.getDate() - 1);
324
+ if (messageDate.getTime() === yesterday.getTime()) {
325
+ return `Yesterday (${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })})`;
326
+ }
327
+
328
+ return date.toLocaleDateString(undefined, {
329
+ weekday: "long",
330
+ month: "short",
331
+ day: "numeric",
332
+ year: messageDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
333
+ });
334
+ }
335
+
336
+ // Get date key for grouping (YYYY-MM-DD)
337
+ function getDateKey(dateStr: string): string {
338
+ const date = new Date(dateStr);
339
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
340
+ }
341
+
342
+ interface DateDividerProps {
343
+ date: string;
344
+ isDark: boolean;
345
+ colors: Record<string, string>;
346
+ }
347
+
348
+ function DateDivider({ date, isDark, colors }: DateDividerProps) {
349
+ return (
350
+ <Box
351
+ sx={{
352
+ display: "flex",
353
+ alignItems: "center",
354
+ gap: 2,
355
+ px: 2,
356
+ py: 1.5,
357
+ my: 1,
358
+ }}
359
+ >
360
+ <Box sx={{ flex: 1, height: 1, bgcolor: isDark ? "rgba(212, 165, 116, 0.2)" : "rgba(139, 105, 20, 0.15)" }} />
361
+ <Typography
362
+ sx={{
363
+ fontFamily: "'JetBrains Mono', monospace",
364
+ fontSize: "0.7rem",
365
+ fontWeight: 600,
366
+ color: colors.gold,
367
+ letterSpacing: "0.03em",
368
+ whiteSpace: "nowrap",
369
+ }}
370
+ >
371
+ {formatDateDivider(date)}
372
+ </Typography>
373
+ <Box sx={{ flex: 1, height: 1, bgcolor: isDark ? "rgba(212, 165, 116, 0.2)" : "rgba(139, 105, 20, 0.15)" }} />
374
+ </Box>
375
+ );
376
+ }
377
+
378
+ interface MessageItemProps {
379
+ message: ChannelMessage;
380
+ isDark: boolean;
381
+ colors: Record<string, string>;
382
+ onOpenThread?: () => void;
383
+ threadCount?: number;
384
+ isThreadView?: boolean;
385
+ onAgentClick?: (agentId: string) => void;
386
+ onTaskClick?: (taskId: string) => void;
387
+ isSelected?: boolean;
388
+ agentsByName?: Map<string, string>; // name -> id mapping for @mentions
389
+ isLeadAgent?: boolean; // Whether the message sender is the lead agent
390
+ }
391
+
392
+ function MessageItem({
393
+ message,
394
+ isDark,
395
+ colors,
396
+ onOpenThread,
397
+ threadCount,
398
+ isThreadView,
399
+ onAgentClick,
400
+ onTaskClick,
401
+ isSelected,
402
+ agentsByName,
403
+ isLeadAgent,
404
+ }: MessageItemProps) {
405
+ const [copied, setCopied] = useState(false);
406
+ const [showRaw, setShowRaw] = useState(false);
407
+ const hasReplies = threadCount && threadCount > 0;
408
+
409
+ const handleCopy = useCallback(() => {
410
+ navigator.clipboard.writeText(message.content);
411
+ setCopied(true);
412
+ setTimeout(() => setCopied(false), 2000);
413
+ }, [message.content]);
414
+
415
+ const toggleRaw = useCallback(() => {
416
+ setShowRaw((prev) => !prev);
417
+ }, []);
418
+
419
+ // Custom markdown components to handle @mentions
420
+ const markdownComponents = useMemo(() => {
421
+ // Helper to render text with @mentions
422
+ const renderTextWithMentions = (text: string): React.ReactNode => {
423
+ if (!agentsByName || agentsByName.size === 0) {
424
+ return text;
425
+ }
426
+
427
+ // Build regex for agent names (longest first)
428
+ const agentNames = Array.from(agentsByName.keys()).sort((a, b) => b.length - a.length);
429
+ if (agentNames.length === 0) return text;
430
+
431
+ const escapedNames = agentNames.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
432
+ const mentionPattern = new RegExp(`@(${escapedNames.join('|')})(?=\\s|$|[.,!?;:])`, 'g');
433
+
434
+ const parts: React.ReactNode[] = [];
435
+ let lastIndex = 0;
436
+ let match;
437
+ let keyCounter = 0;
438
+
439
+ while ((match = mentionPattern.exec(text)) !== null) {
440
+ if (match.index > lastIndex) {
441
+ parts.push(text.slice(lastIndex, match.index));
442
+ }
443
+
444
+ const mentionName = match[1] ?? "";
445
+ const agentId = agentsByName.get(mentionName);
446
+
447
+ if (agentId && onAgentClick) {
448
+ parts.push(
449
+ <Link
450
+ key={`mention-${keyCounter++}`}
451
+ component="button"
452
+ onClick={(e) => {
453
+ e.stopPropagation();
454
+ onAgentClick(agentId);
455
+ }}
456
+ sx={{
457
+ fontFamily: "inherit",
458
+ fontWeight: 600,
459
+ fontSize: "inherit",
460
+ color: colors.amber,
461
+ textDecoration: "none",
462
+ cursor: "pointer",
463
+ bgcolor: isDark ? "rgba(245, 166, 35, 0.1)" : "rgba(212, 136, 6, 0.08)",
464
+ px: 0.5,
465
+ borderRadius: "4px",
466
+ "&:hover": {
467
+ textDecoration: "underline",
468
+ color: colors.honey,
469
+ },
470
+ }}
471
+ >
472
+ @{mentionName}
473
+ </Link>
474
+ );
475
+ } else {
476
+ parts.push(
477
+ <Box
478
+ key={`mention-${keyCounter++}`}
479
+ component="span"
480
+ sx={{
481
+ fontWeight: 600,
482
+ color: colors.gold,
483
+ bgcolor: isDark ? "rgba(212, 165, 116, 0.1)" : "rgba(139, 105, 20, 0.08)",
484
+ px: 0.5,
485
+ borderRadius: "4px",
486
+ }}
487
+ >
488
+ @{mentionName}
489
+ </Box>
490
+ );
491
+ }
492
+
493
+ lastIndex = match.index + match[0].length;
494
+ }
495
+
496
+ if (lastIndex < text.length) {
497
+ parts.push(text.slice(lastIndex));
498
+ }
499
+
500
+ return parts.length > 0 ? parts : text;
501
+ };
502
+
503
+ return {
504
+ // Handle task: links for navigating to tasks
505
+ a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
506
+ if (href?.startsWith("task:")) {
507
+ const taskId = href.slice(5); // Remove "task:" prefix
508
+ if (onTaskClick) {
509
+ return (
510
+ <Link
511
+ component="button"
512
+ onClick={(e) => {
513
+ e.stopPropagation();
514
+ onTaskClick(taskId);
515
+ }}
516
+ sx={{
517
+ fontFamily: "'JetBrains Mono', monospace",
518
+ fontSize: "0.85em",
519
+ color: colors.gold,
520
+ textDecoration: "none",
521
+ cursor: "pointer",
522
+ bgcolor: isDark ? "rgba(212, 165, 116, 0.1)" : "rgba(139, 105, 20, 0.08)",
523
+ px: 0.75,
524
+ py: 0.25,
525
+ borderRadius: "4px",
526
+ "&:hover": {
527
+ textDecoration: "underline",
528
+ bgcolor: isDark ? "rgba(212, 165, 116, 0.15)" : "rgba(139, 105, 20, 0.12)",
529
+ },
530
+ }}
531
+ >
532
+ {children}
533
+ </Link>
534
+ );
535
+ }
536
+ // Non-clickable task reference
537
+ return (
538
+ <Box
539
+ component="span"
540
+ sx={{
541
+ fontFamily: "'JetBrains Mono', monospace",
542
+ fontSize: "0.85em",
543
+ color: colors.gold,
544
+ bgcolor: isDark ? "rgba(212, 165, 116, 0.1)" : "rgba(139, 105, 20, 0.08)",
545
+ px: 0.75,
546
+ py: 0.25,
547
+ borderRadius: "4px",
548
+ }}
549
+ >
550
+ {children}
551
+ </Box>
552
+ );
553
+ }
554
+ // Regular links - render normally
555
+ return <a href={href}>{children}</a>;
556
+ },
557
+ // Process text nodes to handle @mentions
558
+ p: ({ children }: { children?: React.ReactNode }) => {
559
+ const processChildren = (child: React.ReactNode): React.ReactNode => {
560
+ if (typeof child === "string") {
561
+ return renderTextWithMentions(child);
562
+ }
563
+ if (Array.isArray(child)) {
564
+ return child.map((c, i) => <React.Fragment key={i}>{processChildren(c)}</React.Fragment>);
565
+ }
566
+ return child;
567
+ };
568
+ return <p>{processChildren(children)}</p>;
569
+ },
570
+ li: ({ children }: { children?: React.ReactNode }) => {
571
+ const processChildren = (child: React.ReactNode): React.ReactNode => {
572
+ if (typeof child === "string") {
573
+ return renderTextWithMentions(child);
574
+ }
575
+ if (Array.isArray(child)) {
576
+ return child.map((c, i) => <React.Fragment key={i}>{processChildren(c)}</React.Fragment>);
577
+ }
578
+ return child;
579
+ };
580
+ return <li>{processChildren(children)}</li>;
581
+ },
582
+ };
583
+ }, [agentsByName, onAgentClick, onTaskClick, colors, isDark]);
584
+
585
+ return (
586
+ <Box
587
+ sx={{
588
+ display: "flex",
589
+ flexDirection: "column",
590
+ gap: 0.5,
591
+ px: 1.5,
592
+ py: 1,
593
+ mx: 0.5,
594
+ my: 0.25,
595
+ borderRadius: "6px",
596
+ border: "1px solid",
597
+ borderColor: isSelected ? colors.amberBorder : "transparent",
598
+ bgcolor: isSelected
599
+ ? colors.selectedBg
600
+ : isDark ? "rgba(26, 19, 14, 0.5)" : "rgba(255, 255, 255, 0.5)",
601
+ transition: "all 0.2s ease",
602
+ "&:hover": {
603
+ bgcolor: isDark ? "rgba(245, 166, 35, 0.06)" : "rgba(212, 136, 6, 0.04)",
604
+ "& .action-icons": {
605
+ opacity: 1,
606
+ },
607
+ },
608
+ }}
609
+ >
610
+ {/* Header row */}
611
+ <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
612
+ {/* Indicator - hexagon for lead, dot for others */}
613
+ {isLeadAgent ? (
614
+ <Box
615
+ sx={{
616
+ width: 10,
617
+ height: 12,
618
+ clipPath: "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)",
619
+ bgcolor: colors.honey,
620
+ flexShrink: 0,
621
+ boxShadow: isDark ? "0 0 8px rgba(255, 184, 77, 0.5)" : "0 0 6px rgba(184, 115, 0, 0.4)",
622
+ }}
623
+ />
624
+ ) : (
625
+ <Box
626
+ sx={{
627
+ width: 8,
628
+ height: 8,
629
+ borderRadius: "50%",
630
+ bgcolor: message.agentId ? colors.amber : colors.blue,
631
+ flexShrink: 0,
632
+ boxShadow: message.agentId
633
+ ? (isDark ? "0 0 6px rgba(245, 166, 35, 0.4)" : "0 0 4px rgba(212, 136, 6, 0.3)")
634
+ : "0 0 6px rgba(59, 130, 246, 0.4)",
635
+ }}
636
+ />
637
+ )}
638
+
639
+ {/* Agent name - clickable if agent */}
640
+ {message.agentId && onAgentClick ? (
641
+ <Link
642
+ component="button"
643
+ onClick={(e) => {
644
+ e.stopPropagation();
645
+ onAgentClick(message.agentId!);
646
+ }}
647
+ sx={{
648
+ fontFamily: "'Space Grotesk', sans-serif",
649
+ fontWeight: 600,
650
+ fontSize: "0.85rem",
651
+ color: isLeadAgent ? colors.honey : colors.amber,
652
+ textDecoration: "none",
653
+ cursor: "pointer",
654
+ whiteSpace: "nowrap",
655
+ "&:hover": {
656
+ textDecoration: "underline",
657
+ color: isLeadAgent ? "#FFD699" : colors.honey,
658
+ },
659
+ }}
660
+ >
661
+ {message.agentName || "Agent"}
662
+ </Link>
663
+ ) : (
664
+ <Typography
665
+ sx={{
666
+ fontFamily: "'Space Grotesk', sans-serif",
667
+ fontWeight: 600,
668
+ fontSize: "0.85rem",
669
+ color: message.agentId ? (isLeadAgent ? colors.honey : colors.amber) : colors.blue,
670
+ whiteSpace: "nowrap",
671
+ }}
672
+ >
673
+ {message.agentName || "Human"}
674
+ </Typography>
675
+ )}
676
+
677
+ {/* Lead badge */}
678
+ {isLeadAgent && (
679
+ <Typography
680
+ sx={{
681
+ fontFamily: "'JetBrains Mono', monospace",
682
+ fontSize: "0.55rem",
683
+ fontWeight: 700,
684
+ color: colors.honey,
685
+ bgcolor: isDark ? "rgba(255, 184, 77, 0.15)" : "rgba(184, 115, 0, 0.1)",
686
+ px: 0.75,
687
+ py: 0.2,
688
+ borderRadius: "4px",
689
+ border: "1px solid",
690
+ borderColor: isDark ? "rgba(255, 184, 77, 0.3)" : "rgba(184, 115, 0, 0.25)",
691
+ letterSpacing: "0.05em",
692
+ }}
693
+ >
694
+ LEAD
695
+ </Typography>
696
+ )}
697
+
698
+ {/* Timestamp */}
699
+ <Typography
700
+ sx={{
701
+ fontFamily: "'JetBrains Mono', monospace",
702
+ fontSize: "0.7rem",
703
+ color: "text.tertiary",
704
+ letterSpacing: "0.02em",
705
+ }}
706
+ >
707
+ {formatSmartTime(message.createdAt)}
708
+ </Typography>
709
+
710
+ {/* Spacer */}
711
+ <Box sx={{ flex: 1 }} />
712
+
713
+ {/* Reply count badge - clickable to open thread */}
714
+ {!isThreadView && hasReplies && onOpenThread && (
715
+ <Box
716
+ component="button"
717
+ onClick={onOpenThread}
718
+ sx={{
719
+ fontFamily: "'JetBrains Mono', monospace",
720
+ fontSize: "0.65rem",
721
+ fontWeight: 600,
722
+ bgcolor: isDark ? "rgba(212, 165, 116, 0.15)" : "#FEF3C7",
723
+ color: isDark ? "#D4A574" : "#B45309",
724
+ border: "1px solid",
725
+ borderColor: isDark ? "rgba(212, 165, 116, 0.3)" : "#FCD34D",
726
+ borderRadius: "12px",
727
+ px: 1.5,
728
+ py: 0.25,
729
+ cursor: "pointer",
730
+ transition: "all 0.15s ease",
731
+ "&:hover": {
732
+ bgcolor: isDark ? "rgba(212, 165, 116, 0.25)" : "#FDE68A",
733
+ borderColor: isDark ? "rgba(212, 165, 116, 0.5)" : "#FBBF24",
734
+ },
735
+ }}
736
+ >
737
+ {threadCount} {threadCount === 1 ? "reply" : "replies"}
738
+ </Box>
739
+ )}
740
+
741
+ {/* Action icons - appear on hover */}
742
+ <Box className="action-icons" sx={{ display: "flex", gap: 0.5, opacity: 0, transition: "opacity 0.2s ease" }}>
743
+ {/* Toggle raw/markdown */}
744
+ <Tooltip title={showRaw ? "Show formatted" : "Show raw"} placement="top">
745
+ <IconButton
746
+ size="sm"
747
+ variant="plain"
748
+ onClick={toggleRaw}
749
+ sx={{
750
+ color: showRaw ? colors.amber : "text.tertiary",
751
+ fontSize: "0.75rem",
752
+ fontFamily: "'JetBrains Mono', monospace",
753
+ fontWeight: 600,
754
+ width: 28,
755
+ height: 28,
756
+ "&:hover": {
757
+ color: colors.amber,
758
+ bgcolor: colors.hoverBg,
759
+ },
760
+ }}
761
+ >
762
+ {showRaw ? "MD" : "</>"}
763
+ </IconButton>
764
+ </Tooltip>
765
+
766
+ {/* Copy button */}
767
+ <Tooltip title={copied ? "Copied!" : "Copy message"} placement="top">
768
+ <IconButton
769
+ size="sm"
770
+ variant="plain"
771
+ onClick={handleCopy}
772
+ sx={{
773
+ color: copied ? "#22C55E" : "text.tertiary",
774
+ fontSize: "0.9rem",
775
+ width: 28,
776
+ height: 28,
777
+ "&:hover": {
778
+ color: copied ? "#22C55E" : colors.amber,
779
+ bgcolor: colors.hoverBg,
780
+ },
781
+ }}
782
+ >
783
+ {copied ? "✓" : "⧉"}
784
+ </IconButton>
785
+ </Tooltip>
786
+
787
+ {/* Reply icon */}
788
+ {!isThreadView && onOpenThread && (
789
+ <Tooltip title="Open thread" placement="top">
790
+ <IconButton
791
+ size="sm"
792
+ variant="plain"
793
+ onClick={onOpenThread}
794
+ sx={{
795
+ color: "text.tertiary",
796
+ fontSize: "1rem",
797
+ width: 28,
798
+ height: 28,
799
+ "&:hover": {
800
+ color: colors.amber,
801
+ bgcolor: colors.hoverBg,
802
+ },
803
+ }}
804
+ >
805
+
806
+ </IconButton>
807
+ </Tooltip>
808
+ )}
809
+ </Box>
810
+ </Box>
811
+
812
+ {/* Message content */}
813
+ {showRaw ? (
814
+ <Typography
815
+ component="div"
816
+ sx={{
817
+ fontFamily: "'JetBrains Mono', monospace",
818
+ fontSize: "0.8rem",
819
+ color: "text.primary",
820
+ lineHeight: 1.5,
821
+ whiteSpace: "pre-wrap",
822
+ wordBreak: "break-word",
823
+ pl: 2.25,
824
+ bgcolor: isDark ? "rgba(0, 0, 0, 0.2)" : "rgba(0, 0, 0, 0.03)",
825
+ borderRadius: "6px",
826
+ p: 1.5,
827
+ ml: 2.25,
828
+ mr: 1,
829
+ }}
830
+ >
831
+ {message.content}
832
+ </Typography>
833
+ ) : (
834
+ <Box
835
+ sx={{
836
+ pl: 2.25,
837
+ fontFamily: "'Space Grotesk', sans-serif",
838
+ fontSize: "0.85rem",
839
+ color: "text.primary",
840
+ lineHeight: 1.6,
841
+ wordBreak: "break-word",
842
+ "& p": {
843
+ m: 0,
844
+ mb: 0.5,
845
+ "&:last-child": { mb: 0 },
846
+ },
847
+ "& h1, & h2, & h3, & h4, & h5, & h6": {
848
+ fontFamily: "'Space Grotesk', sans-serif",
849
+ fontWeight: 600,
850
+ mt: 1,
851
+ mb: 0.5,
852
+ color: colors.amber,
853
+ },
854
+ "& h1": { fontSize: "1.25rem" },
855
+ "& h2": { fontSize: "1.1rem" },
856
+ "& h3": { fontSize: "1rem" },
857
+ "& code": {
858
+ fontFamily: "'JetBrains Mono', monospace",
859
+ fontSize: "0.8rem",
860
+ bgcolor: isDark ? "rgba(0, 0, 0, 0.3)" : "rgba(0, 0, 0, 0.06)",
861
+ px: 0.5,
862
+ py: 0.25,
863
+ borderRadius: "4px",
864
+ },
865
+ "& pre": {
866
+ bgcolor: isDark ? "rgba(0, 0, 0, 0.3)" : "rgba(0, 0, 0, 0.06)",
867
+ borderRadius: "6px",
868
+ p: 1.5,
869
+ overflow: "auto",
870
+ my: 1,
871
+ "& code": {
872
+ bgcolor: "transparent",
873
+ p: 0,
874
+ },
875
+ },
876
+ "& ul": {
877
+ pl: 2.5,
878
+ my: 0.5,
879
+ listStyleType: "disc",
880
+ },
881
+ "& ol": {
882
+ pl: 2.5,
883
+ my: 0.5,
884
+ listStyleType: "decimal",
885
+ },
886
+ "& li": {
887
+ mb: 0.25,
888
+ display: "list-item",
889
+ },
890
+ "& blockquote": {
891
+ borderLeft: "3px solid",
892
+ borderColor: colors.amber,
893
+ pl: 1.5,
894
+ ml: 0,
895
+ my: 1,
896
+ color: "text.secondary",
897
+ fontStyle: "italic",
898
+ },
899
+ "& a": {
900
+ color: colors.amber,
901
+ textDecoration: "none",
902
+ "&:hover": {
903
+ textDecoration: "underline",
904
+ },
905
+ },
906
+ "& table": {
907
+ borderCollapse: "collapse",
908
+ my: 1,
909
+ fontSize: "0.8rem",
910
+ },
911
+ "& th, & td": {
912
+ border: "1px solid",
913
+ borderColor: "neutral.outlinedBorder",
914
+ px: 1,
915
+ py: 0.5,
916
+ },
917
+ "& th": {
918
+ bgcolor: isDark ? "rgba(0, 0, 0, 0.2)" : "rgba(0, 0, 0, 0.04)",
919
+ fontWeight: 600,
920
+ },
921
+ "& hr": {
922
+ border: "none",
923
+ borderTop: "1px solid",
924
+ borderColor: "neutral.outlinedBorder",
925
+ my: 1,
926
+ },
927
+ "& img": {
928
+ maxWidth: "100%",
929
+ borderRadius: "6px",
930
+ },
931
+ }}
932
+ >
933
+ <ReactMarkdown
934
+ remarkPlugins={[remarkGfm]}
935
+ components={markdownComponents}
936
+ >
937
+ {message.content}
938
+ </ReactMarkdown>
939
+ </Box>
940
+ )}
941
+ </Box>
942
+ );
943
+ }
944
+
945
+ interface ChatPanelProps {
946
+ selectedChannelId?: string | null;
947
+ selectedThreadId?: string | null;
948
+ onSelectChannel?: (channelId: string | null) => void;
949
+ onSelectThread?: (threadId: string | null) => void;
950
+ onNavigateToAgent?: (agentId: string) => void;
951
+ onNavigateToTask?: (taskId: string) => void;
952
+ }
953
+
954
+ export default function ChatPanel({
955
+ selectedChannelId: controlledChannelId,
956
+ selectedThreadId: controlledThreadId,
957
+ onSelectChannel,
958
+ onSelectThread,
959
+ onNavigateToAgent,
960
+ onNavigateToTask,
961
+ }: ChatPanelProps) {
962
+ // Internal state for uncontrolled mode
963
+ const [internalChannelId, setInternalChannelId] = useState<string | null>(null);
964
+ const [internalThreadId, setInternalThreadId] = useState<string | null>(null);
965
+ const [messageInput, setMessageInput] = useState("");
966
+ const [threadMessageInput, setThreadMessageInput] = useState("");
967
+ const [channelDrawerOpen, setChannelDrawerOpen] = useState(false);
968
+ const messagesEndRef = useRef<HTMLDivElement>(null);
969
+ const threadEndRef = useRef<HTMLDivElement>(null);
970
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
971
+ const previousNewestMessageIdRef = useRef<string | null>(null);
972
+ const scrollPositionRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
973
+
974
+ // Use controlled or internal state
975
+ const selectedChannelId = controlledChannelId !== undefined ? controlledChannelId : internalChannelId;
976
+ const selectedThreadId = controlledThreadId !== undefined ? controlledThreadId : internalThreadId;
977
+
978
+ const setSelectedChannelId = useCallback((id: string | null) => {
979
+ if (onSelectChannel) {
980
+ onSelectChannel(id);
981
+ } else {
982
+ setInternalChannelId(id);
983
+ }
984
+ }, [onSelectChannel]);
985
+
986
+ const setSelectedThreadId = useCallback((id: string | null) => {
987
+ if (onSelectThread) {
988
+ onSelectThread(id);
989
+ } else {
990
+ setInternalThreadId(id);
991
+ }
992
+ }, [onSelectThread]);
993
+
994
+ const { mode } = useColorScheme();
995
+ const isDark = mode === "dark";
996
+
997
+ const colors = useMemo(() => ({
998
+ amber: isDark ? "#F5A623" : "#D48806",
999
+ gold: isDark ? "#D4A574" : "#8B6914",
1000
+ honey: isDark ? "#FFB84D" : "#B87300",
1001
+ blue: "#3B82F6",
1002
+ dormant: isDark ? "#6B5344" : "#A89A7C",
1003
+ amberGlow: isDark ? "0 0 8px rgba(245, 166, 35, 0.5)" : "0 0 6px rgba(212, 136, 6, 0.3)",
1004
+ hoverBg: isDark ? "rgba(245, 166, 35, 0.05)" : "rgba(212, 136, 6, 0.05)",
1005
+ selectedBg: isDark ? "rgba(245, 166, 35, 0.1)" : "rgba(212, 136, 6, 0.08)",
1006
+ amberBorder: isDark ? "rgba(245, 166, 35, 0.3)" : "rgba(212, 136, 6, 0.25)",
1007
+ inputBg: isDark ? "rgba(13, 9, 6, 0.6)" : "rgba(255, 255, 255, 0.8)",
1008
+ inputBorder: isDark ? "#3A2D1F" : "#E5D9CA",
1009
+ }), [isDark]);
1010
+
1011
+ const { data: channels, isLoading: channelsLoading } = useChannels();
1012
+ const {
1013
+ data: messages,
1014
+ isLoading: messagesLoading,
1015
+ fetchNextPage,
1016
+ hasNextPage,
1017
+ isFetchingNextPage,
1018
+ } = useInfiniteMessages(selectedChannelId || "");
1019
+ const { data: threadMessages } = useThreadMessages(
1020
+ selectedChannelId || "",
1021
+ selectedThreadId || ""
1022
+ );
1023
+ const postMessageMutation = usePostMessage(selectedChannelId || "");
1024
+ const { data: agents } = useAgents();
1025
+ const agentsList = useMemo(() => agents || [], [agents]);
1026
+
1027
+ // Create name -> id mapping for @mention links
1028
+ const agentsByName = useMemo(() => {
1029
+ const map = new Map<string, string>();
1030
+ agents?.forEach((agent) => map.set(agent.name, agent.id));
1031
+ return map;
1032
+ }, [agents]);
1033
+
1034
+ // Create set of lead agent IDs
1035
+ const leadAgentIds = useMemo(() => {
1036
+ const set = new Set<string>();
1037
+ agents?.forEach((agent) => {
1038
+ if (agent.isLead) set.add(agent.id);
1039
+ });
1040
+ return set;
1041
+ }, [agents]);
1042
+
1043
+ // Track mentions for main and thread inputs
1044
+ const [messageMentions, setMessageMentions] = useState<string[]>([]);
1045
+ const [threadMentions, setThreadMentions] = useState<string[]>([]);
1046
+
1047
+ const selectedChannel = channels?.find((c) => c.id === selectedChannelId);
1048
+
1049
+ // Find thread message from messages
1050
+ const selectedThreadMessage = useMemo(() => {
1051
+ if (!selectedThreadId || !messages) return null;
1052
+ return messages.find((m) => m.id === selectedThreadId) || null;
1053
+ }, [selectedThreadId, messages]);
1054
+
1055
+ // Auto-select first channel only if no channel is selected
1056
+ useEffect(() => {
1057
+ if (channels && channels.length > 0 && !selectedChannelId) {
1058
+ const firstChannel = channels[0];
1059
+ if (firstChannel) {
1060
+ setSelectedChannelId(firstChannel.id);
1061
+ }
1062
+ }
1063
+ }, [channels, selectedChannelId, setSelectedChannelId]);
1064
+
1065
+ // Reset scroll tracking when channel changes
1066
+ useEffect(() => {
1067
+ previousNewestMessageIdRef.current = null;
1068
+ scrollPositionRef.current = null;
1069
+ }, [selectedChannelId]);
1070
+
1071
+ // Get the newest message ID (last in the sorted array)
1072
+ const newestMessageId = messages?.[messages.length - 1]?.id ?? null;
1073
+
1074
+ // Scroll to bottom only when a NEW message arrives AND user is near the bottom
1075
+ useEffect(() => {
1076
+ // If the newest message ID changed, a new message arrived
1077
+ if (newestMessageId && newestMessageId !== previousNewestMessageIdRef.current) {
1078
+ const isInitialLoad = previousNewestMessageIdRef.current === null;
1079
+ previousNewestMessageIdRef.current = newestMessageId;
1080
+
1081
+ const container = messagesContainerRef.current;
1082
+
1083
+ if (isInitialLoad) {
1084
+ // On initial load, scroll immediately without animation
1085
+ messagesEndRef.current?.scrollIntoView();
1086
+ } else if (container) {
1087
+ // Check if user is near the bottom (within 150px)
1088
+ const isNearBottom =
1089
+ container.scrollHeight - container.scrollTop - container.clientHeight < 150;
1090
+
1091
+ if (isNearBottom) {
1092
+ // User is near bottom, scroll to show new message
1093
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
1094
+ }
1095
+ // If user is scrolled up, don't auto-scroll - let them read in peace
1096
+ }
1097
+ }
1098
+ }, [newestMessageId]);
1099
+
1100
+ // Preserve scroll position when loading older messages
1101
+ useEffect(() => {
1102
+ const container = messagesContainerRef.current;
1103
+ if (!container || !scrollPositionRef.current) return;
1104
+
1105
+ // Restore scroll position after older messages are prepended
1106
+ const { scrollTop, scrollHeight: oldScrollHeight } = scrollPositionRef.current;
1107
+ const newScrollHeight = container.scrollHeight;
1108
+ const heightDiff = newScrollHeight - oldScrollHeight;
1109
+
1110
+ if (heightDiff > 0) {
1111
+ container.scrollTop = scrollTop + heightDiff;
1112
+ }
1113
+
1114
+ scrollPositionRef.current = null;
1115
+ }, [messages]);
1116
+
1117
+ // Scroll to bottom of thread when thread opens or messages change
1118
+ useEffect(() => {
1119
+ if (selectedThreadId && threadMessages) {
1120
+ setTimeout(() => {
1121
+ threadEndRef.current?.scrollIntoView({ behavior: "smooth" });
1122
+ }, 100);
1123
+ }
1124
+ }, [selectedThreadId, threadMessages]);
1125
+
1126
+ // Count replies per message
1127
+ const replyCounts = new Map<string, number>();
1128
+ messages?.forEach((msg) => {
1129
+ if (msg.replyToId) {
1130
+ replyCounts.set(msg.replyToId, (replyCounts.get(msg.replyToId) || 0) + 1);
1131
+ }
1132
+ });
1133
+
1134
+ // Filter out threaded replies from main view (only show top-level messages)
1135
+ const topLevelMessages = messages?.filter((msg) => !msg.replyToId) || [];
1136
+
1137
+ const handleSendMessage = useCallback(() => {
1138
+ if (!messageInput.trim() || !selectedChannelId) return;
1139
+
1140
+ postMessageMutation.mutate({
1141
+ content: messageInput.trim(),
1142
+ mentions: messageMentions.length > 0 ? messageMentions : undefined,
1143
+ });
1144
+ setMessageInput("");
1145
+ setMessageMentions([]);
1146
+ }, [messageInput, selectedChannelId, postMessageMutation, messageMentions]);
1147
+
1148
+ const handleSendThreadMessage = useCallback(() => {
1149
+ if (!threadMessageInput.trim() || !selectedChannelId || !selectedThreadMessage) return;
1150
+
1151
+ postMessageMutation.mutate({
1152
+ content: threadMessageInput.trim(),
1153
+ replyToId: selectedThreadMessage.id,
1154
+ mentions: threadMentions.length > 0 ? threadMentions : undefined,
1155
+ });
1156
+ setThreadMessageInput("");
1157
+ setThreadMentions([]);
1158
+ }, [threadMessageInput, selectedChannelId, selectedThreadMessage, postMessageMutation, threadMentions]);
1159
+
1160
+ const handleOpenThread = useCallback((message: ChannelMessage) => {
1161
+ setSelectedThreadId(message.id);
1162
+ }, [setSelectedThreadId]);
1163
+
1164
+ const handleCloseThread = useCallback(() => {
1165
+ setSelectedThreadId(null);
1166
+ }, [setSelectedThreadId]);
1167
+
1168
+ const handleAgentClick = useCallback((agentId: string) => {
1169
+ if (onNavigateToAgent) {
1170
+ onNavigateToAgent(agentId);
1171
+ }
1172
+ }, [onNavigateToAgent]);
1173
+
1174
+ // Input styles shared between main and thread (memoized to prevent re-renders)
1175
+ const inputStyles = useMemo(() => ({
1176
+ flex: 1,
1177
+ fontFamily: "'Space Grotesk', sans-serif",
1178
+ fontSize: "16px", // 16px prevents iOS zoom on focus
1179
+ bgcolor: colors.inputBg,
1180
+ borderColor: colors.inputBorder,
1181
+ borderRadius: "8px",
1182
+ "--Textarea-focusedThickness": "2px",
1183
+ "--Textarea-focusedHighlight": colors.amber,
1184
+ "&:hover": {
1185
+ borderColor: isDark ? "#4A3A2F" : "#D1C5B4",
1186
+ },
1187
+ "&:focus-within": {
1188
+ borderColor: colors.amber,
1189
+ boxShadow: isDark ? "0 0 0 2px rgba(245, 166, 35, 0.15)" : "0 0 0 2px rgba(212, 136, 6, 0.1)",
1190
+ },
1191
+ "& textarea": {
1192
+ fontFamily: "'Space Grotesk', sans-serif",
1193
+ fontSize: "16px", // 16px prevents iOS zoom on focus
1194
+ color: isDark ? "#FFF8E7" : "#1A130E",
1195
+ },
1196
+ "& textarea::placeholder": {
1197
+ color: isDark ? "#8B7355" : "#8B7355",
1198
+ fontFamily: "'Space Grotesk', sans-serif",
1199
+ },
1200
+ }), [colors.inputBg, colors.inputBorder, colors.amber, isDark]);
1201
+
1202
+ const sendButtonStyles = useMemo(() => ({
1203
+ fontFamily: "'Space Grotesk', sans-serif",
1204
+ fontSize: "0.9rem",
1205
+ fontWeight: 600,
1206
+ letterSpacing: "0.03em",
1207
+ px: 3,
1208
+ py: 1.5,
1209
+ minHeight: 44, // Good touch target size
1210
+ borderRadius: "8px",
1211
+ bgcolor: colors.amber,
1212
+ color: isDark ? "#1A130E" : "#FFFFFF",
1213
+ border: "none",
1214
+ cursor: "pointer",
1215
+ transition: "all 0.2s ease",
1216
+ "&:hover": {
1217
+ bgcolor: colors.honey,
1218
+ transform: "translateY(-1px)",
1219
+ boxShadow: isDark ? "0 4px 12px rgba(245, 166, 35, 0.3)" : "0 4px 12px rgba(212, 136, 6, 0.2)",
1220
+ },
1221
+ "&:active": {
1222
+ transform: "translateY(0)",
1223
+ },
1224
+ "&:disabled": {
1225
+ opacity: 0.5,
1226
+ cursor: "not-allowed",
1227
+ transform: "none",
1228
+ boxShadow: "none",
1229
+ },
1230
+ }), [colors.amber, colors.honey, isDark]);
1231
+
1232
+ // Channel list content - reused in drawer and desktop sidebar
1233
+ const channelListContent = (
1234
+ <Box sx={{ flex: 1, overflow: "auto", p: 1 }}>
1235
+ {channelsLoading ? (
1236
+ <Typography sx={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: "0.8rem", color: "text.tertiary", p: 1.5 }}>
1237
+ Loading...
1238
+ </Typography>
1239
+ ) : !channels || channels.length === 0 ? (
1240
+ <Typography sx={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: "0.8rem", color: "text.tertiary", p: 1.5 }}>
1241
+ No channels
1242
+ </Typography>
1243
+ ) : (
1244
+ channels.map((channel) => (
1245
+ <Box
1246
+ key={channel.id}
1247
+ onClick={() => {
1248
+ setSelectedChannelId(channel.id);
1249
+ setSelectedThreadId(null);
1250
+ setChannelDrawerOpen(false);
1251
+ }}
1252
+ sx={{
1253
+ px: 1.5,
1254
+ py: 1.5,
1255
+ borderRadius: "6px",
1256
+ cursor: "pointer",
1257
+ bgcolor: selectedChannelId === channel.id ? colors.selectedBg : "transparent",
1258
+ border: "1px solid",
1259
+ borderColor: selectedChannelId === channel.id ? colors.amberBorder : "transparent",
1260
+ transition: "all 0.15s ease",
1261
+ mb: 0.5,
1262
+ minHeight: 44,
1263
+ display: "flex",
1264
+ alignItems: "center",
1265
+ "&:hover": {
1266
+ bgcolor: selectedChannelId === channel.id ? colors.selectedBg : colors.hoverBg,
1267
+ },
1268
+ }}
1269
+ >
1270
+ <Typography
1271
+ sx={{
1272
+ fontFamily: "'JetBrains Mono', monospace",
1273
+ fontSize: "0.8rem",
1274
+ fontWeight: selectedChannelId === channel.id ? 600 : 400,
1275
+ color: selectedChannelId === channel.id ? colors.amber : "text.secondary",
1276
+ }}
1277
+ >
1278
+ # {channel.name}
1279
+ </Typography>
1280
+ </Box>
1281
+ ))
1282
+ )}
1283
+ </Box>
1284
+ );
1285
+
1286
+ return (
1287
+ <Card
1288
+ variant="outlined"
1289
+ sx={{
1290
+ p: 0,
1291
+ height: "100%",
1292
+ display: "flex",
1293
+ flexDirection: "row",
1294
+ overflow: "hidden",
1295
+ bgcolor: "background.surface",
1296
+ borderColor: "neutral.outlinedBorder",
1297
+ borderRadius: { xs: 0, md: "12px" },
1298
+ gap: 0,
1299
+ }}
1300
+ >
1301
+ {/* Mobile Channel Drawer */}
1302
+ <Drawer
1303
+ open={channelDrawerOpen}
1304
+ onClose={() => setChannelDrawerOpen(false)}
1305
+ sx={{
1306
+ display: { xs: "block", md: "none" },
1307
+ "& .MuiDrawer-content": {
1308
+ width: 280,
1309
+ bgcolor: isDark ? "#1A130E" : "#FDF8F3",
1310
+ },
1311
+ }}
1312
+ >
1313
+ <Box
1314
+ sx={{
1315
+ height: "100%",
1316
+ display: "flex",
1317
+ flexDirection: "column",
1318
+ }}
1319
+ >
1320
+ {/* Drawer header */}
1321
+ <Box
1322
+ sx={{
1323
+ px: 2,
1324
+ py: 1.5,
1325
+ borderBottom: "1px solid",
1326
+ borderColor: "neutral.outlinedBorder",
1327
+ bgcolor: "background.level1",
1328
+ display: "flex",
1329
+ alignItems: "center",
1330
+ justifyContent: "space-between",
1331
+ minHeight: 56,
1332
+ }}
1333
+ >
1334
+ <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
1335
+ <Box
1336
+ sx={{
1337
+ width: 8,
1338
+ height: 10,
1339
+ clipPath: "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)",
1340
+ bgcolor: colors.amber,
1341
+ boxShadow: colors.amberGlow,
1342
+ }}
1343
+ />
1344
+ <Typography
1345
+ sx={{
1346
+ fontFamily: "'Space Grotesk', sans-serif",
1347
+ fontWeight: 600,
1348
+ color: colors.amber,
1349
+ letterSpacing: "0.05em",
1350
+ fontSize: "0.85rem",
1351
+ }}
1352
+ >
1353
+ CHANNELS
1354
+ </Typography>
1355
+ </Box>
1356
+ <IconButton
1357
+ size="sm"
1358
+ variant="plain"
1359
+ onClick={() => setChannelDrawerOpen(false)}
1360
+ sx={{
1361
+ color: "text.tertiary",
1362
+ minWidth: 44,
1363
+ minHeight: 44,
1364
+ "&:hover": { color: "text.primary", bgcolor: colors.hoverBg },
1365
+ }}
1366
+ >
1367
+
1368
+ </IconButton>
1369
+ </Box>
1370
+ {channelListContent}
1371
+ </Box>
1372
+ </Drawer>
1373
+
1374
+ {/* Desktop Channel List - Fixed width, hidden on mobile */}
1375
+ <Box
1376
+ sx={{
1377
+ width: 220,
1378
+ flexShrink: 0,
1379
+ borderRight: "1px solid",
1380
+ borderColor: "neutral.outlinedBorder",
1381
+ display: { xs: "none", md: "flex" },
1382
+ flexDirection: "column",
1383
+ overflow: "hidden",
1384
+ bgcolor: isDark ? "rgba(13, 9, 6, 0.3)" : "rgba(245, 237, 228, 0.5)",
1385
+ }}
1386
+ >
1387
+ {/* Channels header */}
1388
+ <Box
1389
+ sx={{
1390
+ px: 2,
1391
+ py: 1.5,
1392
+ borderBottom: "1px solid",
1393
+ borderColor: "neutral.outlinedBorder",
1394
+ bgcolor: "background.level1",
1395
+ height: 64,
1396
+ display: "flex",
1397
+ alignItems: "center",
1398
+ }}
1399
+ >
1400
+ <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
1401
+ <Box
1402
+ sx={{
1403
+ width: 8,
1404
+ height: 10,
1405
+ clipPath: "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)",
1406
+ bgcolor: colors.amber,
1407
+ boxShadow: colors.amberGlow,
1408
+ }}
1409
+ />
1410
+ <Typography
1411
+ sx={{
1412
+ fontFamily: "'Space Grotesk', sans-serif",
1413
+ fontWeight: 600,
1414
+ color: colors.amber,
1415
+ letterSpacing: "0.05em",
1416
+ fontSize: "0.85rem",
1417
+ }}
1418
+ >
1419
+ CHANNELS
1420
+ </Typography>
1421
+ </Box>
1422
+ </Box>
1423
+ {channelListContent}
1424
+ </Box>
1425
+
1426
+ {/* Messages Panel - Flex, equal split with thread, hidden when thread is open on mobile */}
1427
+ <Box
1428
+ sx={{
1429
+ flex: 1,
1430
+ display: {
1431
+ xs: selectedThreadMessage ? "none" : "flex",
1432
+ md: "flex",
1433
+ },
1434
+ flexDirection: "column",
1435
+ overflow: "hidden",
1436
+ minWidth: 0,
1437
+ }}
1438
+ >
1439
+ {/* Channel header with title and description */}
1440
+ <Box
1441
+ sx={{
1442
+ px: { xs: 1.5, md: 2.5 },
1443
+ py: 1.5,
1444
+ borderBottom: "1px solid",
1445
+ borderColor: "neutral.outlinedBorder",
1446
+ bgcolor: "background.level1",
1447
+ height: { xs: 56, md: 64 },
1448
+ display: "flex",
1449
+ alignItems: "center",
1450
+ gap: 1,
1451
+ }}
1452
+ >
1453
+ {/* Mobile hamburger menu */}
1454
+ <IconButton
1455
+ size="sm"
1456
+ variant="plain"
1457
+ onClick={() => setChannelDrawerOpen(true)}
1458
+ sx={{
1459
+ display: { xs: "flex", md: "none" },
1460
+ color: colors.amber,
1461
+ minWidth: 44,
1462
+ minHeight: 44,
1463
+ "&:hover": { bgcolor: colors.hoverBg },
1464
+ }}
1465
+ >
1466
+
1467
+ </IconButton>
1468
+
1469
+ <Box sx={{ flex: 1, minWidth: 0 }}>
1470
+ <Typography
1471
+ sx={{
1472
+ fontFamily: "'Space Grotesk', sans-serif",
1473
+ fontWeight: 600,
1474
+ fontSize: { xs: "0.9rem", md: "1rem" },
1475
+ color: "text.primary",
1476
+ mb: 0.25,
1477
+ overflow: "hidden",
1478
+ textOverflow: "ellipsis",
1479
+ whiteSpace: "nowrap",
1480
+ }}
1481
+ >
1482
+ # {selectedChannel?.name || "Select a channel"}
1483
+ </Typography>
1484
+ {selectedChannel?.description && (
1485
+ <Typography
1486
+ sx={{
1487
+ fontFamily: "'Space Grotesk', sans-serif",
1488
+ fontSize: "0.7rem",
1489
+ color: "text.tertiary",
1490
+ lineHeight: 1.4,
1491
+ display: { xs: "none", sm: "block" },
1492
+ overflow: "hidden",
1493
+ textOverflow: "ellipsis",
1494
+ whiteSpace: "nowrap",
1495
+ }}
1496
+ >
1497
+ {selectedChannel.description}
1498
+ </Typography>
1499
+ )}
1500
+ </Box>
1501
+ </Box>
1502
+
1503
+ {/* Messages list */}
1504
+ <Box
1505
+ ref={messagesContainerRef}
1506
+ sx={{
1507
+ flex: 1,
1508
+ overflow: "auto",
1509
+ py: 1,
1510
+ bgcolor: isDark ? "rgba(13, 9, 6, 0.2)" : "rgba(253, 248, 243, 0.5)",
1511
+ }}
1512
+ >
1513
+ {messagesLoading ? (
1514
+ <Typography sx={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: "0.85rem", color: "text.tertiary", p: 3 }}>
1515
+ Loading messages...
1516
+ </Typography>
1517
+ ) : topLevelMessages.length === 0 ? (
1518
+ <Box sx={{ p: 3, textAlign: "center" }}>
1519
+ <Typography sx={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: "0.9rem", color: "text.tertiary", mb: 1 }}>
1520
+ No messages yet
1521
+ </Typography>
1522
+ <Typography sx={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: "0.8rem", color: "text.tertiary" }}>
1523
+ Start the conversation!
1524
+ </Typography>
1525
+ </Box>
1526
+ ) : (
1527
+ <>
1528
+ {/* Load more button */}
1529
+ {hasNextPage && (
1530
+ <Box sx={{ display: "flex", justifyContent: "center", py: 2 }}>
1531
+ <Box
1532
+ component="button"
1533
+ onClick={() => {
1534
+ // Save scroll position before loading older messages
1535
+ const container = messagesContainerRef.current;
1536
+ if (container) {
1537
+ scrollPositionRef.current = {
1538
+ scrollTop: container.scrollTop,
1539
+ scrollHeight: container.scrollHeight,
1540
+ };
1541
+ }
1542
+ fetchNextPage();
1543
+ }}
1544
+ disabled={isFetchingNextPage}
1545
+ sx={{
1546
+ fontFamily: "'Space Grotesk', sans-serif",
1547
+ fontSize: "0.8rem",
1548
+ fontWeight: 600,
1549
+ color: colors.amber,
1550
+ bgcolor: "transparent",
1551
+ border: "1px solid",
1552
+ borderColor: colors.amberBorder,
1553
+ borderRadius: "6px",
1554
+ px: 3,
1555
+ py: 1,
1556
+ cursor: isFetchingNextPage ? "wait" : "pointer",
1557
+ transition: "all 0.2s ease",
1558
+ opacity: isFetchingNextPage ? 0.6 : 1,
1559
+ "&:hover": {
1560
+ bgcolor: colors.hoverBg,
1561
+ borderColor: colors.amber,
1562
+ },
1563
+ }}
1564
+ >
1565
+ {isFetchingNextPage ? "Loading..." : "Load older messages"}
1566
+ </Box>
1567
+ </Box>
1568
+ )}
1569
+
1570
+ {/* Messages with date dividers */}
1571
+ {(() => {
1572
+ let lastDateKey = "";
1573
+ return topLevelMessages.map((message) => {
1574
+ const dateKey = getDateKey(message.createdAt);
1575
+ const showDivider = dateKey !== lastDateKey;
1576
+ lastDateKey = dateKey;
1577
+ return (
1578
+ <React.Fragment key={message.id}>
1579
+ {showDivider && (
1580
+ <DateDivider date={message.createdAt} isDark={isDark} colors={colors} />
1581
+ )}
1582
+ <MessageItem
1583
+ message={message}
1584
+ isDark={isDark}
1585
+ colors={colors}
1586
+ onOpenThread={() => handleOpenThread(message)}
1587
+ threadCount={replyCounts.get(message.id)}
1588
+ onAgentClick={handleAgentClick}
1589
+ onTaskClick={onNavigateToTask}
1590
+ isSelected={selectedThreadMessage?.id === message.id}
1591
+ agentsByName={agentsByName}
1592
+ isLeadAgent={message.agentId ? leadAgentIds.has(message.agentId) : false}
1593
+ />
1594
+ </React.Fragment>
1595
+ );
1596
+ });
1597
+ })()}
1598
+ <div ref={messagesEndRef} />
1599
+ </>
1600
+ )}
1601
+ </Box>
1602
+
1603
+ {/* Message input */}
1604
+ <Box
1605
+ sx={{
1606
+ p: { xs: 1.5, md: 2 },
1607
+ borderTop: "1px solid",
1608
+ borderColor: "neutral.outlinedBorder",
1609
+ bgcolor: "background.level1",
1610
+ }}
1611
+ >
1612
+ <MentionInput
1613
+ value={messageInput}
1614
+ onChange={setMessageInput}
1615
+ onSend={handleSendMessage}
1616
+ onMentionsChange={setMessageMentions}
1617
+ placeholder="Type a message... (use @ to mention)"
1618
+ agents={agentsList}
1619
+ inputStyles={inputStyles}
1620
+ sendButtonStyles={sendButtonStyles}
1621
+ sendLabel="Send"
1622
+ disabled={!messageInput.trim() || postMessageMutation.isPending}
1623
+ colors={colors}
1624
+ isDark={isDark}
1625
+ />
1626
+ </Box>
1627
+ </Box>
1628
+
1629
+ {/* Thread Panel - Full screen on mobile, equal width on desktop */}
1630
+ {selectedThreadMessage && (
1631
+ <Box
1632
+ sx={{
1633
+ position: { xs: "fixed", md: "relative" },
1634
+ inset: { xs: 0, md: "auto" },
1635
+ zIndex: { xs: 1300, md: "auto" },
1636
+ flex: { xs: "none", md: 1 },
1637
+ width: { xs: "100%", md: "auto" },
1638
+ minWidth: 0,
1639
+ borderLeft: { xs: "none", md: "1px solid" },
1640
+ borderColor: "neutral.outlinedBorder",
1641
+ display: "flex",
1642
+ flexDirection: "column",
1643
+ overflow: "hidden",
1644
+ bgcolor: isDark ? "#1A130E" : "#FDF8F3",
1645
+ }}
1646
+ >
1647
+ {/* Thread header */}
1648
+ <Box
1649
+ sx={{
1650
+ px: { xs: 1.5, md: 2.5 },
1651
+ py: 1.5,
1652
+ borderBottom: "1px solid",
1653
+ borderColor: "neutral.outlinedBorder",
1654
+ bgcolor: "background.level1",
1655
+ display: "flex",
1656
+ alignItems: "center",
1657
+ justifyContent: "space-between",
1658
+ height: { xs: 56, md: 64 },
1659
+ }}
1660
+ >
1661
+ <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
1662
+ {/* Mobile back button */}
1663
+ <IconButton
1664
+ size="sm"
1665
+ variant="plain"
1666
+ onClick={handleCloseThread}
1667
+ sx={{
1668
+ display: { xs: "flex", md: "none" },
1669
+ color: colors.gold,
1670
+ minWidth: 44,
1671
+ minHeight: 44,
1672
+ "&:hover": { bgcolor: colors.hoverBg },
1673
+ }}
1674
+ >
1675
+
1676
+ </IconButton>
1677
+ <Box
1678
+ sx={{
1679
+ width: 8,
1680
+ height: 10,
1681
+ clipPath: "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)",
1682
+ bgcolor: colors.gold,
1683
+ boxShadow: isDark ? "0 0 6px rgba(212, 165, 116, 0.4)" : "0 0 4px rgba(139, 105, 20, 0.3)",
1684
+ display: { xs: "none", md: "block" },
1685
+ }}
1686
+ />
1687
+ <Typography
1688
+ sx={{
1689
+ fontFamily: "'Space Grotesk', sans-serif",
1690
+ fontWeight: 600,
1691
+ fontSize: "0.85rem",
1692
+ color: colors.gold,
1693
+ letterSpacing: "0.05em",
1694
+ }}
1695
+ >
1696
+ THREAD
1697
+ </Typography>
1698
+ </Box>
1699
+ {/* Desktop close button */}
1700
+ <Tooltip title="Close thread" placement="bottom">
1701
+ <IconButton
1702
+ size="sm"
1703
+ variant="plain"
1704
+ onClick={handleCloseThread}
1705
+ sx={{
1706
+ display: { xs: "none", md: "flex" },
1707
+ color: "text.tertiary",
1708
+ fontSize: "1.1rem",
1709
+ "&:hover": {
1710
+ color: "text.primary",
1711
+ bgcolor: colors.hoverBg,
1712
+ },
1713
+ }}
1714
+ >
1715
+
1716
+ </IconButton>
1717
+ </Tooltip>
1718
+ </Box>
1719
+
1720
+ {/* Original message */}
1721
+ <Box
1722
+ sx={{
1723
+ borderBottom: "1px solid",
1724
+ borderColor: "neutral.outlinedBorder",
1725
+ bgcolor: isDark ? "rgba(245, 166, 35, 0.03)" : "rgba(212, 136, 6, 0.02)",
1726
+ maxHeight: "30vh",
1727
+ overflow: "auto",
1728
+ }}
1729
+ >
1730
+ <MessageItem
1731
+ message={selectedThreadMessage}
1732
+ isDark={isDark}
1733
+ colors={colors}
1734
+ isThreadView
1735
+ onAgentClick={handleAgentClick}
1736
+ onTaskClick={onNavigateToTask}
1737
+ agentsByName={agentsByName}
1738
+ isLeadAgent={selectedThreadMessage.agentId ? leadAgentIds.has(selectedThreadMessage.agentId) : false}
1739
+ />
1740
+ </Box>
1741
+
1742
+ {/* Thread divider */}
1743
+ <Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 2 }}>
1744
+ <Box sx={{ flex: 1, height: 1, bgcolor: "neutral.outlinedBorder" }} />
1745
+ <Typography
1746
+ sx={{
1747
+ fontFamily: "'JetBrains Mono', monospace",
1748
+ fontSize: "0.65rem",
1749
+ color: "text.tertiary",
1750
+ letterSpacing: "0.05em",
1751
+ }}
1752
+ >
1753
+ {threadMessages?.length || 0} {(threadMessages?.length || 0) === 1 ? "REPLY" : "REPLIES"}
1754
+ </Typography>
1755
+ <Box sx={{ flex: 1, height: 1, bgcolor: "neutral.outlinedBorder" }} />
1756
+ </Box>
1757
+
1758
+ {/* Thread replies */}
1759
+ <Box
1760
+ sx={{
1761
+ flex: 1,
1762
+ overflow: "auto",
1763
+ py: 0.5,
1764
+ }}
1765
+ >
1766
+ {threadMessages && threadMessages.length > 0 ? (
1767
+ <>
1768
+ {threadMessages.map((message) => (
1769
+ <MessageItem
1770
+ key={message.id}
1771
+ message={message}
1772
+ isDark={isDark}
1773
+ colors={colors}
1774
+ isThreadView
1775
+ onAgentClick={handleAgentClick}
1776
+ onTaskClick={onNavigateToTask}
1777
+ agentsByName={agentsByName}
1778
+ isLeadAgent={message.agentId ? leadAgentIds.has(message.agentId) : false}
1779
+ />
1780
+ ))}
1781
+ <div ref={threadEndRef} />
1782
+ </>
1783
+ ) : (
1784
+ <Box sx={{ p: 3, textAlign: "center" }}>
1785
+ <Typography sx={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: "0.85rem", color: "text.tertiary" }}>
1786
+ No replies yet
1787
+ </Typography>
1788
+ </Box>
1789
+ )}
1790
+ </Box>
1791
+
1792
+ {/* Thread message input */}
1793
+ <Box
1794
+ sx={{
1795
+ p: { xs: 1.5, md: 2 },
1796
+ borderTop: "1px solid",
1797
+ borderColor: "neutral.outlinedBorder",
1798
+ bgcolor: "background.level1",
1799
+ }}
1800
+ >
1801
+ <MentionInput
1802
+ value={threadMessageInput}
1803
+ onChange={setThreadMessageInput}
1804
+ onSend={handleSendThreadMessage}
1805
+ onMentionsChange={setThreadMentions}
1806
+ placeholder="Reply to thread... (use @ to mention)"
1807
+ agents={agentsList}
1808
+ inputStyles={inputStyles}
1809
+ sendButtonStyles={sendButtonStyles}
1810
+ sendLabel="Reply"
1811
+ disabled={!threadMessageInput.trim() || postMessageMutation.isPending}
1812
+ colors={colors}
1813
+ isDark={isDark}
1814
+ />
1815
+ </Box>
1816
+ </Box>
1817
+ )}
1818
+ </Card>
1819
+ );
1820
+ }