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.
- package/deploy/workspace/entrypoint.sh +80 -22
- package/deploy/workspace/gh-credential-relay +90 -0
- package/dist/dashboard/out/404.html +1 -1
- package/{packages/dashboard/ui-dist/_next/static/chunks/677-7323947c23b35979.js → dist/dashboard/out/_next/static/chunks/320-22ebe7be58cf982a.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-9914652442f7e4fb.js +1 -0
- 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
- 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
- package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-113060009ef35bc2.js +1 -0
- 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
- package/dist/dashboard/out/_next/static/chunks/app/layout-6b91e33784c20610.js +1 -0
- 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
- 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
- package/dist/dashboard/out/_next/static/chunks/app/{page-4e64923d73c35bc9.js → page-487fa38f041815c1.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/pricing/page-9db3ebdfa567a7c9.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-bcf46064ac4474ce.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/{page-84161c802b020a1f.js → page-4dbe33f0f7691b7c.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/signup/{page-18a4665665f6be11.js → page-1ede2205b58649ca.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/main-app-fdbeb09028f57c9f.js +1 -0
- package/dist/dashboard/out/app/onboarding.html +1 -1
- package/dist/dashboard/out/app/onboarding.txt +2 -2
- package/dist/dashboard/out/app.html +1 -1
- package/dist/dashboard/out/app.txt +2 -2
- package/dist/dashboard/out/cloud/link.html +1 -1
- package/dist/dashboard/out/cloud/link.txt +2 -2
- package/dist/dashboard/out/connect-repos.html +1 -1
- package/dist/dashboard/out/connect-repos.txt +2 -2
- package/dist/dashboard/out/history.html +1 -1
- package/dist/dashboard/out/history.txt +2 -2
- package/dist/dashboard/out/index.html +1 -1
- package/dist/dashboard/out/index.txt +2 -2
- package/dist/dashboard/out/login.html +2 -2
- package/dist/dashboard/out/login.txt +2 -2
- package/dist/dashboard/out/metrics.html +1 -1
- package/dist/dashboard/out/metrics.txt +2 -2
- package/dist/dashboard/out/pricing.html +2 -2
- package/dist/dashboard/out/pricing.txt +2 -2
- package/dist/dashboard/out/providers/setup/claude.html +1 -1
- package/dist/dashboard/out/providers/setup/claude.txt +2 -2
- package/dist/dashboard/out/providers/setup/codex.html +1 -1
- package/dist/dashboard/out/providers/setup/codex.txt +2 -2
- package/dist/dashboard/out/providers/setup/cursor.html +1 -1
- package/dist/dashboard/out/providers/setup/cursor.txt +2 -2
- package/dist/dashboard/out/providers.html +1 -1
- package/dist/dashboard/out/providers.txt +2 -2
- package/dist/dashboard/out/signup.html +2 -2
- package/dist/dashboard/out/signup.txt +2 -2
- package/package.json +14 -14
- package/packages/api-types/package.json +1 -1
- package/packages/bridge/dist/spawner.js +9 -0
- package/packages/bridge/package.json +7 -7
- package/packages/cloud/dist/server.js +3 -3
- package/packages/cloud/package.json +6 -6
- package/packages/config/package.json +2 -2
- package/packages/continuity/package.json +1 -1
- package/packages/daemon/package.json +12 -12
- package/packages/dashboard/dist/server.js +9 -9
- package/packages/dashboard/package.json +12 -12
- package/packages/dashboard/ui/react-components/App.tsx +21 -435
- package/packages/dashboard/ui/react-components/ChannelChat.tsx +29 -75
- package/packages/dashboard/ui/react-components/MessageComposer.tsx +457 -0
- package/packages/dashboard/ui-dist/404.html +1 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/320-22ebe7be58cf982a.js +1 -0
- 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
- package/packages/dashboard/ui-dist/_next/static/chunks/app/{page-4e64923d73c35bc9.js → page-487fa38f041815c1.js} +1 -1
- package/packages/dashboard/ui-dist/app/onboarding.html +1 -1
- package/packages/dashboard/ui-dist/app/onboarding.txt +2 -2
- package/packages/dashboard/ui-dist/app.html +1 -1
- package/packages/dashboard/ui-dist/app.txt +2 -2
- package/packages/dashboard/ui-dist/cloud/link.html +1 -1
- package/packages/dashboard/ui-dist/cloud/link.txt +2 -2
- package/packages/dashboard/ui-dist/connect-repos.html +1 -1
- package/packages/dashboard/ui-dist/connect-repos.txt +2 -2
- package/packages/dashboard/ui-dist/history.html +1 -1
- package/packages/dashboard/ui-dist/history.txt +2 -2
- package/packages/dashboard/ui-dist/index.html +1 -1
- package/packages/dashboard/ui-dist/index.txt +2 -2
- package/packages/dashboard/ui-dist/login.html +2 -2
- package/packages/dashboard/ui-dist/login.txt +2 -2
- package/packages/dashboard/ui-dist/metrics.html +1 -1
- package/packages/dashboard/ui-dist/metrics.txt +2 -2
- package/packages/dashboard/ui-dist/pricing.html +2 -2
- package/packages/dashboard/ui-dist/pricing.txt +2 -2
- package/packages/dashboard/ui-dist/providers/setup/claude.html +1 -1
- package/packages/dashboard/ui-dist/providers/setup/claude.txt +2 -2
- package/packages/dashboard/ui-dist/providers/setup/codex.html +1 -1
- package/packages/dashboard/ui-dist/providers/setup/codex.txt +2 -2
- package/packages/dashboard/ui-dist/providers/setup/cursor.html +1 -1
- package/packages/dashboard/ui-dist/providers/setup/cursor.txt +2 -2
- package/packages/dashboard/ui-dist/providers.html +1 -1
- package/packages/dashboard/ui-dist/providers.txt +2 -2
- package/packages/dashboard/ui-dist/signup.html +2 -2
- package/packages/dashboard/ui-dist/signup.txt +2 -2
- package/packages/dashboard-server/dist/server.js +5 -5
- package/packages/dashboard-server/package.json +12 -12
- package/packages/hooks/package.json +4 -4
- package/packages/mcp/package.json +2 -2
- package/packages/memory/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/protocol/package.json +1 -1
- package/packages/resiliency/dist/cgroup-manager.d.ts +152 -0
- package/packages/resiliency/dist/cgroup-manager.js +394 -0
- package/packages/resiliency/dist/index.d.ts +1 -0
- package/packages/resiliency/dist/index.js +1 -0
- package/packages/resiliency/package.json +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/spawner/package.json +1 -1
- package/packages/state/package.json +1 -1
- package/packages/storage/package.json +2 -2
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +1 -1
- package/packages/wrapper/dist/relay-pty-orchestrator.d.ts +8 -4
- package/packages/wrapper/dist/relay-pty-orchestrator.js +47 -25
- package/packages/wrapper/package.json +6 -6
- package/dist/dashboard/out/_next/static/chunks/320-900169c942e31422.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-f746f29e01fffc43.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/cloud/link/page-5011ae044b90449d.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-03ac6f35a6654ea6.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/history/page-b2ce7c96ed0931da.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/layout-c0d118c0f92d969c.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/login/page-6ec54eee75877971.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-bf2cb1e5915bc92d.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/pricing/page-0efa024c28ba4597.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-e65a0010da6ea5be.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/main-app-6e8e8d3ef4e0192a.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/320-900169c942e31422.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/app/onboarding/page-f746f29e01fffc43.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/app/page-44813aa26ad19681.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/connect-repos/page-03ac6f35a6654ea6.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/layout-c0d118c0f92d969c.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/page-7993778218818ace.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/pricing/page-0efa024c28ba4597.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/providers/page-e65a0010da6ea5be.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/providers/setup/[provider]/page-84161c802b020a1f.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/app/signup/page-18a4665665f6be11.js +0 -1
- package/packages/dashboard/ui-dist/_next/static/chunks/main-app-6e8e8d3ef4e0192a.js +0 -1
- /package/dist/dashboard/out/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_buildManifest.js +0 -0
- /package/dist/dashboard/out/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_ssgManifest.js +0 -0
- /package/packages/dashboard/ui-dist/_next/static/{KIxE0Ds_zdGuDJDQu7_sb → 1yt8VDAusp2yTBf4JFA7F}/_buildManifest.js +0 -0
- /package/packages/dashboard/ui-dist/_next/static/{KIxE0Ds_zdGuDJDQu7_sb → 1yt8VDAusp2yTBf4JFA7F}/_ssgManifest.js +0 -0
- /package/packages/dashboard/ui-dist/_next/static/{SoK46dEi3IsNBVWXD9x0L → chdCrViSD2yReZElqRfk0}/_buildManifest.js +0 -0
- /package/packages/dashboard/ui-dist/_next/static/{SoK46dEi3IsNBVWXD9x0L → chdCrViSD2yReZElqRfk0}/_ssgManifest.js +0 -0
- /package/packages/dashboard/ui-dist/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_buildManifest.js +0 -0
- /package/packages/dashboard/ui-dist/_next/static/{lIJs7zSKBaI58kpqegulQ → loxKCRf0rbwVD8vl_Gw60}/_ssgManifest.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-relay/daemon",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "Relay daemon server - agent coordination and message routing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -22,17 +22,17 @@
|
|
|
22
22
|
"test:watch": "vitest"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agent-relay/protocol": "2.0.
|
|
26
|
-
"@agent-relay/config": "2.0.
|
|
27
|
-
"@agent-relay/storage": "2.0.
|
|
28
|
-
"@agent-relay/bridge": "2.0.
|
|
29
|
-
"@agent-relay/utils": "2.0.
|
|
30
|
-
"@agent-relay/policy": "2.0.
|
|
31
|
-
"@agent-relay/memory": "2.0.
|
|
32
|
-
"@agent-relay/resiliency": "2.0.
|
|
33
|
-
"@agent-relay/user-directory": "2.0.
|
|
34
|
-
"@agent-relay/wrapper": "2.0.
|
|
35
|
-
"@agent-relay/telemetry": "2.0.
|
|
25
|
+
"@agent-relay/protocol": "2.0.7",
|
|
26
|
+
"@agent-relay/config": "2.0.7",
|
|
27
|
+
"@agent-relay/storage": "2.0.7",
|
|
28
|
+
"@agent-relay/bridge": "2.0.7",
|
|
29
|
+
"@agent-relay/utils": "2.0.7",
|
|
30
|
+
"@agent-relay/policy": "2.0.7",
|
|
31
|
+
"@agent-relay/memory": "2.0.7",
|
|
32
|
+
"@agent-relay/resiliency": "2.0.7",
|
|
33
|
+
"@agent-relay/user-directory": "2.0.7",
|
|
34
|
+
"@agent-relay/wrapper": "2.0.7",
|
|
35
|
+
"@agent-relay/telemetry": "2.0.7",
|
|
36
36
|
"ws": "^8.18.3",
|
|
37
37
|
"better-sqlite3": "^12.6.2",
|
|
38
38
|
"pg": "^8.16.3",
|
|
@@ -679,25 +679,25 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
679
679
|
const attachmentRegistry = new Map();
|
|
680
680
|
// Serve dashboard static files at root (built with `next build`)
|
|
681
681
|
// Search order:
|
|
682
|
-
// 1. ui-dist/ - @agent-relay/dashboard package (
|
|
683
|
-
// 2.
|
|
684
|
-
// 3.
|
|
682
|
+
// 1. ui-dist/ - @agent-relay/dashboard package (published)
|
|
683
|
+
// 2. ui/out - development (packages/dashboard/ui/out)
|
|
684
|
+
// 3. dist/dashboard/out - monorepo build
|
|
685
685
|
const findDashboardDir = () => {
|
|
686
686
|
let current = __dirname;
|
|
687
687
|
// Try up to 10 levels up
|
|
688
688
|
for (let i = 0; i < 10; i++) {
|
|
689
|
-
//
|
|
689
|
+
// Package structure: ui-dist at package root (for published package)
|
|
690
690
|
const uiDistPath = path.join(current, 'ui-dist');
|
|
691
691
|
if (fs.existsSync(uiDistPath))
|
|
692
692
|
return uiDistPath;
|
|
693
|
-
//
|
|
693
|
+
// Development: ui/out at package root
|
|
694
|
+
const uiOutPath = path.join(current, 'ui', 'out');
|
|
695
|
+
if (fs.existsSync(uiOutPath))
|
|
696
|
+
return uiOutPath;
|
|
697
|
+
// Monorepo build output
|
|
694
698
|
const distPath = path.join(current, 'dist', 'dashboard', 'out');
|
|
695
699
|
if (fs.existsSync(distPath))
|
|
696
700
|
return distPath;
|
|
697
|
-
// Legacy: development path
|
|
698
|
-
const srcPath = path.join(current, 'src', 'dashboard', 'out');
|
|
699
|
-
if (fs.existsSync(srcPath))
|
|
700
|
-
return srcPath;
|
|
701
701
|
const parent = path.dirname(current);
|
|
702
702
|
if (parent === current)
|
|
703
703
|
break; // reached root
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-relay/dashboard",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "Web dashboard for Agent Relay - optional package for visual agent coordination",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -25,17 +25,17 @@
|
|
|
25
25
|
"test:watch": "vitest"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@agent-relay/protocol": "2.0.
|
|
29
|
-
"@agent-relay/config": "2.0.
|
|
30
|
-
"@agent-relay/storage": "2.0.
|
|
31
|
-
"@agent-relay/bridge": "2.0.
|
|
32
|
-
"@agent-relay/utils": "2.0.
|
|
33
|
-
"@agent-relay/resiliency": "2.0.
|
|
34
|
-
"@agent-relay/trajectory": "2.0.
|
|
35
|
-
"@agent-relay/cloud": "2.0.
|
|
36
|
-
"@agent-relay/daemon": "2.0.
|
|
37
|
-
"@agent-relay/user-directory": "2.0.
|
|
38
|
-
"@agent-relay/wrapper": "2.0.
|
|
28
|
+
"@agent-relay/protocol": "2.0.7",
|
|
29
|
+
"@agent-relay/config": "2.0.7",
|
|
30
|
+
"@agent-relay/storage": "2.0.7",
|
|
31
|
+
"@agent-relay/bridge": "2.0.7",
|
|
32
|
+
"@agent-relay/utils": "2.0.7",
|
|
33
|
+
"@agent-relay/resiliency": "2.0.7",
|
|
34
|
+
"@agent-relay/trajectory": "2.0.7",
|
|
35
|
+
"@agent-relay/cloud": "2.0.7",
|
|
36
|
+
"@agent-relay/daemon": "2.0.7",
|
|
37
|
+
"@agent-relay/user-directory": "2.0.7",
|
|
38
|
+
"@agent-relay/wrapper": "2.0.7",
|
|
39
39
|
"express": "^5.2.1",
|
|
40
40
|
"ws": "^8.18.3"
|
|
41
41
|
},
|
|
@@ -17,9 +17,8 @@ import { SpawnModal, type SpawnConfig } from './SpawnModal';
|
|
|
17
17
|
import { NewConversationModal } from './NewConversationModal';
|
|
18
18
|
import { SettingsPage, defaultSettings, type Settings } from './settings';
|
|
19
19
|
import { ConversationHistory } from './ConversationHistory';
|
|
20
|
-
import {
|
|
20
|
+
import type { HumanUser } from './MentionAutocomplete';
|
|
21
21
|
import { NotificationToast, useToasts } from './NotificationToast';
|
|
22
|
-
import { FileAutocomplete, getFileQuery, completeFileInValue } from './FileAutocomplete';
|
|
23
22
|
import { WorkspaceSelector, type Workspace } from './WorkspaceSelector';
|
|
24
23
|
import { AddWorkspaceModal } from './AddWorkspaceModal';
|
|
25
24
|
import { LogViewerPanel } from './LogViewerPanel';
|
|
@@ -28,6 +27,7 @@ import { DecisionQueue, type Decision } from './DecisionQueue';
|
|
|
28
27
|
import { FleetOverview } from './FleetOverview';
|
|
29
28
|
import type { ServerInfo } from './ServerCard';
|
|
30
29
|
import { TypingIndicator } from './TypingIndicator';
|
|
30
|
+
import { MessageComposer } from './MessageComposer';
|
|
31
31
|
import { OnlineUsersIndicator } from './OnlineUsersIndicator';
|
|
32
32
|
import { UserProfilePanel } from './UserProfilePanel';
|
|
33
33
|
import { AgentProfilePanel } from './AgentProfilePanel';
|
|
@@ -1771,24 +1771,37 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) {
|
|
|
1771
1771
|
});
|
|
1772
1772
|
}, [currentHuman, dmSelectedAgentsByHuman]);
|
|
1773
1773
|
|
|
1774
|
-
const handleDmSend = useCallback(async (
|
|
1774
|
+
const handleDmSend = useCallback(async (content: string, attachmentIds?: string[]): Promise<boolean> => {
|
|
1775
1775
|
if (!currentHuman) return false;
|
|
1776
1776
|
const humanName = currentHuman.name;
|
|
1777
1777
|
|
|
1778
1778
|
// Always send to the human
|
|
1779
|
-
await sendMessage(humanName, content);
|
|
1779
|
+
await sendMessage(humanName, content, undefined, attachmentIds);
|
|
1780
1780
|
|
|
1781
1781
|
// Only send to agents if they were explicitly selected for this conversation
|
|
1782
1782
|
// Don't send to agents in pure 1:1 human conversations
|
|
1783
1783
|
if (selectedDmAgents.length > 0) {
|
|
1784
1784
|
for (const agent of selectedDmAgents) {
|
|
1785
|
-
await sendMessage(agent, content);
|
|
1785
|
+
await sendMessage(agent, content, undefined, attachmentIds);
|
|
1786
1786
|
}
|
|
1787
1787
|
}
|
|
1788
1788
|
|
|
1789
1789
|
return true;
|
|
1790
1790
|
}, [currentHuman, selectedDmAgents, sendMessage]);
|
|
1791
1791
|
|
|
1792
|
+
const handleMainComposerSend = useCallback(
|
|
1793
|
+
async (content: string, attachmentIds?: string[]) => {
|
|
1794
|
+
const recipient = currentChannel === 'general' ? '*' : currentChannel;
|
|
1795
|
+
|
|
1796
|
+
if (currentHuman) {
|
|
1797
|
+
return handleDmSend(content, attachmentIds);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
return sendMessage(recipient, content, undefined, attachmentIds);
|
|
1801
|
+
},
|
|
1802
|
+
[currentChannel, currentHuman, handleDmSend, sendMessage]
|
|
1803
|
+
);
|
|
1804
|
+
|
|
1792
1805
|
const dmInviteCommands = useMemo(() => {
|
|
1793
1806
|
if (!currentHuman) return [];
|
|
1794
1807
|
return agents
|
|
@@ -2690,15 +2703,16 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) {
|
|
|
2690
2703
|
{viewMode !== 'channels' && (
|
|
2691
2704
|
<div className="p-2 sm:p-4 bg-bg-tertiary border-t border-border-subtle">
|
|
2692
2705
|
<MessageComposer
|
|
2693
|
-
recipient={currentChannel === 'general' ? '*' : currentChannel}
|
|
2694
2706
|
agents={agents}
|
|
2695
2707
|
humanUsers={humanUsers}
|
|
2696
|
-
onSend={
|
|
2708
|
+
onSend={handleMainComposerSend}
|
|
2697
2709
|
onTyping={sendTyping}
|
|
2698
2710
|
isSending={isSending}
|
|
2699
2711
|
error={sendError}
|
|
2700
2712
|
insertMention={pendingMention}
|
|
2701
2713
|
onMentionInserted={() => setPendingMention(undefined)}
|
|
2714
|
+
enableFileAutocomplete
|
|
2715
|
+
placeholder={`Message ${currentChannel === 'general' ? 'everyone' : '@' + currentChannel}...`}
|
|
2702
2716
|
/>
|
|
2703
2717
|
</div>
|
|
2704
2718
|
)}
|
|
@@ -2974,434 +2988,6 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) {
|
|
|
2974
2988
|
);
|
|
2975
2989
|
}
|
|
2976
2990
|
|
|
2977
|
-
/**
|
|
2978
|
-
* Pending attachment interface for UI state
|
|
2979
|
-
*/
|
|
2980
|
-
interface PendingAttachment {
|
|
2981
|
-
id: string;
|
|
2982
|
-
file: File;
|
|
2983
|
-
preview: string;
|
|
2984
|
-
isUploading: boolean;
|
|
2985
|
-
uploadedId?: string;
|
|
2986
|
-
error?: string;
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
/**
|
|
2990
|
-
* Message Composer Component with @-mention autocomplete and image attachments
|
|
2991
|
-
*/
|
|
2992
|
-
interface MessageComposerProps {
|
|
2993
|
-
recipient: string;
|
|
2994
|
-
agents: Agent[];
|
|
2995
|
-
humanUsers: HumanUser[];
|
|
2996
|
-
onSend: (to: string, content: string, thread?: string, attachmentIds?: string[]) => Promise<boolean>;
|
|
2997
|
-
onTyping?: (isTyping: boolean) => void;
|
|
2998
|
-
isSending: boolean;
|
|
2999
|
-
error: string | null;
|
|
3000
|
-
insertMention?: string;
|
|
3001
|
-
onMentionInserted?: () => void;
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
function MessageComposer({ recipient, agents, humanUsers, onSend, onTyping, isSending, error, insertMention, onMentionInserted }: MessageComposerProps) {
|
|
3005
|
-
const [message, setMessage] = useState('');
|
|
3006
|
-
const [cursorPosition, setCursorPosition] = useState(0);
|
|
3007
|
-
const [showMentions, setShowMentions] = useState(false);
|
|
3008
|
-
const [showFiles, setShowFiles] = useState(false);
|
|
3009
|
-
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
3010
|
-
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
3011
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
3012
|
-
|
|
3013
|
-
// Handle insertMention prop - insert @username when triggered from outside
|
|
3014
|
-
useEffect(() => {
|
|
3015
|
-
if (insertMention && onMentionInserted) {
|
|
3016
|
-
const mentionText = `@${insertMention} `;
|
|
3017
|
-
// Insert at current cursor position or append to end
|
|
3018
|
-
const textarea = textareaRef.current;
|
|
3019
|
-
if (textarea) {
|
|
3020
|
-
const start = textarea.selectionStart || message.length;
|
|
3021
|
-
const newMessage = message.slice(0, start) + mentionText + message.slice(start);
|
|
3022
|
-
setMessage(newMessage);
|
|
3023
|
-
// Focus and set cursor position after the mention
|
|
3024
|
-
setTimeout(() => {
|
|
3025
|
-
textarea.focus();
|
|
3026
|
-
const newPos = start + mentionText.length;
|
|
3027
|
-
textarea.setSelectionRange(newPos, newPos);
|
|
3028
|
-
}, 0);
|
|
3029
|
-
} else {
|
|
3030
|
-
// Fallback: just append to message
|
|
3031
|
-
setMessage(prev => prev + mentionText);
|
|
3032
|
-
}
|
|
3033
|
-
onMentionInserted();
|
|
3034
|
-
}
|
|
3035
|
-
}, [insertMention, onMentionInserted, message]);
|
|
3036
|
-
|
|
3037
|
-
// Process image files (used by both paste and file input)
|
|
3038
|
-
const processImageFiles = useCallback(async (imageFiles: File[]) => {
|
|
3039
|
-
for (const file of imageFiles) {
|
|
3040
|
-
const id = crypto.randomUUID();
|
|
3041
|
-
const preview = URL.createObjectURL(file);
|
|
3042
|
-
|
|
3043
|
-
// Add to pending attachments
|
|
3044
|
-
setAttachments(prev => [...prev, {
|
|
3045
|
-
id,
|
|
3046
|
-
file,
|
|
3047
|
-
preview,
|
|
3048
|
-
isUploading: true,
|
|
3049
|
-
}]);
|
|
3050
|
-
|
|
3051
|
-
// Upload the file
|
|
3052
|
-
try {
|
|
3053
|
-
const result = await api.uploadAttachment(file);
|
|
3054
|
-
if (result.success && result.data) {
|
|
3055
|
-
setAttachments(prev => prev.map(a =>
|
|
3056
|
-
a.id === id
|
|
3057
|
-
? { ...a, isUploading: false, uploadedId: result.data!.attachment.id }
|
|
3058
|
-
: a
|
|
3059
|
-
));
|
|
3060
|
-
} else {
|
|
3061
|
-
setAttachments(prev => prev.map(a =>
|
|
3062
|
-
a.id === id
|
|
3063
|
-
? { ...a, isUploading: false, error: result.error || 'Upload failed' }
|
|
3064
|
-
: a
|
|
3065
|
-
));
|
|
3066
|
-
}
|
|
3067
|
-
} catch (err) {
|
|
3068
|
-
setAttachments(prev => prev.map(a =>
|
|
3069
|
-
a.id === id
|
|
3070
|
-
? { ...a, isUploading: false, error: 'Upload failed' }
|
|
3071
|
-
: a
|
|
3072
|
-
));
|
|
3073
|
-
}
|
|
3074
|
-
}
|
|
3075
|
-
}, []);
|
|
3076
|
-
|
|
3077
|
-
// Handle file selection from file input
|
|
3078
|
-
const handleFileSelect = useCallback((files: FileList | null) => {
|
|
3079
|
-
if (!files || files.length === 0) return;
|
|
3080
|
-
|
|
3081
|
-
const imageFiles = Array.from(files).filter(file =>
|
|
3082
|
-
file.type.startsWith('image/')
|
|
3083
|
-
);
|
|
3084
|
-
|
|
3085
|
-
if (imageFiles.length > 0) {
|
|
3086
|
-
processImageFiles(imageFiles);
|
|
3087
|
-
}
|
|
3088
|
-
}, [processImageFiles]);
|
|
3089
|
-
|
|
3090
|
-
// Handle paste for clipboard images
|
|
3091
|
-
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
3092
|
-
const clipboardData = e.clipboardData;
|
|
3093
|
-
if (!clipboardData) return;
|
|
3094
|
-
|
|
3095
|
-
// Collect image files from both sources
|
|
3096
|
-
let imageFiles: File[] = [];
|
|
3097
|
-
|
|
3098
|
-
// Method 1: Check clipboardData.files (works for file pastes)
|
|
3099
|
-
if (clipboardData.files && clipboardData.files.length > 0) {
|
|
3100
|
-
imageFiles = Array.from(clipboardData.files).filter(file =>
|
|
3101
|
-
file.type.startsWith('image/')
|
|
3102
|
-
);
|
|
3103
|
-
}
|
|
3104
|
-
|
|
3105
|
-
// Method 2: Check clipboardData.items (works for screenshots/copied images)
|
|
3106
|
-
// This is the primary method for pasted images from clipboard
|
|
3107
|
-
if (imageFiles.length === 0 && clipboardData.items) {
|
|
3108
|
-
const items = Array.from(clipboardData.items);
|
|
3109
|
-
for (const item of items) {
|
|
3110
|
-
// Check if this item is an image
|
|
3111
|
-
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
3112
|
-
const file = item.getAsFile();
|
|
3113
|
-
if (file) {
|
|
3114
|
-
imageFiles.push(file);
|
|
3115
|
-
}
|
|
3116
|
-
}
|
|
3117
|
-
}
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3120
|
-
// Process any found images
|
|
3121
|
-
if (imageFiles.length > 0) {
|
|
3122
|
-
e.preventDefault();
|
|
3123
|
-
processImageFiles(imageFiles);
|
|
3124
|
-
}
|
|
3125
|
-
}, [processImageFiles]);
|
|
3126
|
-
|
|
3127
|
-
// Remove an attachment
|
|
3128
|
-
const removeAttachment = useCallback((id: string) => {
|
|
3129
|
-
setAttachments(prev => {
|
|
3130
|
-
const attachment = prev.find(a => a.id === id);
|
|
3131
|
-
if (attachment) {
|
|
3132
|
-
URL.revokeObjectURL(attachment.preview);
|
|
3133
|
-
}
|
|
3134
|
-
return prev.filter(a => a.id !== id);
|
|
3135
|
-
});
|
|
3136
|
-
}, []);
|
|
3137
|
-
|
|
3138
|
-
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
3139
|
-
const value = e.target.value;
|
|
3140
|
-
const cursorPos = e.target.selectionStart || 0;
|
|
3141
|
-
setMessage(value);
|
|
3142
|
-
setCursorPosition(cursorPos);
|
|
3143
|
-
|
|
3144
|
-
// Send typing indicator when user has content
|
|
3145
|
-
onTyping?.(value.trim().length > 0);
|
|
3146
|
-
|
|
3147
|
-
// Check for file autocomplete first (@ followed by path-like pattern)
|
|
3148
|
-
const fileQuery = getFileQuery(value, cursorPos);
|
|
3149
|
-
if (fileQuery !== null) {
|
|
3150
|
-
setShowFiles(true);
|
|
3151
|
-
setShowMentions(false);
|
|
3152
|
-
return;
|
|
3153
|
-
}
|
|
3154
|
-
|
|
3155
|
-
// Check for mention autocomplete (@ at start without path patterns)
|
|
3156
|
-
const mentionQuery = getMentionQuery(value, cursorPos);
|
|
3157
|
-
if (mentionQuery !== null) {
|
|
3158
|
-
setShowMentions(true);
|
|
3159
|
-
setShowFiles(false);
|
|
3160
|
-
return;
|
|
3161
|
-
}
|
|
3162
|
-
|
|
3163
|
-
// Neither - hide both
|
|
3164
|
-
setShowMentions(false);
|
|
3165
|
-
setShowFiles(false);
|
|
3166
|
-
};
|
|
3167
|
-
|
|
3168
|
-
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
3169
|
-
// Don't handle Enter/Tab when autocomplete is visible (let autocomplete handle it)
|
|
3170
|
-
if ((showMentions || showFiles) && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Tab')) {
|
|
3171
|
-
return; // Let the autocomplete component handle these keys
|
|
3172
|
-
}
|
|
3173
|
-
|
|
3174
|
-
if (e.key === 'Enter' && !e.shiftKey && !showMentions && !showFiles) {
|
|
3175
|
-
e.preventDefault();
|
|
3176
|
-
if ((message.trim() || attachments.length > 0) && !isSending) {
|
|
3177
|
-
handleSubmit(e as unknown as React.FormEvent);
|
|
3178
|
-
}
|
|
3179
|
-
}
|
|
3180
|
-
};
|
|
3181
|
-
|
|
3182
|
-
const handleMentionSelect = (mention: string, newValue: string) => {
|
|
3183
|
-
setMessage(newValue);
|
|
3184
|
-
setShowMentions(false);
|
|
3185
|
-
setShowFiles(false);
|
|
3186
|
-
setTimeout(() => {
|
|
3187
|
-
if (textareaRef.current) {
|
|
3188
|
-
textareaRef.current.focus();
|
|
3189
|
-
const pos = newValue.indexOf(' ') + 1;
|
|
3190
|
-
textareaRef.current.setSelectionRange(pos, pos);
|
|
3191
|
-
}
|
|
3192
|
-
}, 0);
|
|
3193
|
-
};
|
|
3194
|
-
|
|
3195
|
-
const handleFilePathSelect = (filePath: string, newValue: string) => {
|
|
3196
|
-
setMessage(newValue);
|
|
3197
|
-
setShowFiles(false);
|
|
3198
|
-
setShowMentions(false);
|
|
3199
|
-
setTimeout(() => {
|
|
3200
|
-
if (textareaRef.current) {
|
|
3201
|
-
textareaRef.current.focus();
|
|
3202
|
-
const pos = newValue.indexOf(' ', 1) + 1; // After @path/to/file<space>
|
|
3203
|
-
textareaRef.current.setSelectionRange(pos, pos);
|
|
3204
|
-
}
|
|
3205
|
-
}, 0);
|
|
3206
|
-
};
|
|
3207
|
-
|
|
3208
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
3209
|
-
e.preventDefault();
|
|
3210
|
-
|
|
3211
|
-
// Need either message or attachments
|
|
3212
|
-
const hasMessage = message.trim().length > 0;
|
|
3213
|
-
const hasAttachments = attachments.length > 0;
|
|
3214
|
-
if ((!hasMessage && !hasAttachments) || isSending) return;
|
|
3215
|
-
|
|
3216
|
-
// Check if any attachments are still uploading
|
|
3217
|
-
const stillUploading = attachments.some(a => a.isUploading);
|
|
3218
|
-
if (stillUploading) return;
|
|
3219
|
-
|
|
3220
|
-
// Get uploaded attachment IDs
|
|
3221
|
-
const attachmentIds = attachments
|
|
3222
|
-
.filter(a => a.uploadedId)
|
|
3223
|
-
.map(a => a.uploadedId!);
|
|
3224
|
-
|
|
3225
|
-
const mentionMatch = message.match(/^@(\S+)\s*([\s\S]*)/);
|
|
3226
|
-
let target: string;
|
|
3227
|
-
let content: string;
|
|
3228
|
-
|
|
3229
|
-
if (mentionMatch) {
|
|
3230
|
-
const mentionedName = mentionMatch[1];
|
|
3231
|
-
content = mentionMatch[2] || '';
|
|
3232
|
-
|
|
3233
|
-
if (mentionedName === '*' || mentionedName.toLowerCase() === 'everyone' || mentionedName.toLowerCase() === 'all') {
|
|
3234
|
-
target = '*';
|
|
3235
|
-
} else if (mentionedName.toLowerCase().startsWith('team:')) {
|
|
3236
|
-
// Team mention - pass through to backend (e.g., team:frontend)
|
|
3237
|
-
target = mentionedName;
|
|
3238
|
-
} else {
|
|
3239
|
-
// Check if this is a file path mention (contains / or ends with common file extensions)
|
|
3240
|
-
// If so, keep the full message as content and use default recipient
|
|
3241
|
-
if (mentionedName.includes('/') || /\.(ts|tsx|js|jsx|json|md|py|go|rs|java|c|cpp|h|css|html|yaml|yml|toml)$/i.test(mentionedName)) {
|
|
3242
|
-
target = recipient;
|
|
3243
|
-
content = message; // Keep the @path/to/file in the message
|
|
3244
|
-
} else {
|
|
3245
|
-
target = mentionedName;
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3248
|
-
} else {
|
|
3249
|
-
target = recipient;
|
|
3250
|
-
content = message;
|
|
3251
|
-
}
|
|
3252
|
-
|
|
3253
|
-
// If no message but has attachments, send with default text
|
|
3254
|
-
if (!content.trim() && attachmentIds.length > 0) {
|
|
3255
|
-
content = '[Screenshot attached]';
|
|
3256
|
-
}
|
|
3257
|
-
|
|
3258
|
-
const success = await onSend(
|
|
3259
|
-
target,
|
|
3260
|
-
content || message,
|
|
3261
|
-
undefined,
|
|
3262
|
-
attachmentIds.length > 0 ? attachmentIds : undefined
|
|
3263
|
-
);
|
|
3264
|
-
|
|
3265
|
-
if (success) {
|
|
3266
|
-
// Clean up previews
|
|
3267
|
-
attachments.forEach(a => URL.revokeObjectURL(a.preview));
|
|
3268
|
-
setMessage('');
|
|
3269
|
-
setAttachments([]);
|
|
3270
|
-
setShowMentions(false);
|
|
3271
|
-
setShowFiles(false);
|
|
3272
|
-
}
|
|
3273
|
-
};
|
|
3274
|
-
|
|
3275
|
-
// Check if we can send (have content or attachments, not uploading)
|
|
3276
|
-
const canSend = (message.trim() || attachments.length > 0) &&
|
|
3277
|
-
!isSending &&
|
|
3278
|
-
!attachments.some(a => a.isUploading);
|
|
3279
|
-
|
|
3280
|
-
return (
|
|
3281
|
-
<form className="flex flex-col gap-1.5 sm:gap-2" onSubmit={handleSubmit}>
|
|
3282
|
-
{/* Attachment previews */}
|
|
3283
|
-
{attachments.length > 0 && (
|
|
3284
|
-
<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">
|
|
3285
|
-
{attachments.map(attachment => (
|
|
3286
|
-
<div
|
|
3287
|
-
key={attachment.id}
|
|
3288
|
-
className="relative group"
|
|
3289
|
-
>
|
|
3290
|
-
<img
|
|
3291
|
-
src={attachment.preview}
|
|
3292
|
-
alt={attachment.file.name}
|
|
3293
|
-
className={`h-16 w-auto rounded-lg object-cover ${attachment.isUploading ? 'opacity-50' : ''} ${attachment.error ? 'border-2 border-error' : ''}`}
|
|
3294
|
-
/>
|
|
3295
|
-
{attachment.isUploading && (
|
|
3296
|
-
<div className="absolute inset-0 flex items-center justify-center">
|
|
3297
|
-
<svg className="animate-spin h-5 w-5 text-accent-cyan" viewBox="0 0 24 24">
|
|
3298
|
-
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" strokeDasharray="32" strokeLinecap="round" />
|
|
3299
|
-
</svg>
|
|
3300
|
-
</div>
|
|
3301
|
-
)}
|
|
3302
|
-
{attachment.error && (
|
|
3303
|
-
<div className="absolute bottom-0 left-0 right-0 bg-error/90 text-white text-[10px] px-1 py-0.5 truncate">
|
|
3304
|
-
{attachment.error}
|
|
3305
|
-
</div>
|
|
3306
|
-
)}
|
|
3307
|
-
<button
|
|
3308
|
-
type="button"
|
|
3309
|
-
onClick={() => removeAttachment(attachment.id)}
|
|
3310
|
-
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"
|
|
3311
|
-
title="Remove"
|
|
3312
|
-
>
|
|
3313
|
-
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
3314
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
3315
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
3316
|
-
</svg>
|
|
3317
|
-
</button>
|
|
3318
|
-
</div>
|
|
3319
|
-
))}
|
|
3320
|
-
</div>
|
|
3321
|
-
)}
|
|
3322
|
-
|
|
3323
|
-
{/* Input row */}
|
|
3324
|
-
<div className="flex items-center gap-1.5 sm:gap-3">
|
|
3325
|
-
{/* Image upload button */}
|
|
3326
|
-
<input
|
|
3327
|
-
ref={fileInputRef}
|
|
3328
|
-
type="file"
|
|
3329
|
-
accept="image/*"
|
|
3330
|
-
multiple
|
|
3331
|
-
className="hidden"
|
|
3332
|
-
onChange={(e) => handleFileSelect(e.target.files)}
|
|
3333
|
-
/>
|
|
3334
|
-
<button
|
|
3335
|
-
type="button"
|
|
3336
|
-
onClick={() => fileInputRef.current?.click()}
|
|
3337
|
-
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"
|
|
3338
|
-
title="Attach screenshot (or paste from clipboard)"
|
|
3339
|
-
>
|
|
3340
|
-
<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]">
|
|
3341
|
-
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
3342
|
-
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
3343
|
-
<polyline points="21 15 16 10 5 21" />
|
|
3344
|
-
</svg>
|
|
3345
|
-
</button>
|
|
3346
|
-
|
|
3347
|
-
<div className="flex-1 relative min-w-0">
|
|
3348
|
-
{/* Agent mention autocomplete */}
|
|
3349
|
-
<MentionAutocomplete
|
|
3350
|
-
agents={agents}
|
|
3351
|
-
humanUsers={humanUsers}
|
|
3352
|
-
inputValue={message}
|
|
3353
|
-
cursorPosition={cursorPosition}
|
|
3354
|
-
onSelect={handleMentionSelect}
|
|
3355
|
-
onClose={() => setShowMentions(false)}
|
|
3356
|
-
isVisible={showMentions}
|
|
3357
|
-
/>
|
|
3358
|
-
{/* File path autocomplete */}
|
|
3359
|
-
<FileAutocomplete
|
|
3360
|
-
inputValue={message}
|
|
3361
|
-
cursorPosition={cursorPosition}
|
|
3362
|
-
onSelect={handleFilePathSelect}
|
|
3363
|
-
onClose={() => setShowFiles(false)}
|
|
3364
|
-
isVisible={showFiles}
|
|
3365
|
-
/>
|
|
3366
|
-
<textarea
|
|
3367
|
-
ref={textareaRef}
|
|
3368
|
-
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"
|
|
3369
|
-
placeholder={`Message ${recipient === '*' ? 'everyone' : '@' + recipient}...`}
|
|
3370
|
-
value={message}
|
|
3371
|
-
onChange={handleInputChange}
|
|
3372
|
-
onKeyDown={handleKeyDown}
|
|
3373
|
-
onPaste={handlePaste}
|
|
3374
|
-
onSelect={(e) => setCursorPosition((e.target as HTMLTextAreaElement).selectionStart || 0)}
|
|
3375
|
-
disabled={isSending}
|
|
3376
|
-
rows={1}
|
|
3377
|
-
/>
|
|
3378
|
-
</div>
|
|
3379
|
-
<button
|
|
3380
|
-
type="submit"
|
|
3381
|
-
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"
|
|
3382
|
-
disabled={!canSend}
|
|
3383
|
-
title={isSending ? 'Sending...' : attachments.some(a => a.isUploading) ? 'Uploading...' : 'Send message'}
|
|
3384
|
-
>
|
|
3385
|
-
{isSending ? (
|
|
3386
|
-
<span className="hidden sm:inline">Sending...</span>
|
|
3387
|
-
) : attachments.some(a => a.isUploading) ? (
|
|
3388
|
-
<span className="hidden sm:inline">Uploading...</span>
|
|
3389
|
-
) : (
|
|
3390
|
-
<span className="flex items-center gap-1 sm:gap-2">
|
|
3391
|
-
<span className="hidden sm:inline">Send</span>
|
|
3392
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
3393
|
-
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
3394
|
-
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
3395
|
-
</svg>
|
|
3396
|
-
</span>
|
|
3397
|
-
)}
|
|
3398
|
-
</button>
|
|
3399
|
-
{error && <span className="text-error text-xs ml-2">{error}</span>}
|
|
3400
|
-
</div>
|
|
3401
|
-
</form>
|
|
3402
|
-
);
|
|
3403
|
-
}
|
|
3404
|
-
|
|
3405
2991
|
function LoadingSpinner() {
|
|
3406
2992
|
return (
|
|
3407
2993
|
<svg className="animate-spin mb-4 text-accent-cyan" width="28" height="28" viewBox="0 0 24 24">
|