agent-relay 2.0.6 → 2.0.7

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 (145) hide show
  1. package/deploy/workspace/entrypoint.sh +80 -22
  2. package/deploy/workspace/gh-credential-relay +90 -0
  3. package/dist/dashboard/out/404.html +1 -1
  4. package/{packages/dashboard/ui-dist/_next/static/chunks/677-7323947c23b35979.js → dist/dashboard/out/_next/static/chunks/320-22ebe7be58cf982a.js} +1 -1
  5. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-9914652442f7e4fb.js +1 -0
  6. package/{packages/dashboard/ui-dist/_next/static/chunks/app/app/page-2e525b1dcc790967.js → dist/dashboard/out/_next/static/chunks/app/app/page-9d6bc8729b429956.js} +1 -1
  7. package/{packages/dashboard/ui-dist/_next/static/chunks/app/cloud/link/page-5011ae044b90449d.js → dist/dashboard/out/_next/static/chunks/app/cloud/link/page-fa1d5842aa90e8a6.js} +1 -1
  8. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-113060009ef35bc2.js +1 -0
  9. package/{packages/dashboard/ui-dist/_next/static/chunks/app/history/page-b2ce7c96ed0931da.js → dist/dashboard/out/_next/static/chunks/app/history/page-9965d2483011b846.js} +1 -1
  10. package/dist/dashboard/out/_next/static/chunks/app/layout-6b91e33784c20610.js +1 -0
  11. package/{packages/dashboard/ui-dist/_next/static/chunks/app/login/page-6ec54eee75877971.js → dist/dashboard/out/_next/static/chunks/app/login/page-a0ca6f7ca6a100b8.js} +1 -1
  12. package/{packages/dashboard/ui-dist/_next/static/chunks/app/metrics/page-bf2cb1e5915bc92d.js → dist/dashboard/out/_next/static/chunks/app/metrics/page-1e37ef8e73940b40.js} +1 -1
  13. package/dist/dashboard/out/_next/static/chunks/app/{page-4e64923d73c35bc9.js → page-487fa38f041815c1.js} +1 -1
  14. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-9db3ebdfa567a7c9.js +1 -0
  15. package/dist/dashboard/out/_next/static/chunks/app/providers/page-bcf46064ac4474ce.js +1 -0
  16. package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/{page-84161c802b020a1f.js → page-4dbe33f0f7691b7c.js} +1 -1
  17. package/dist/dashboard/out/_next/static/chunks/app/signup/{page-18a4665665f6be11.js → page-1ede2205b58649ca.js} +1 -1
  18. package/dist/dashboard/out/_next/static/chunks/main-app-fdbeb09028f57c9f.js +1 -0
  19. package/dist/dashboard/out/app/onboarding.html +1 -1
  20. package/dist/dashboard/out/app/onboarding.txt +2 -2
  21. package/dist/dashboard/out/app.html +1 -1
  22. package/dist/dashboard/out/app.txt +2 -2
  23. package/dist/dashboard/out/cloud/link.html +1 -1
  24. package/dist/dashboard/out/cloud/link.txt +2 -2
  25. package/dist/dashboard/out/connect-repos.html +1 -1
  26. package/dist/dashboard/out/connect-repos.txt +2 -2
  27. package/dist/dashboard/out/history.html +1 -1
  28. package/dist/dashboard/out/history.txt +2 -2
  29. package/dist/dashboard/out/index.html +1 -1
  30. package/dist/dashboard/out/index.txt +2 -2
  31. package/dist/dashboard/out/login.html +2 -2
  32. package/dist/dashboard/out/login.txt +2 -2
  33. package/dist/dashboard/out/metrics.html +1 -1
  34. package/dist/dashboard/out/metrics.txt +2 -2
  35. package/dist/dashboard/out/pricing.html +2 -2
  36. package/dist/dashboard/out/pricing.txt +2 -2
  37. package/dist/dashboard/out/providers/setup/claude.html +1 -1
  38. package/dist/dashboard/out/providers/setup/claude.txt +2 -2
  39. package/dist/dashboard/out/providers/setup/codex.html +1 -1
  40. package/dist/dashboard/out/providers/setup/codex.txt +2 -2
  41. package/dist/dashboard/out/providers/setup/cursor.html +1 -1
  42. package/dist/dashboard/out/providers/setup/cursor.txt +2 -2
  43. package/dist/dashboard/out/providers.html +1 -1
  44. package/dist/dashboard/out/providers.txt +2 -2
  45. package/dist/dashboard/out/signup.html +2 -2
  46. package/dist/dashboard/out/signup.txt +2 -2
  47. package/package.json +14 -14
  48. package/packages/api-types/package.json +1 -1
  49. package/packages/bridge/dist/spawner.js +9 -0
  50. package/packages/bridge/package.json +7 -7
  51. package/packages/cloud/dist/server.js +3 -3
  52. package/packages/cloud/package.json +6 -6
  53. package/packages/config/package.json +2 -2
  54. package/packages/continuity/package.json +1 -1
  55. package/packages/daemon/package.json +12 -12
  56. package/packages/dashboard/dist/server.js +9 -9
  57. package/packages/dashboard/package.json +12 -12
  58. package/packages/dashboard/ui/react-components/App.tsx +21 -435
  59. package/packages/dashboard/ui/react-components/ChannelChat.tsx +29 -75
  60. package/packages/dashboard/ui/react-components/MessageComposer.tsx +457 -0
  61. package/packages/dashboard/ui-dist/404.html +1 -1
  62. package/packages/dashboard/ui-dist/_next/static/chunks/320-22ebe7be58cf982a.js +1 -0
  63. package/{dist/dashboard/out/_next/static/chunks/app/app/page-2e525b1dcc790967.js → packages/dashboard/ui-dist/_next/static/chunks/app/app/page-9d6bc8729b429956.js} +1 -1
  64. package/packages/dashboard/ui-dist/_next/static/chunks/app/{page-4e64923d73c35bc9.js → page-487fa38f041815c1.js} +1 -1
  65. package/packages/dashboard/ui-dist/app/onboarding.html +1 -1
  66. package/packages/dashboard/ui-dist/app/onboarding.txt +2 -2
  67. package/packages/dashboard/ui-dist/app.html +1 -1
  68. package/packages/dashboard/ui-dist/app.txt +2 -2
  69. package/packages/dashboard/ui-dist/cloud/link.html +1 -1
  70. package/packages/dashboard/ui-dist/cloud/link.txt +2 -2
  71. package/packages/dashboard/ui-dist/connect-repos.html +1 -1
  72. package/packages/dashboard/ui-dist/connect-repos.txt +2 -2
  73. package/packages/dashboard/ui-dist/history.html +1 -1
  74. package/packages/dashboard/ui-dist/history.txt +2 -2
  75. package/packages/dashboard/ui-dist/index.html +1 -1
  76. package/packages/dashboard/ui-dist/index.txt +2 -2
  77. package/packages/dashboard/ui-dist/login.html +2 -2
  78. package/packages/dashboard/ui-dist/login.txt +2 -2
  79. package/packages/dashboard/ui-dist/metrics.html +1 -1
  80. package/packages/dashboard/ui-dist/metrics.txt +2 -2
  81. package/packages/dashboard/ui-dist/pricing.html +2 -2
  82. package/packages/dashboard/ui-dist/pricing.txt +2 -2
  83. package/packages/dashboard/ui-dist/providers/setup/claude.html +1 -1
  84. package/packages/dashboard/ui-dist/providers/setup/claude.txt +2 -2
  85. package/packages/dashboard/ui-dist/providers/setup/codex.html +1 -1
  86. package/packages/dashboard/ui-dist/providers/setup/codex.txt +2 -2
  87. package/packages/dashboard/ui-dist/providers/setup/cursor.html +1 -1
  88. package/packages/dashboard/ui-dist/providers/setup/cursor.txt +2 -2
  89. package/packages/dashboard/ui-dist/providers.html +1 -1
  90. package/packages/dashboard/ui-dist/providers.txt +2 -2
  91. package/packages/dashboard/ui-dist/signup.html +2 -2
  92. package/packages/dashboard/ui-dist/signup.txt +2 -2
  93. package/packages/dashboard-server/dist/server.js +5 -5
  94. package/packages/dashboard-server/package.json +12 -12
  95. package/packages/hooks/package.json +4 -4
  96. package/packages/mcp/package.json +2 -2
  97. package/packages/memory/package.json +2 -2
  98. package/packages/policy/package.json +2 -2
  99. package/packages/protocol/package.json +1 -1
  100. package/packages/resiliency/dist/cgroup-manager.d.ts +152 -0
  101. package/packages/resiliency/dist/cgroup-manager.js +394 -0
  102. package/packages/resiliency/dist/index.d.ts +1 -0
  103. package/packages/resiliency/dist/index.js +1 -0
  104. package/packages/resiliency/package.json +1 -1
  105. package/packages/sdk/package.json +2 -2
  106. package/packages/spawner/package.json +1 -1
  107. package/packages/state/package.json +1 -1
  108. package/packages/storage/package.json +2 -2
  109. package/packages/telemetry/package.json +1 -1
  110. package/packages/trajectory/package.json +2 -2
  111. package/packages/user-directory/package.json +2 -2
  112. package/packages/utils/package.json +1 -1
  113. package/packages/wrapper/dist/relay-pty-orchestrator.d.ts +8 -4
  114. package/packages/wrapper/dist/relay-pty-orchestrator.js +47 -25
  115. package/packages/wrapper/package.json +6 -6
  116. package/dist/dashboard/out/_next/static/chunks/320-900169c942e31422.js +0 -1
  117. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-f746f29e01fffc43.js +0 -1
  118. package/dist/dashboard/out/_next/static/chunks/app/cloud/link/page-5011ae044b90449d.js +0 -1
  119. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-03ac6f35a6654ea6.js +0 -1
  120. package/dist/dashboard/out/_next/static/chunks/app/history/page-b2ce7c96ed0931da.js +0 -1
  121. package/dist/dashboard/out/_next/static/chunks/app/layout-c0d118c0f92d969c.js +0 -1
  122. package/dist/dashboard/out/_next/static/chunks/app/login/page-6ec54eee75877971.js +0 -1
  123. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-bf2cb1e5915bc92d.js +0 -1
  124. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-0efa024c28ba4597.js +0 -1
  125. package/dist/dashboard/out/_next/static/chunks/app/providers/page-e65a0010da6ea5be.js +0 -1
  126. package/dist/dashboard/out/_next/static/chunks/main-app-6e8e8d3ef4e0192a.js +0 -1
  127. package/packages/dashboard/ui-dist/_next/static/chunks/320-900169c942e31422.js +0 -1
  128. package/packages/dashboard/ui-dist/_next/static/chunks/app/app/onboarding/page-f746f29e01fffc43.js +0 -1
  129. package/packages/dashboard/ui-dist/_next/static/chunks/app/app/page-44813aa26ad19681.js +0 -1
  130. package/packages/dashboard/ui-dist/_next/static/chunks/app/connect-repos/page-03ac6f35a6654ea6.js +0 -1
  131. package/packages/dashboard/ui-dist/_next/static/chunks/app/layout-c0d118c0f92d969c.js +0 -1
  132. package/packages/dashboard/ui-dist/_next/static/chunks/app/page-7993778218818ace.js +0 -1
  133. package/packages/dashboard/ui-dist/_next/static/chunks/app/pricing/page-0efa024c28ba4597.js +0 -1
  134. package/packages/dashboard/ui-dist/_next/static/chunks/app/providers/page-e65a0010da6ea5be.js +0 -1
  135. package/packages/dashboard/ui-dist/_next/static/chunks/app/providers/setup/[provider]/page-84161c802b020a1f.js +0 -1
  136. package/packages/dashboard/ui-dist/_next/static/chunks/app/signup/page-18a4665665f6be11.js +0 -1
  137. package/packages/dashboard/ui-dist/_next/static/chunks/main-app-6e8e8d3ef4e0192a.js +0 -1
  138. /package/dist/dashboard/out/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_buildManifest.js +0 -0
  139. /package/dist/dashboard/out/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_ssgManifest.js +0 -0
  140. /package/packages/dashboard/ui-dist/_next/static/{KIxE0Ds_zdGuDJDQu7_sb → 1yt8VDAusp2yTBf4JFA7F}/_buildManifest.js +0 -0
  141. /package/packages/dashboard/ui-dist/_next/static/{KIxE0Ds_zdGuDJDQu7_sb → 1yt8VDAusp2yTBf4JFA7F}/_ssgManifest.js +0 -0
  142. /package/packages/dashboard/ui-dist/_next/static/{SoK46dEi3IsNBVWXD9x0L → chdCrViSD2yReZElqRfk0}/_buildManifest.js +0 -0
  143. /package/packages/dashboard/ui-dist/_next/static/{SoK46dEi3IsNBVWXD9x0L → chdCrViSD2yReZElqRfk0}/_ssgManifest.js +0 -0
  144. /package/packages/dashboard/ui-dist/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_buildManifest.js +0 -0
  145. /package/packages/dashboard/ui-dist/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_ssgManifest.js +0 -0
@@ -3,13 +3,16 @@
3
3
  *
4
4
  * Chat view for a channel or DM conversation.
5
5
  * Displays messages and provides input for sending new messages.
6
+ * Uses shared MessageComposer for consistent attachment/paste support.
6
7
  */
7
8
 
8
- import React, { useState, useCallback, useRef, useEffect } from 'react';
9
+ import React, { useCallback, useRef, useEffect } from 'react';
9
10
  import type { ChannelMessage } from './hooks/useChannels';
10
11
  import type { Agent } from '../types';
11
12
  import type { UserPresence } from './hooks/usePresence';
12
13
  import { MessageSenderName } from './MessageSenderName';
14
+ import { MessageComposer } from './MessageComposer';
15
+ import type { HumanUser } from './MentionAutocomplete';
13
16
 
14
17
  export interface ChannelChatProps {
15
18
  /** Current channel name */
@@ -18,8 +21,8 @@ export interface ChannelChatProps {
18
21
  messages: ChannelMessage[];
19
22
  /** Current user's username */
20
23
  currentUser: string;
21
- /** Send a message */
22
- onSendMessage: (body: string, thread?: string) => void;
24
+ /** Send a message (now supports attachments) */
25
+ onSendMessage: (body: string, thread?: string, attachmentIds?: string[]) => void;
23
26
  /** Online users for mentions */
24
27
  onlineUsers?: string[];
25
28
  /** Agents list for profile lookup */
@@ -30,6 +33,8 @@ export interface ChannelChatProps {
30
33
  onAgentClick?: (agent: Agent) => void;
31
34
  /** Callback when user name is clicked */
32
35
  onUserClick?: (user: UserPresence) => void;
36
+ /** Whether message sending is in progress */
37
+ isSending?: boolean;
33
38
  }
34
39
 
35
40
  export function ChannelChat({
@@ -42,10 +47,9 @@ export function ChannelChat({
42
47
  onlineUserPresence = [],
43
48
  onAgentClick,
44
49
  onUserClick,
50
+ isSending = false,
45
51
  }: ChannelChatProps) {
46
- const [inputValue, setInputValue] = useState('');
47
52
  const messagesEndRef = useRef<HTMLDivElement>(null);
48
- const inputRef = useRef<HTMLTextAreaElement>(null);
49
53
 
50
54
  // Filter messages for this channel
51
55
  const channelMessages = messages.filter(m => {
@@ -65,27 +69,24 @@ export function ChannelChat({
65
69
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
66
70
  }, [channelMessages.length]);
67
71
 
68
- const handleSend = useCallback(() => {
69
- const trimmed = inputValue.trim();
70
- if (!trimmed) return;
71
-
72
- onSendMessage(trimmed);
73
- setInputValue('');
74
- inputRef.current?.focus();
75
- }, [inputValue, onSendMessage]);
76
-
77
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
78
- if (e.key === 'Enter' && !e.shiftKey) {
79
- e.preventDefault();
80
- handleSend();
81
- }
82
- }, [handleSend]);
72
+ // Handle message send with attachments
73
+ const handleSend = useCallback(async (content: string, attachmentIds?: string[]): Promise<boolean> => {
74
+ if (!content.trim() && (!attachmentIds || attachmentIds.length === 0)) return false;
75
+ onSendMessage(content, undefined, attachmentIds);
76
+ return true;
77
+ }, [onSendMessage]);
83
78
 
84
79
  const isDm = channel.startsWith('dm:');
85
80
  const channelDisplay = isDm
86
81
  ? channel.split(':').slice(1).filter(u => u !== currentUser).join(', ')
87
82
  : channel;
88
83
 
84
+ // Convert online user presence to HumanUser format for mentions
85
+ const humanUsers: HumanUser[] = onlineUserPresence.map(u => ({
86
+ username: u.username,
87
+ avatarUrl: u.avatarUrl,
88
+ }));
89
+
89
90
  return (
90
91
  <div style={{
91
92
  display: 'flex',
@@ -163,65 +164,18 @@ export function ChannelChat({
163
164
  <div ref={messagesEndRef} />
164
165
  </div>
165
166
 
166
- {/* Input */}
167
+ {/* Input - Using shared MessageComposer for attachment support */}
167
168
  <div style={{
168
169
  padding: '16px 20px',
169
170
  borderTop: '1px solid var(--border-color, #313244)',
170
171
  }}>
171
- <div style={{
172
- display: 'flex',
173
- gap: '12px',
174
- alignItems: 'flex-end',
175
- }}>
176
- <textarea
177
- ref={inputRef}
178
- value={inputValue}
179
- onChange={(e) => setInputValue(e.target.value)}
180
- onKeyDown={handleKeyDown}
181
- placeholder={`Message ${channelDisplay}...`}
182
- style={{
183
- flex: 1,
184
- padding: '12px 16px',
185
- backgroundColor: 'var(--bg-secondary, #1e1e2e)',
186
- border: '1px solid var(--border-color, #313244)',
187
- borderRadius: '8px',
188
- color: 'var(--text-primary, #cdd6f4)',
189
- fontSize: '14px',
190
- resize: 'none',
191
- minHeight: '44px',
192
- maxHeight: '120px',
193
- outline: 'none',
194
- fontFamily: 'inherit',
195
- }}
196
- rows={1}
197
- />
198
- <button
199
- onClick={handleSend}
200
- disabled={!inputValue.trim()}
201
- style={{
202
- padding: '12px 20px',
203
- backgroundColor: inputValue.trim()
204
- ? 'var(--accent-color, #89b4fa)'
205
- : 'var(--bg-tertiary, #313244)',
206
- border: 'none',
207
- borderRadius: '8px',
208
- color: inputValue.trim() ? '#11111b' : 'var(--text-muted, #6c7086)',
209
- fontSize: '14px',
210
- fontWeight: 500,
211
- cursor: inputValue.trim() ? 'pointer' : 'default',
212
- transition: 'all 0.15s ease',
213
- }}
214
- >
215
- Send
216
- </button>
217
- </div>
218
- <div style={{
219
- fontSize: '12px',
220
- color: 'var(--text-muted, #6c7086)',
221
- marginTop: '8px',
222
- }}>
223
- Press Enter to send, Shift+Enter for new line
224
- </div>
172
+ <MessageComposer
173
+ onSend={handleSend}
174
+ isSending={isSending}
175
+ placeholder={`Message ${channelDisplay}...`}
176
+ agents={agents}
177
+ humanUsers={humanUsers}
178
+ />
225
179
  </div>
226
180
  </div>
227
181
  );
@@ -0,0 +1,457 @@
1
+ /**
2
+ * MessageComposer - Shared message input component with attachment support
3
+ *
4
+ * Features:
5
+ * - Image paste from clipboard
6
+ * - File upload via button
7
+ * - @-mention autocomplete (optional)
8
+ * - File path autocomplete (optional)
9
+ * - Typing indicator support
10
+ * - Multi-line support (Shift+Enter)
11
+ *
12
+ * Used by both DMs and Channels for consistent messaging experience.
13
+ */
14
+
15
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
16
+ import { api } from '../lib/api';
17
+ import { MentionAutocomplete, getMentionQuery, type HumanUser } from './MentionAutocomplete';
18
+ import { FileAutocomplete, getFileQuery } from './FileAutocomplete';
19
+ import type { Agent } from '../types';
20
+
21
+ /**
22
+ * Pending attachment state during upload
23
+ */
24
+ export interface PendingAttachment {
25
+ id: string;
26
+ file: File;
27
+ preview: string;
28
+ isUploading: boolean;
29
+ uploadedId?: string;
30
+ error?: string;
31
+ }
32
+
33
+ /**
34
+ * Props for the MessageComposer component
35
+ */
36
+ export interface MessageComposerProps {
37
+ /** Called when user sends a message */
38
+ onSend: (content: string, attachmentIds?: string[]) => Promise<boolean>;
39
+ /** Called when typing state changes */
40
+ onTyping?: (isTyping: boolean) => void;
41
+ /** Whether a send is in progress */
42
+ isSending?: boolean;
43
+ /** Whether input is disabled */
44
+ disabled?: boolean;
45
+ /** Placeholder text */
46
+ placeholder?: string;
47
+ /** Error message to display */
48
+ error?: string | null;
49
+ /** Agent list for @-mention autocomplete */
50
+ agents?: Agent[];
51
+ /** Human user list for @-mention autocomplete */
52
+ humanUsers?: HumanUser[];
53
+ /** Enable file path autocomplete */
54
+ enableFileAutocomplete?: boolean;
55
+ /** Mention to insert (triggered externally) */
56
+ insertMention?: string;
57
+ /** Called after mention is inserted */
58
+ onMentionInserted?: () => void;
59
+ /** Custom class for the form container */
60
+ className?: string;
61
+ }
62
+
63
+ export function MessageComposer({
64
+ onSend,
65
+ onTyping,
66
+ isSending = false,
67
+ disabled = false,
68
+ placeholder = 'Type a message...',
69
+ error,
70
+ agents = [],
71
+ humanUsers = [],
72
+ enableFileAutocomplete = false,
73
+ insertMention,
74
+ onMentionInserted,
75
+ className = '',
76
+ }: MessageComposerProps) {
77
+ const [message, setMessage] = useState('');
78
+ const [cursorPosition, setCursorPosition] = useState(0);
79
+ const [showMentions, setShowMentions] = useState(false);
80
+ const [showFiles, setShowFiles] = useState(false);
81
+ const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
82
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
83
+ const fileInputRef = useRef<HTMLInputElement>(null);
84
+
85
+ // Handle insertMention prop - insert @username when triggered from outside
86
+ useEffect(() => {
87
+ if (insertMention && onMentionInserted) {
88
+ const mentionText = `@${insertMention} `;
89
+ const textarea = textareaRef.current;
90
+ if (textarea) {
91
+ const start = textarea.selectionStart || message.length;
92
+ const newMessage = message.slice(0, start) + mentionText + message.slice(start);
93
+ setMessage(newMessage);
94
+ setTimeout(() => {
95
+ textarea.focus();
96
+ const newPos = start + mentionText.length;
97
+ textarea.setSelectionRange(newPos, newPos);
98
+ }, 0);
99
+ } else {
100
+ setMessage(prev => prev + mentionText);
101
+ }
102
+ onMentionInserted();
103
+ }
104
+ }, [insertMention, onMentionInserted, message]);
105
+
106
+ // Process image files (used by both paste and file input)
107
+ const processImageFiles = useCallback(async (imageFiles: File[]) => {
108
+ for (const file of imageFiles) {
109
+ const id = crypto.randomUUID();
110
+ const preview = URL.createObjectURL(file);
111
+
112
+ // Add to pending attachments
113
+ setAttachments(prev => [...prev, {
114
+ id,
115
+ file,
116
+ preview,
117
+ isUploading: true,
118
+ }]);
119
+
120
+ // Upload the file
121
+ try {
122
+ const result = await api.uploadAttachment(file);
123
+ if (result.success && result.data) {
124
+ setAttachments(prev => prev.map(a =>
125
+ a.id === id
126
+ ? { ...a, isUploading: false, uploadedId: result.data!.attachment.id }
127
+ : a
128
+ ));
129
+ } else {
130
+ setAttachments(prev => prev.map(a =>
131
+ a.id === id
132
+ ? { ...a, isUploading: false, error: result.error || 'Upload failed' }
133
+ : a
134
+ ));
135
+ }
136
+ } catch (err) {
137
+ setAttachments(prev => prev.map(a =>
138
+ a.id === id
139
+ ? { ...a, isUploading: false, error: 'Upload failed' }
140
+ : a
141
+ ));
142
+ }
143
+ }
144
+ }, []);
145
+
146
+ // Handle file selection from file input
147
+ const handleFileSelect = useCallback((files: FileList | null) => {
148
+ if (!files || files.length === 0) return;
149
+
150
+ const imageFiles = Array.from(files).filter(file =>
151
+ file.type.startsWith('image/')
152
+ );
153
+
154
+ if (imageFiles.length > 0) {
155
+ processImageFiles(imageFiles);
156
+ }
157
+ }, [processImageFiles]);
158
+
159
+ // Handle paste for clipboard images
160
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
161
+ const clipboardData = e.clipboardData;
162
+ if (!clipboardData) return;
163
+
164
+ let imageFiles: File[] = [];
165
+
166
+ // Method 1: Check clipboardData.files (works for file pastes)
167
+ if (clipboardData.files && clipboardData.files.length > 0) {
168
+ imageFiles = Array.from(clipboardData.files).filter(file =>
169
+ file.type.startsWith('image/')
170
+ );
171
+ }
172
+
173
+ // Method 2: Check clipboardData.items (works for screenshots/copied images)
174
+ if (imageFiles.length === 0 && clipboardData.items) {
175
+ const items = Array.from(clipboardData.items);
176
+ for (const item of items) {
177
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
178
+ const file = item.getAsFile();
179
+ if (file) {
180
+ imageFiles.push(file);
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // Process any found images
187
+ if (imageFiles.length > 0) {
188
+ e.preventDefault();
189
+ processImageFiles(imageFiles);
190
+ }
191
+ }, [processImageFiles]);
192
+
193
+ // Remove an attachment
194
+ const removeAttachment = useCallback((id: string) => {
195
+ setAttachments(prev => {
196
+ const attachment = prev.find(a => a.id === id);
197
+ if (attachment) {
198
+ URL.revokeObjectURL(attachment.preview);
199
+ }
200
+ return prev.filter(a => a.id !== id);
201
+ });
202
+ }, []);
203
+
204
+ const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
205
+ const value = e.target.value;
206
+ const cursorPos = e.target.selectionStart || 0;
207
+ setMessage(value);
208
+ setCursorPosition(cursorPos);
209
+
210
+ // Send typing indicator when user has content
211
+ onTyping?.(value.trim().length > 0);
212
+
213
+ // Check for file autocomplete first (@ followed by path-like pattern)
214
+ if (enableFileAutocomplete) {
215
+ const fileQuery = getFileQuery(value, cursorPos);
216
+ if (fileQuery !== null) {
217
+ setShowFiles(true);
218
+ setShowMentions(false);
219
+ return;
220
+ }
221
+ }
222
+
223
+ // Check for mention autocomplete (@ at start without path patterns)
224
+ if (agents.length > 0 || humanUsers.length > 0) {
225
+ const mentionQuery = getMentionQuery(value, cursorPos);
226
+ if (mentionQuery !== null) {
227
+ setShowMentions(true);
228
+ setShowFiles(false);
229
+ return;
230
+ }
231
+ }
232
+
233
+ // Neither - hide both
234
+ setShowMentions(false);
235
+ setShowFiles(false);
236
+ };
237
+
238
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
239
+ // Don't handle Enter/Tab when autocomplete is visible
240
+ if ((showMentions || showFiles) && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Tab')) {
241
+ return;
242
+ }
243
+
244
+ if (e.key === 'Enter' && !e.shiftKey && !showMentions && !showFiles) {
245
+ e.preventDefault();
246
+ if ((message.trim() || attachments.length > 0) && !isSending && !disabled) {
247
+ handleSubmit(e as unknown as React.FormEvent);
248
+ }
249
+ }
250
+ };
251
+
252
+ const handleMentionSelect = (mention: string, newValue: string) => {
253
+ setMessage(newValue);
254
+ setShowMentions(false);
255
+ setShowFiles(false);
256
+ setTimeout(() => {
257
+ if (textareaRef.current) {
258
+ textareaRef.current.focus();
259
+ const pos = newValue.indexOf(' ') + 1;
260
+ textareaRef.current.setSelectionRange(pos, pos);
261
+ }
262
+ }, 0);
263
+ };
264
+
265
+ const handleFilePathSelect = (filePath: string, newValue: string) => {
266
+ setMessage(newValue);
267
+ setShowFiles(false);
268
+ setShowMentions(false);
269
+ setTimeout(() => {
270
+ if (textareaRef.current) {
271
+ textareaRef.current.focus();
272
+ const pos = newValue.indexOf(' ', 1) + 1;
273
+ textareaRef.current.setSelectionRange(pos, pos);
274
+ }
275
+ }, 0);
276
+ };
277
+
278
+ const handleSubmit = async (e: React.FormEvent) => {
279
+ e.preventDefault();
280
+
281
+ const hasMessage = message.trim().length > 0;
282
+ const hasAttachments = attachments.length > 0;
283
+ if ((!hasMessage && !hasAttachments) || isSending || disabled) return;
284
+
285
+ // Check if any attachments are still uploading
286
+ const stillUploading = attachments.some(a => a.isUploading);
287
+ if (stillUploading) return;
288
+
289
+ // Get uploaded attachment IDs
290
+ const attachmentIds = attachments
291
+ .filter(a => a.uploadedId)
292
+ .map(a => a.uploadedId!);
293
+
294
+ // If no message but has attachments, send with default text
295
+ let content = message.trim();
296
+ if (!content && attachmentIds.length > 0) {
297
+ content = '[Screenshot attached]';
298
+ }
299
+
300
+ const success = await onSend(
301
+ content,
302
+ attachmentIds.length > 0 ? attachmentIds : undefined
303
+ );
304
+
305
+ if (success) {
306
+ // Clean up previews
307
+ attachments.forEach(a => URL.revokeObjectURL(a.preview));
308
+ setMessage('');
309
+ setAttachments([]);
310
+ setShowMentions(false);
311
+ setShowFiles(false);
312
+ }
313
+ };
314
+
315
+ // Check if we can send
316
+ const canSend = (message.trim() || attachments.length > 0) &&
317
+ !isSending &&
318
+ !disabled &&
319
+ !attachments.some(a => a.isUploading);
320
+
321
+ return (
322
+ <form className={`flex flex-col gap-1.5 sm:gap-2 ${className}`} onSubmit={handleSubmit}>
323
+ {/* Attachment previews */}
324
+ {attachments.length > 0 && (
325
+ <div className="flex flex-wrap gap-1.5 sm:gap-2 p-1.5 sm:p-2 bg-bg-card rounded-lg border border-border-subtle">
326
+ {attachments.map(attachment => (
327
+ <div key={attachment.id} className="relative group">
328
+ <img
329
+ src={attachment.preview}
330
+ alt={attachment.file.name}
331
+ className={`h-16 w-auto rounded-lg object-cover ${attachment.isUploading ? 'opacity-50' : ''} ${attachment.error ? 'border-2 border-error' : ''}`}
332
+ />
333
+ {attachment.isUploading && (
334
+ <div className="absolute inset-0 flex items-center justify-center">
335
+ <svg className="animate-spin h-5 w-5 text-accent-cyan" viewBox="0 0 24 24">
336
+ <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" strokeDasharray="32" strokeLinecap="round" />
337
+ </svg>
338
+ </div>
339
+ )}
340
+ {attachment.error && (
341
+ <div className="absolute bottom-0 left-0 right-0 bg-error/90 text-white text-[10px] px-1 py-0.5 truncate">
342
+ {attachment.error}
343
+ </div>
344
+ )}
345
+ <button
346
+ type="button"
347
+ onClick={() => removeAttachment(attachment.id)}
348
+ className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-bg-tertiary border border-border-subtle rounded-full flex items-center justify-center text-text-muted hover:text-error hover:border-error transition-colors opacity-0 group-hover:opacity-100"
349
+ title="Remove"
350
+ >
351
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
352
+ <line x1="18" y1="6" x2="6" y2="18" />
353
+ <line x1="6" y1="6" x2="18" y2="18" />
354
+ </svg>
355
+ </button>
356
+ </div>
357
+ ))}
358
+ </div>
359
+ )}
360
+
361
+ {/* Input row */}
362
+ <div className="flex items-center gap-1.5 sm:gap-3">
363
+ {/* Image upload button */}
364
+ <input
365
+ ref={fileInputRef}
366
+ type="file"
367
+ accept="image/*"
368
+ multiple
369
+ className="hidden"
370
+ onChange={(e) => handleFileSelect(e.target.files)}
371
+ />
372
+ <button
373
+ type="button"
374
+ onClick={() => fileInputRef.current?.click()}
375
+ disabled={disabled}
376
+ className="p-2 sm:p-2.5 bg-bg-card border border-border-subtle rounded-lg sm:rounded-xl text-text-muted hover:text-accent-cyan hover:border-accent-cyan/50 transition-colors flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
377
+ title="Attach screenshot (or paste from clipboard)"
378
+ >
379
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="sm:w-[18px] sm:h-[18px]">
380
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
381
+ <circle cx="8.5" cy="8.5" r="1.5" />
382
+ <polyline points="21 15 16 10 5 21" />
383
+ </svg>
384
+ </button>
385
+
386
+ <div className="flex-1 relative min-w-0">
387
+ {/* Agent mention autocomplete */}
388
+ {(agents.length > 0 || humanUsers.length > 0) && (
389
+ <MentionAutocomplete
390
+ agents={agents}
391
+ humanUsers={humanUsers}
392
+ inputValue={message}
393
+ cursorPosition={cursorPosition}
394
+ onSelect={handleMentionSelect}
395
+ onClose={() => setShowMentions(false)}
396
+ isVisible={showMentions}
397
+ />
398
+ )}
399
+ {/* File path autocomplete */}
400
+ {enableFileAutocomplete && (
401
+ <FileAutocomplete
402
+ inputValue={message}
403
+ cursorPosition={cursorPosition}
404
+ onSelect={handleFilePathSelect}
405
+ onClose={() => setShowFiles(false)}
406
+ isVisible={showFiles}
407
+ />
408
+ )}
409
+ <textarea
410
+ ref={textareaRef}
411
+ className="w-full py-2 sm:py-3 px-3 sm:px-4 bg-bg-card border border-border-subtle rounded-lg sm:rounded-xl text-sm font-sans text-text-primary outline-none transition-all duration-200 resize-none min-h-[40px] sm:min-h-[44px] max-h-[100px] sm:max-h-[120px] overflow-y-auto focus:border-accent-cyan/50 focus:shadow-[0_0_0_3px_rgba(0,217,255,0.1)] placeholder:text-text-muted disabled:opacity-50 disabled:cursor-not-allowed"
412
+ placeholder={placeholder}
413
+ value={message}
414
+ onChange={handleInputChange}
415
+ onKeyDown={handleKeyDown}
416
+ onPaste={handlePaste}
417
+ onSelect={(e) => setCursorPosition((e.target as HTMLTextAreaElement).selectionStart || 0)}
418
+ disabled={disabled || isSending}
419
+ rows={1}
420
+ />
421
+ </div>
422
+ <button
423
+ type="submit"
424
+ className="py-2 sm:py-3 px-3 sm:px-5 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold border-none rounded-lg sm:rounded-xl text-xs sm:text-sm cursor-pointer transition-all duration-150 hover:shadow-glow-cyan hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none flex-shrink-0"
425
+ disabled={!canSend}
426
+ title={isSending ? 'Sending...' : attachments.some(a => a.isUploading) ? 'Uploading...' : 'Send message'}
427
+ >
428
+ {isSending ? (
429
+ <span className="hidden sm:inline">Sending...</span>
430
+ ) : attachments.some(a => a.isUploading) ? (
431
+ <span className="hidden sm:inline">Uploading...</span>
432
+ ) : (
433
+ <span className="flex items-center gap-1 sm:gap-2">
434
+ <span className="hidden sm:inline">Send</span>
435
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
436
+ <line x1="22" y1="2" x2="11" y2="13"></line>
437
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
438
+ </svg>
439
+ </span>
440
+ )}
441
+ </button>
442
+ {error && <span className="text-error text-xs ml-2">{error}</span>}
443
+ </div>
444
+
445
+ {/* Helper text */}
446
+ <p className="text-xs text-text-muted px-1">
447
+ <kbd className="px-1 py-0.5 bg-bg-tertiary rounded text-[10px]">Enter</kbd> to send,{' '}
448
+ <kbd className="px-1 py-0.5 bg-bg-tertiary rounded text-[10px]">Shift+Enter</kbd> for new line
449
+ {(agents.length > 0 || humanUsers.length > 0) && (
450
+ <>, <kbd className="px-1 py-0.5 bg-bg-tertiary rounded text-[10px]">@</kbd> to mention</>
451
+ )}
452
+ </p>
453
+ </form>
454
+ );
455
+ }
456
+
457
+ export default MessageComposer;
@@ -1 +1 @@
1
- <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/99c2552394077586.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-1cdd8ed57114d5e1.js"/><script src="/_next/static/chunks/fd9d1056-609918ca7b6280bb.js" async=""></script><script src="/_next/static/chunks/117-c8afed19e821a35d.js" async=""></script><script src="/_next/static/chunks/main-app-6e8e8d3ef4e0192a.js" async=""></script><meta name="robots" content="noindex"/><title>404: This page could not be found.</title><title>Agent Relay Dashboard</title><meta name="description" content="Fleet control dashboard for Agent Relay"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script src="/_next/static/chunks/webpack-1cdd8ed57114d5e1.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/99c2552394077586.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[4707,[],\"\"]\n5:I[6423,[],\"\"]\nb:I[1060,[],\"\"]\n6:{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"}\n7:{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"}\n8:{\"display\":\"inline-block\"}\n9:{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0}\nc:[]\n"])</script><script>self.__next_f.push([1,"0:[\"$\",\"$L2\",null,{\"buildId\":\"lIJs7zSKBaI58kpqegulQ\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"_not-found\"],\"initialTree\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null],null],null]},[null,[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"/_not-found\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\"}]],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/99c2552394077586.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":\"$6\",\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":\"$7\",\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":\"$8\",\"children\":[\"$\",\"h2\",null,{\"style\":\"$9\",\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],\"$La\"],\"globalErrorComponent\":\"$b\",\"missingSlots\":\"$Wc\"}]\n"])</script><script>self.__next_f.push([1,"a:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"Agent Relay Dashboard\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"Fleet control dashboard for Agent Relay\"}]]\n3:null\n"])</script></body></html>
1
+ <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/99c2552394077586.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-1cdd8ed57114d5e1.js"/><script src="/_next/static/chunks/fd9d1056-609918ca7b6280bb.js" async=""></script><script src="/_next/static/chunks/117-c8afed19e821a35d.js" async=""></script><script src="/_next/static/chunks/main-app-fdbeb09028f57c9f.js" async=""></script><meta name="robots" content="noindex"/><title>404: This page could not be found.</title><title>Agent Relay Dashboard</title><meta name="description" content="Fleet control dashboard for Agent Relay"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script src="/_next/static/chunks/webpack-1cdd8ed57114d5e1.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/99c2552394077586.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[4707,[],\"\"]\n5:I[6423,[],\"\"]\nb:I[1060,[],\"\"]\n6:{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"}\n7:{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"}\n8:{\"display\":\"inline-block\"}\n9:{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0}\nc:[]\n"])</script><script>self.__next_f.push([1,"0:[\"$\",\"$L2\",null,{\"buildId\":\"loxKCRf0rbwVD8vl_Gw60\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"_not-found\"],\"initialTree\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null],null],null]},[null,[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"/_not-found\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\"}]],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/99c2552394077586.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":\"$6\",\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":\"$7\",\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":\"$8\",\"children\":[\"$\",\"h2\",null,{\"style\":\"$9\",\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],\"$La\"],\"globalErrorComponent\":\"$b\",\"missingSlots\":\"$Wc\"}]\n"])</script><script>self.__next_f.push([1,"a:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"Agent Relay Dashboard\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"Fleet control dashboard for Agent Relay\"}]]\n3:null\n"])</script></body></html>