@agent-relay/dashboard 2.0.80 → 2.0.82
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/out/404.html +1 -1
- package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
- package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +1 -1
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +7 -1
- package/src/app/about/page.tsx +7 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
- package/src/app/app/[[...slug]]/page.tsx +23 -0
- package/src/app/app/onboarding/page.tsx +394 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
- package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
- package/src/app/blog/page.tsx +15 -0
- package/src/app/careers/page.tsx +7 -0
- package/src/app/changelog/page.tsx +7 -0
- package/src/app/cloud/link/page.tsx +464 -0
- package/src/app/complete-profile/page.tsx +204 -0
- package/src/app/connect-repos/page.tsx +410 -0
- package/src/app/contact/page.tsx +7 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/favicon.png +0 -0
- package/src/app/globals.css +200 -0
- package/src/app/history/page.tsx +658 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/login/page.tsx +424 -0
- package/src/app/metrics/page.tsx +781 -0
- package/src/app/page.tsx +59 -0
- package/src/app/pricing/page.tsx +7 -0
- package/src/app/privacy/page.tsx +7 -0
- package/src/app/providers/page.tsx +193 -0
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
- package/src/app/providers/setup/[provider]/constants.ts +35 -0
- package/src/app/providers/setup/[provider]/page.tsx +42 -0
- package/src/app/security/page.tsx +7 -0
- package/src/app/signup/page.tsx +533 -0
- package/src/app/terms/page.tsx +7 -0
- package/src/components/ActivityFeed.tsx +216 -0
- package/src/components/AddWorkspaceModal.tsx +170 -0
- package/src/components/AgentCard.test.tsx +134 -0
- package/src/components/AgentCard.tsx +585 -0
- package/src/components/AgentList.test.tsx +147 -0
- package/src/components/AgentList.tsx +419 -0
- package/src/components/AgentLogPreview.tsx +173 -0
- package/src/components/AgentProfilePanel.tsx +569 -0
- package/src/components/App.tsx +3424 -0
- package/src/components/BillingPanel.tsx +922 -0
- package/src/components/BillingResult.tsx +447 -0
- package/src/components/BroadcastComposer.tsx +690 -0
- package/src/components/ChannelAdminPanel.tsx +773 -0
- package/src/components/ChannelBrowser.tsx +385 -0
- package/src/components/ChannelChat.tsx +261 -0
- package/src/components/ChannelSidebar.tsx +399 -0
- package/src/components/CloudSessionProvider.tsx +130 -0
- package/src/components/CommandPalette.tsx +815 -0
- package/src/components/ConfirmationDialog.tsx +133 -0
- package/src/components/ConversationHistory.tsx +518 -0
- package/src/components/CoordinatorPanel.tsx +956 -0
- package/src/components/DecisionQueue.tsx +717 -0
- package/src/components/DirectMessageView.tsx +164 -0
- package/src/components/FileAutocomplete.tsx +368 -0
- package/src/components/FleetOverview.tsx +278 -0
- package/src/components/LogViewer.tsx +310 -0
- package/src/components/LogViewerPanel.tsx +482 -0
- package/src/components/Logo.tsx +284 -0
- package/src/components/MentionAutocomplete.tsx +384 -0
- package/src/components/MessageComposer.tsx +473 -0
- package/src/components/MessageList.tsx +725 -0
- package/src/components/MessageSenderName.tsx +91 -0
- package/src/components/MessageStatusIndicator.tsx +142 -0
- package/src/components/NewConversationModal.tsx +400 -0
- package/src/components/NotificationToast.tsx +488 -0
- package/src/components/OnlineUsersIndicator.tsx +164 -0
- package/src/components/Pagination.tsx +124 -0
- package/src/components/PricingPlans.tsx +386 -0
- package/src/components/ProjectList.tsx +711 -0
- package/src/components/ProviderAuthFlow.tsx +343 -0
- package/src/components/ProviderConnectionList.tsx +375 -0
- package/src/components/ProvisioningProgress.tsx +730 -0
- package/src/components/ReactionChips.tsx +70 -0
- package/src/components/ReactionPicker.tsx +121 -0
- package/src/components/RepoAccessPanel.tsx +787 -0
- package/src/components/RepositoriesPanel.tsx +901 -0
- package/src/components/ServerCard.tsx +202 -0
- package/src/components/SessionExpiredModal.tsx +128 -0
- package/src/components/SpawnModal.test.tsx +190 -0
- package/src/components/SpawnModal.tsx +1001 -0
- package/src/components/TaskAssignmentUI.tsx +375 -0
- package/src/components/TerminalProviderSetup.tsx +517 -0
- package/src/components/ThemeProvider.tsx +159 -0
- package/src/components/ThinkingIndicator.tsx +231 -0
- package/src/components/ThreadList.tsx +198 -0
- package/src/components/ThreadPanel.tsx +405 -0
- package/src/components/TrajectoryViewer.tsx +698 -0
- package/src/components/TypingIndicator.tsx +69 -0
- package/src/components/UsageBanner.tsx +231 -0
- package/src/components/UserProfilePanel.tsx +233 -0
- package/src/components/WorkspaceContext.tsx +95 -0
- package/src/components/WorkspaceSelector.tsx +234 -0
- package/src/components/WorkspaceStatusIndicator.tsx +396 -0
- package/src/components/XTermInteractive.tsx +516 -0
- package/src/components/XTermLogViewer.tsx +719 -0
- package/src/components/channels/ChannelDialogs.tsx +1411 -0
- package/src/components/channels/ChannelHeader.tsx +317 -0
- package/src/components/channels/ChannelMessageList.tsx +463 -0
- package/src/components/channels/ChannelViewV1.tsx +146 -0
- package/src/components/channels/MessageInput.tsx +302 -0
- package/src/components/channels/SearchInput.tsx +172 -0
- package/src/components/channels/SearchResults.tsx +336 -0
- package/src/components/channels/api.test.ts +1527 -0
- package/src/components/channels/api.ts +703 -0
- package/src/components/channels/index.ts +76 -0
- package/src/components/channels/mockApi.ts +344 -0
- package/src/components/channels/types.ts +566 -0
- package/src/components/hooks/index.ts +58 -0
- package/src/components/hooks/useAgentLogs.ts +504 -0
- package/src/components/hooks/useAgents.ts +127 -0
- package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
- package/src/components/hooks/useBroadcastDedup.ts +86 -0
- package/src/components/hooks/useChannelAdmin.ts +329 -0
- package/src/components/hooks/useChannelBrowser.ts +239 -0
- package/src/components/hooks/useChannelCommands.ts +138 -0
- package/src/components/hooks/useChannels.ts +367 -0
- package/src/components/hooks/useDebounce.ts +29 -0
- package/src/components/hooks/useDirectMessage.test.ts +952 -0
- package/src/components/hooks/useDirectMessage.ts +141 -0
- package/src/components/hooks/useMessages.ts +310 -0
- package/src/components/hooks/useOrchestrator.test.ts +165 -0
- package/src/components/hooks/useOrchestrator.ts +424 -0
- package/src/components/hooks/usePinnedAgents.test.ts +356 -0
- package/src/components/hooks/usePinnedAgents.ts +140 -0
- package/src/components/hooks/usePresence.test.ts +245 -0
- package/src/components/hooks/usePresence.ts +377 -0
- package/src/components/hooks/useRecentRepos.ts +130 -0
- package/src/components/hooks/useSession.ts +209 -0
- package/src/components/hooks/useThread.ts +138 -0
- package/src/components/hooks/useTrajectory.ts +265 -0
- package/src/components/hooks/useWebSocket.ts +290 -0
- package/src/components/hooks/useWorkspaceMembers.ts +132 -0
- package/src/components/hooks/useWorkspaceRepos.ts +73 -0
- package/src/components/hooks/useWorkspaceStatus.ts +237 -0
- package/src/components/index.ts +81 -0
- package/src/components/layout/Header.tsx +311 -0
- package/src/components/layout/RepoContextHeader.tsx +361 -0
- package/src/components/layout/Sidebar.archive.test.tsx +126 -0
- package/src/components/layout/Sidebar.test.tsx +691 -0
- package/src/components/layout/Sidebar.tsx +900 -0
- package/src/components/layout/index.ts +7 -0
- package/src/components/settings/BillingSettingsPanel.tsx +564 -0
- package/src/components/settings/SettingsPage.tsx +683 -0
- package/src/components/settings/TeamSettingsPanel.tsx +560 -0
- package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
- package/src/components/settings/index.ts +11 -0
- package/src/components/settings/types.ts +79 -0
- package/src/components/utils/messageFormatting.test.tsx +331 -0
- package/src/components/utils/messageFormatting.tsx +597 -0
- package/src/index.ts +63 -0
- package/src/landing/AboutPage.tsx +77 -0
- package/src/landing/BlogContent.tsx +187 -0
- package/src/landing/BlogPage.tsx +47 -0
- package/src/landing/CareersPage.tsx +53 -0
- package/src/landing/ChangelogPage.tsx +33 -0
- package/src/landing/ContactPage.tsx +41 -0
- package/src/landing/DocsPage.tsx +43 -0
- package/src/landing/LandingPage.tsx +702 -0
- package/src/landing/PricingPage.tsx +549 -0
- package/src/landing/PrivacyPage.tsx +117 -0
- package/src/landing/SecurityPage.tsx +42 -0
- package/src/landing/StaticPage.tsx +165 -0
- package/src/landing/TermsPage.tsx +125 -0
- package/src/landing/blogData.ts +312 -0
- package/src/landing/index.ts +18 -0
- package/src/landing/styles.css +3673 -0
- package/src/lib/agent-merge.test.ts +43 -0
- package/src/lib/agent-merge.ts +35 -0
- package/src/lib/api.ts +1294 -0
- package/src/lib/cloudApi.ts +893 -0
- package/src/lib/colors.test.ts +175 -0
- package/src/lib/colors.ts +218 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/hierarchy.ts +242 -0
- package/src/lib/stuckDetection.ts +142 -0
- package/src/lib/useUrlRouting.ts +190 -0
- package/src/types/index.ts +317 -0
- package/src/types/threading.ts +7 -0
- package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
- package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
- /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
- /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard V2 - Main App Page (Client Component)
|
|
3
|
+
*
|
|
4
|
+
* In cloud mode: Shows workspace selection and connects to selected workspace's dashboard.
|
|
5
|
+
* In local mode: Connects to local daemon WebSocket.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
11
|
+
import { App } from '../../../components/App';
|
|
12
|
+
import { CloudSessionProvider } from '../../../components/CloudSessionProvider';
|
|
13
|
+
import { LogoIcon } from '../../../components/Logo';
|
|
14
|
+
import { setActiveWorkspaceId } from '../../../lib/api';
|
|
15
|
+
import { ProvisioningProgress } from '../../../components/ProvisioningProgress';
|
|
16
|
+
import { ProviderConnectionList, type ProviderInfo } from '../../../components/ProviderConnectionList';
|
|
17
|
+
|
|
18
|
+
interface Workspace {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
status: 'provisioning' | 'running' | 'stopped' | 'error';
|
|
22
|
+
publicUrl?: string;
|
|
23
|
+
providers?: string[];
|
|
24
|
+
repositories?: string[];
|
|
25
|
+
createdAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Repository {
|
|
29
|
+
id: string;
|
|
30
|
+
fullName: string;
|
|
31
|
+
isPrivate: boolean;
|
|
32
|
+
defaultBranch: string;
|
|
33
|
+
syncStatus: string;
|
|
34
|
+
hasNangoConnection: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type PageState = 'loading' | 'local' | 'select-workspace' | 'no-workspaces' | 'provisioning' | 'connect-provider' | 'connecting' | 'connected' | 'error' | 'create-workspace';
|
|
38
|
+
|
|
39
|
+
interface ProvisioningInfo {
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
workspaceName: string;
|
|
42
|
+
stage: string | null;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Available AI providers
|
|
47
|
+
const AI_PROVIDERS: ProviderInfo[] = [
|
|
48
|
+
{ id: 'anthropic', name: 'Anthropic', displayName: 'Claude', color: '#D97757', cliCommand: 'claude', requiresUrlCopy: true },
|
|
49
|
+
{ id: 'codex', name: 'OpenAI', displayName: 'Codex', color: '#10A37F', cliCommand: 'codex login', requiresUrlCopy: true },
|
|
50
|
+
{ id: 'google', name: 'Google', displayName: 'Gemini', color: '#4285F4', cliCommand: 'gemini' },
|
|
51
|
+
{ id: 'opencode', name: 'OpenCode', displayName: 'OpenCode', color: '#00D4AA', cliCommand: 'opencode' },
|
|
52
|
+
{ id: 'droid', name: 'Factory', displayName: 'Droid', color: '#6366F1', cliCommand: 'droid' },
|
|
53
|
+
{ id: 'cursor', name: 'Cursor', displayName: 'Cursor', color: '#7C3AED', cliCommand: 'agent', requiresUrlCopy: true },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Force cloud mode via env var - prevents silent fallback to local mode
|
|
57
|
+
const FORCE_CLOUD_MODE = process.env.NEXT_PUBLIC_FORCE_CLOUD_MODE === 'true';
|
|
58
|
+
|
|
59
|
+
export default function DashboardPageClient() {
|
|
60
|
+
const [state, setState] = useState<PageState>('loading');
|
|
61
|
+
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
62
|
+
const [repos, setRepos] = useState<Repository[]>([]);
|
|
63
|
+
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(null);
|
|
64
|
+
const [wsUrl, setWsUrl] = useState<string | undefined>(undefined);
|
|
65
|
+
const [error, setError] = useState<string | null>(null);
|
|
66
|
+
// Track cloud mode for potential future use
|
|
67
|
+
const [_isCloudMode, setIsCloudMode] = useState(FORCE_CLOUD_MODE);
|
|
68
|
+
const [csrfToken, setCsrfToken] = useState<string | null>(null);
|
|
69
|
+
const [provisioningInfo, setProvisioningInfo] = useState<ProvisioningInfo | null>(null);
|
|
70
|
+
const [connectedProviders, setConnectedProviders] = useState<string[]>([]);
|
|
71
|
+
|
|
72
|
+
// Check if we're in cloud mode and fetch data
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const init = async () => {
|
|
75
|
+
try {
|
|
76
|
+
// Check session to determine if we're in cloud mode
|
|
77
|
+
const sessionRes = await fetch('/api/auth/session', { credentials: 'include' });
|
|
78
|
+
|
|
79
|
+
// If session endpoint doesn't exist (404), we're in local mode
|
|
80
|
+
if (sessionRes.status === 404) {
|
|
81
|
+
if (FORCE_CLOUD_MODE) {
|
|
82
|
+
throw new Error('Cloud mode enforced but session endpoint returned 404. Is the cloud server running?');
|
|
83
|
+
}
|
|
84
|
+
setIsCloudMode(false);
|
|
85
|
+
setState('local');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Capture CSRF token from response header
|
|
90
|
+
const token = sessionRes.headers.get('X-CSRF-Token');
|
|
91
|
+
if (token) {
|
|
92
|
+
setCsrfToken(token);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const session = await sessionRes.json();
|
|
96
|
+
|
|
97
|
+
if (!session.authenticated) {
|
|
98
|
+
// Cloud mode but not authenticated - redirect to login
|
|
99
|
+
window.location.href = '/login';
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Cloud mode - fetch workspaces and repos
|
|
104
|
+
setIsCloudMode(true);
|
|
105
|
+
|
|
106
|
+
// Track which providers are already connected
|
|
107
|
+
// Map backend IDs to frontend IDs for consistency
|
|
108
|
+
const BACKEND_TO_FRONTEND_MAP: Record<string, string> = {
|
|
109
|
+
openai: 'codex', // Backend stores 'openai', frontend uses 'codex'
|
|
110
|
+
};
|
|
111
|
+
if (session.connectedProviders) {
|
|
112
|
+
const providers: string[] = [];
|
|
113
|
+
session.connectedProviders.forEach((p: { provider: string }) => {
|
|
114
|
+
providers.push(p.provider);
|
|
115
|
+
// Also add the frontend ID if there's a mapping
|
|
116
|
+
const frontendId = BACKEND_TO_FRONTEND_MAP[p.provider];
|
|
117
|
+
if (frontendId) {
|
|
118
|
+
providers.push(frontendId);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
setConnectedProviders(providers);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [workspacesRes, reposRes] = await Promise.all([
|
|
125
|
+
// Use /accessible to include workspaces user can access via GitHub repo permissions
|
|
126
|
+
fetch('/api/workspaces/accessible', { credentials: 'include' }),
|
|
127
|
+
fetch('/api/github-app/repos', { credentials: 'include' }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
if (!workspacesRes.ok) {
|
|
131
|
+
if (workspacesRes.status === 401) {
|
|
132
|
+
window.location.href = '/login';
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
throw new Error('Failed to fetch workspaces');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const workspacesData = await workspacesRes.json();
|
|
139
|
+
const reposData = reposRes.ok ? await reposRes.json() : { repositories: [] };
|
|
140
|
+
|
|
141
|
+
setWorkspaces(workspacesData.workspaces || []);
|
|
142
|
+
setRepos(reposData.repositories || []);
|
|
143
|
+
|
|
144
|
+
// Determine next state based on workspace availability
|
|
145
|
+
const runningWorkspaces = (workspacesData.workspaces || []).filter(
|
|
146
|
+
(w: Workspace) => w.status === 'running' && w.publicUrl
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Check if user explicitly wants to see workspace picker (from "Add Workspace" button)
|
|
150
|
+
const urlParams = typeof window !== 'undefined'
|
|
151
|
+
? new URLSearchParams(window.location.search)
|
|
152
|
+
: null;
|
|
153
|
+
const forceShowPicker = urlParams?.get('select') === 'true';
|
|
154
|
+
|
|
155
|
+
// If user explicitly requested the picker, show it and clean up URL
|
|
156
|
+
if (forceShowPicker) {
|
|
157
|
+
// Remove query param from URL without reload
|
|
158
|
+
window.history.replaceState({}, '', '/app');
|
|
159
|
+
setState('select-workspace');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check for previously connected workspace (stored in localStorage)
|
|
164
|
+
// This enables seamless reconnection on page reload
|
|
165
|
+
const savedWorkspaceId = typeof window !== 'undefined'
|
|
166
|
+
? localStorage.getItem('agentrelay_workspace_id')
|
|
167
|
+
: null;
|
|
168
|
+
|
|
169
|
+
if (savedWorkspaceId) {
|
|
170
|
+
const savedWorkspace = runningWorkspaces.find((w: Workspace) => w.id === savedWorkspaceId);
|
|
171
|
+
if (savedWorkspace) {
|
|
172
|
+
// Auto-reconnect to previously selected workspace
|
|
173
|
+
connectToWorkspace(savedWorkspace);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (runningWorkspaces.length === 1) {
|
|
179
|
+
// Auto-connect to the only running workspace
|
|
180
|
+
connectToWorkspace(runningWorkspaces[0]);
|
|
181
|
+
} else if (runningWorkspaces.length > 1) {
|
|
182
|
+
setState('select-workspace');
|
|
183
|
+
} else if ((workspacesData.workspaces || []).length > 0) {
|
|
184
|
+
// Has workspaces but none running
|
|
185
|
+
setState('select-workspace');
|
|
186
|
+
} else if ((reposData.repositories || []).length > 0) {
|
|
187
|
+
// Has repos but no workspaces - show create workspace
|
|
188
|
+
setState('no-workspaces');
|
|
189
|
+
} else {
|
|
190
|
+
// No repos, no workspaces - redirect to connect repos
|
|
191
|
+
window.location.href = '/connect-repos';
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// If session check fails with network error, assume local mode (unless forced cloud)
|
|
195
|
+
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
|
|
196
|
+
if (FORCE_CLOUD_MODE) {
|
|
197
|
+
console.error('Cloud mode enforced but network request failed:', err);
|
|
198
|
+
setError('Cloud mode enforced but failed to connect to server. Is the cloud server running?');
|
|
199
|
+
setState('error');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
setIsCloudMode(false);
|
|
203
|
+
setState('local');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
console.error('Init error:', err);
|
|
207
|
+
setError(err instanceof Error ? err.message : 'Failed to initialize');
|
|
208
|
+
setState('error');
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
init();
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
const connectToWorkspace = useCallback((workspace: Workspace) => {
|
|
216
|
+
if (!workspace.publicUrl) {
|
|
217
|
+
setError('Workspace has no public URL');
|
|
218
|
+
setState('error');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setSelectedWorkspace(workspace);
|
|
223
|
+
setState('connecting');
|
|
224
|
+
|
|
225
|
+
// Set the active workspace ID for API proxying
|
|
226
|
+
setActiveWorkspaceId(workspace.id);
|
|
227
|
+
|
|
228
|
+
// Derive WebSocket URL from public URL
|
|
229
|
+
// e.g., https://workspace-abc.agentrelay.dev -> wss://workspace-abc.agentrelay.dev/ws
|
|
230
|
+
const url = new URL(workspace.publicUrl);
|
|
231
|
+
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
232
|
+
const derivedWsUrl = `${wsProtocol}//${url.host}/ws`;
|
|
233
|
+
|
|
234
|
+
setWsUrl(derivedWsUrl);
|
|
235
|
+
setState('connected');
|
|
236
|
+
}, []);
|
|
237
|
+
|
|
238
|
+
const handleCreateWorkspace = useCallback(async (repoFullName: string) => {
|
|
239
|
+
setError(null);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
243
|
+
if (csrfToken) {
|
|
244
|
+
headers['X-CSRF-Token'] = csrfToken;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const res = await fetch('/api/workspaces/quick', {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
credentials: 'include',
|
|
250
|
+
headers,
|
|
251
|
+
body: JSON.stringify({ repositoryFullName: repoFullName }),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const data = await res.json();
|
|
255
|
+
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
throw new Error(data.error || 'Failed to create workspace');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Set provisioning state with workspace info
|
|
261
|
+
const startedAt = Date.now();
|
|
262
|
+
setProvisioningInfo({
|
|
263
|
+
workspaceId: data.workspaceId,
|
|
264
|
+
workspaceName: repoFullName.split('/')[1] || repoFullName,
|
|
265
|
+
stage: null,
|
|
266
|
+
startedAt,
|
|
267
|
+
});
|
|
268
|
+
setState('provisioning');
|
|
269
|
+
|
|
270
|
+
// Poll for workspace to be ready
|
|
271
|
+
// Cloud deployments (Fly.io) can take 3-5 minutes for cold starts
|
|
272
|
+
const pollForReady = async (workspaceId: string) => {
|
|
273
|
+
const maxAttempts = 150; // 5 minutes with 2s interval
|
|
274
|
+
const pollIntervalMs = 2000;
|
|
275
|
+
let attempts = 0;
|
|
276
|
+
|
|
277
|
+
while (attempts < maxAttempts) {
|
|
278
|
+
const statusRes = await fetch(`/api/workspaces/${workspaceId}/status`, {
|
|
279
|
+
credentials: 'include',
|
|
280
|
+
});
|
|
281
|
+
const statusData = await statusRes.json();
|
|
282
|
+
|
|
283
|
+
// Update provisioning stage if available
|
|
284
|
+
if (statusData.provisioning?.stage) {
|
|
285
|
+
setProvisioningInfo(prev => prev ? {
|
|
286
|
+
...prev,
|
|
287
|
+
stage: statusData.provisioning.stage,
|
|
288
|
+
} : null);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (statusData.status === 'running') {
|
|
292
|
+
// Fetch updated workspace info
|
|
293
|
+
const wsRes = await fetch(`/api/workspaces/${workspaceId}`, {
|
|
294
|
+
credentials: 'include',
|
|
295
|
+
});
|
|
296
|
+
const wsData = await wsRes.json();
|
|
297
|
+
if (wsData.publicUrl) {
|
|
298
|
+
// Clear provisioning info and show provider connection screen
|
|
299
|
+
setProvisioningInfo(null);
|
|
300
|
+
setSelectedWorkspace(wsData);
|
|
301
|
+
setState('connect-provider');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
} else if (statusData.status === 'error') {
|
|
305
|
+
const errorMsg = statusData.errorMessage || 'Workspace provisioning failed';
|
|
306
|
+
throw new Error(errorMsg);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
310
|
+
attempts++;
|
|
311
|
+
|
|
312
|
+
// Log progress every 30 seconds
|
|
313
|
+
if (attempts % 15 === 0) {
|
|
314
|
+
console.log(`[workspace] Still provisioning... (${Math.floor(attempts * pollIntervalMs / 1000)}s elapsed)`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
throw new Error('Workspace provisioning timed out after 5 minutes. Please try again or contact support.');
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
await pollForReady(data.workspaceId);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error('Create workspace error:', err);
|
|
324
|
+
setProvisioningInfo(null);
|
|
325
|
+
setError(err instanceof Error ? err.message : 'Failed to create workspace');
|
|
326
|
+
setState('no-workspaces');
|
|
327
|
+
}
|
|
328
|
+
}, [connectToWorkspace, csrfToken]);
|
|
329
|
+
|
|
330
|
+
// Handle provider connection success
|
|
331
|
+
const handleProviderConnected = useCallback((providerId: string) => {
|
|
332
|
+
setConnectedProviders(prev => [...new Set([...prev, providerId])]);
|
|
333
|
+
}, []);
|
|
334
|
+
|
|
335
|
+
// Skip provider connection and continue to workspace
|
|
336
|
+
const handleSkipProvider = useCallback(() => {
|
|
337
|
+
if (selectedWorkspace) {
|
|
338
|
+
connectToWorkspace(selectedWorkspace);
|
|
339
|
+
}
|
|
340
|
+
}, [selectedWorkspace, connectToWorkspace]);
|
|
341
|
+
|
|
342
|
+
const handleStartWorkspace = useCallback(async (workspace: Workspace) => {
|
|
343
|
+
setState('loading');
|
|
344
|
+
setError(null);
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const headers: Record<string, string> = {};
|
|
348
|
+
if (csrfToken) {
|
|
349
|
+
headers['X-CSRF-Token'] = csrfToken;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const res = await fetch(`/api/workspaces/${workspace.id}/restart`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
credentials: 'include',
|
|
355
|
+
headers,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
const data = await res.json();
|
|
360
|
+
throw new Error(data.error || 'Failed to start workspace');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Poll for workspace to be ready
|
|
364
|
+
const maxAttempts = 60;
|
|
365
|
+
let attempts = 0;
|
|
366
|
+
|
|
367
|
+
while (attempts < maxAttempts) {
|
|
368
|
+
const statusRes = await fetch(`/api/workspaces/${workspace.id}/status`, {
|
|
369
|
+
credentials: 'include',
|
|
370
|
+
});
|
|
371
|
+
const statusData = await statusRes.json();
|
|
372
|
+
|
|
373
|
+
if (statusData.status === 'running') {
|
|
374
|
+
const wsRes = await fetch(`/api/workspaces/${workspace.id}`, {
|
|
375
|
+
credentials: 'include',
|
|
376
|
+
});
|
|
377
|
+
const wsData = await wsRes.json();
|
|
378
|
+
if (wsData.publicUrl) {
|
|
379
|
+
connectToWorkspace({ ...workspace, ...wsData });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
385
|
+
attempts++;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
throw new Error('Workspace start timed out');
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.error('Start workspace error:', err);
|
|
391
|
+
setError(err instanceof Error ? err.message : 'Failed to start workspace');
|
|
392
|
+
setState('select-workspace');
|
|
393
|
+
}
|
|
394
|
+
}, [connectToWorkspace, csrfToken]);
|
|
395
|
+
|
|
396
|
+
// Loading state
|
|
397
|
+
if (state === 'loading') {
|
|
398
|
+
return (
|
|
399
|
+
<div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
|
|
400
|
+
<div className="text-center">
|
|
401
|
+
<svg className="w-8 h-8 text-accent-cyan animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
|
|
402
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
403
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
404
|
+
</svg>
|
|
405
|
+
<p className="mt-4 text-text-muted">Loading...</p>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Local mode - just render the App component
|
|
412
|
+
if (state === 'local') {
|
|
413
|
+
return <App />;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Connected to workspace - render App with workspace's WebSocket
|
|
417
|
+
// Wrap in CloudSessionProvider so App has access to cloud session context
|
|
418
|
+
if (state === 'connected' && wsUrl) {
|
|
419
|
+
return (
|
|
420
|
+
<CloudSessionProvider cloudMode={true}>
|
|
421
|
+
<App wsUrl={wsUrl} />
|
|
422
|
+
</CloudSessionProvider>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Connecting state
|
|
427
|
+
if (state === 'connecting') {
|
|
428
|
+
return (
|
|
429
|
+
<div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
|
|
430
|
+
<div className="text-center">
|
|
431
|
+
<svg className="w-8 h-8 text-accent-cyan animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
|
|
432
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
433
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
434
|
+
</svg>
|
|
435
|
+
<p className="mt-4 text-white font-medium">Connecting to {selectedWorkspace?.name}...</p>
|
|
436
|
+
<p className="mt-2 text-text-muted text-sm">{selectedWorkspace?.publicUrl}</p>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Provisioning state - show progress UI
|
|
443
|
+
if (state === 'provisioning' && provisioningInfo) {
|
|
444
|
+
return (
|
|
445
|
+
<div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
|
|
446
|
+
<div className="w-full max-w-xl">
|
|
447
|
+
<ProvisioningProgress
|
|
448
|
+
isProvisioning={true}
|
|
449
|
+
currentStage={provisioningInfo.stage}
|
|
450
|
+
workspaceName={provisioningInfo.workspaceName}
|
|
451
|
+
error={error}
|
|
452
|
+
onCancel={() => {
|
|
453
|
+
setProvisioningInfo(null);
|
|
454
|
+
setState('no-workspaces');
|
|
455
|
+
}}
|
|
456
|
+
/>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Error state
|
|
463
|
+
if (state === 'error') {
|
|
464
|
+
return (
|
|
465
|
+
<div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center p-4">
|
|
466
|
+
<div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-8 max-w-md w-full text-center">
|
|
467
|
+
<div className="w-16 h-16 mx-auto mb-4 bg-error/20 rounded-full flex items-center justify-center">
|
|
468
|
+
<svg className="w-8 h-8 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
469
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
470
|
+
</svg>
|
|
471
|
+
</div>
|
|
472
|
+
<h2 className="text-xl font-semibold text-white mb-2">Something went wrong</h2>
|
|
473
|
+
<p className="text-text-muted mb-6">{error}</p>
|
|
474
|
+
<button
|
|
475
|
+
onClick={() => window.location.reload()}
|
|
476
|
+
className="w-full py-3 px-4 bg-bg-tertiary border border-border-subtle rounded-xl text-white font-medium hover:bg-bg-hover transition-colors"
|
|
477
|
+
>
|
|
478
|
+
Try Again
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Connect provider state - show after workspace is ready
|
|
486
|
+
if (state === 'connect-provider' && selectedWorkspace) {
|
|
487
|
+
return (
|
|
488
|
+
<div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex flex-col items-center justify-center p-4">
|
|
489
|
+
{/* Background grid */}
|
|
490
|
+
<div className="fixed inset-0 opacity-10 pointer-events-none">
|
|
491
|
+
<div
|
|
492
|
+
className="absolute inset-0"
|
|
493
|
+
style={{
|
|
494
|
+
backgroundImage: `linear-gradient(rgba(0, 217, 255, 0.1) 1px, transparent 1px),
|
|
495
|
+
linear-gradient(90deg, rgba(0, 217, 255, 0.1) 1px, transparent 1px)`,
|
|
496
|
+
backgroundSize: '50px 50px',
|
|
497
|
+
}}
|
|
498
|
+
/>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<div className="relative z-10 w-full max-w-xl">
|
|
502
|
+
{/* Logo */}
|
|
503
|
+
<div className="flex flex-col items-center mb-8">
|
|
504
|
+
<LogoIcon size={48} withGlow={true} />
|
|
505
|
+
<h1 className="mt-4 text-2xl font-bold text-white">Connect AI Provider</h1>
|
|
506
|
+
<p className="mt-2 text-text-muted text-center">
|
|
507
|
+
Your workspace <span className="text-white">{selectedWorkspace.name}</span> is ready!
|
|
508
|
+
<br />Connect an AI provider to start using agents.
|
|
509
|
+
</p>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
{/* Shared provider connection component */}
|
|
513
|
+
<ProviderConnectionList
|
|
514
|
+
providers={AI_PROVIDERS}
|
|
515
|
+
connectedProviders={connectedProviders}
|
|
516
|
+
workspaceId={selectedWorkspace.id}
|
|
517
|
+
csrfToken={csrfToken || undefined}
|
|
518
|
+
onProviderConnected={handleProviderConnected}
|
|
519
|
+
onContinue={handleSkipProvider}
|
|
520
|
+
showDetailedInfo={true}
|
|
521
|
+
/>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Create workspace state - show repo selection
|
|
528
|
+
if (state === 'create-workspace') {
|
|
529
|
+
// Filter out repos that already have workspaces
|
|
530
|
+
// Workspace names are like "Workspace for Owner/repo" or just the repo fullName
|
|
531
|
+
const workspaceRepoFullNames = new Set(
|
|
532
|
+
workspaces.flatMap(w => {
|
|
533
|
+
const names: string[] = [];
|
|
534
|
+
// Check repositories array first
|
|
535
|
+
if (w.repositories && w.repositories.length > 0) {
|
|
536
|
+
w.repositories.forEach(r => names.push(r.toLowerCase()));
|
|
537
|
+
}
|
|
538
|
+
// Also extract from workspace name (format: "Workspace for Owner/repo" or "Owner/repo")
|
|
539
|
+
const match = w.name.match(/(?:Workspace for\s+)?(.+\/.+)/i);
|
|
540
|
+
if (match) {
|
|
541
|
+
names.push(match[1].toLowerCase());
|
|
542
|
+
}
|
|
543
|
+
return names;
|
|
544
|
+
})
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const availableRepos = repos.filter(repo => {
|
|
548
|
+
return !workspaceRepoFullNames.has(repo.fullName.toLowerCase());
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
<div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex flex-col items-center justify-center p-4">
|
|
553
|
+
{/* Background grid */}
|
|
554
|
+
<div className="fixed inset-0 opacity-10 pointer-events-none">
|
|
555
|
+
<div
|
|
556
|
+
className="absolute inset-0"
|
|
557
|
+
style={{
|
|
558
|
+
backgroundImage: `linear-gradient(rgba(0, 217, 255, 0.1) 1px, transparent 1px),
|
|
559
|
+
linear-gradient(90deg, rgba(0, 217, 255, 0.1) 1px, transparent 1px)`,
|
|
560
|
+
backgroundSize: '50px 50px',
|
|
561
|
+
}}
|
|
562
|
+
/>
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
<div className="relative z-10 w-full max-w-2xl">
|
|
566
|
+
{/* Logo */}
|
|
567
|
+
<div className="flex flex-col items-center mb-8">
|
|
568
|
+
<LogoIcon size={48} withGlow={true} />
|
|
569
|
+
<h1 className="mt-4 text-2xl font-bold text-white">Create Workspace</h1>
|
|
570
|
+
<p className="mt-2 text-text-muted">
|
|
571
|
+
Select a repository to create a workspace
|
|
572
|
+
</p>
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
{error && (
|
|
576
|
+
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-xl">
|
|
577
|
+
<p className="text-error">{error}</p>
|
|
578
|
+
</div>
|
|
579
|
+
)}
|
|
580
|
+
|
|
581
|
+
<div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-6">
|
|
582
|
+
{/* Back button */}
|
|
583
|
+
<button
|
|
584
|
+
onClick={() => setState('select-workspace')}
|
|
585
|
+
className="mb-4 flex items-center gap-2 text-text-muted hover:text-white transition-colors text-sm"
|
|
586
|
+
>
|
|
587
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
588
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
589
|
+
</svg>
|
|
590
|
+
Back to workspaces
|
|
591
|
+
</button>
|
|
592
|
+
|
|
593
|
+
<h2 className="text-lg font-semibold text-white mb-4">Available Repositories</h2>
|
|
594
|
+
<p className="text-text-muted mb-6 text-sm">
|
|
595
|
+
These are repositories the GitHub App has access to that don't have a workspace yet.
|
|
596
|
+
</p>
|
|
597
|
+
|
|
598
|
+
{availableRepos.length > 0 ? (
|
|
599
|
+
<div className="space-y-3">
|
|
600
|
+
{availableRepos.map((repo) => (
|
|
601
|
+
<button
|
|
602
|
+
key={repo.id}
|
|
603
|
+
onClick={() => handleCreateWorkspace(repo.fullName)}
|
|
604
|
+
className="w-full flex items-center gap-3 p-4 bg-bg-tertiary rounded-xl border border-border-subtle hover:border-accent-cyan/50 transition-colors text-left"
|
|
605
|
+
>
|
|
606
|
+
<svg className="w-5 h-5 text-text-muted flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
|
607
|
+
<path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z" />
|
|
608
|
+
</svg>
|
|
609
|
+
<div className="flex-1 min-w-0">
|
|
610
|
+
<p className="text-white font-medium truncate">{repo.fullName}</p>
|
|
611
|
+
<p className="text-text-muted text-sm">{repo.isPrivate ? 'Private' : 'Public'}</p>
|
|
612
|
+
</div>
|
|
613
|
+
<svg className="w-5 h-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
614
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
615
|
+
</svg>
|
|
616
|
+
</button>
|
|
617
|
+
))}
|
|
618
|
+
</div>
|
|
619
|
+
) : (
|
|
620
|
+
<div className="text-center py-8">
|
|
621
|
+
<p className="text-text-muted mb-4">All connected repositories already have workspaces.</p>
|
|
622
|
+
<a
|
|
623
|
+
href="/connect-repos"
|
|
624
|
+
className="inline-flex items-center gap-2 py-3 px-6 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-xl hover:shadow-glow-cyan transition-all"
|
|
625
|
+
>
|
|
626
|
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
627
|
+
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
|
628
|
+
</svg>
|
|
629
|
+
Connect More Repositories
|
|
630
|
+
</a>
|
|
631
|
+
</div>
|
|
632
|
+
)}
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Workspace selection / no workspaces UI
|
|
640
|
+
return (
|
|
641
|
+
<div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex flex-col items-center justify-center p-4">
|
|
642
|
+
{/* Background grid */}
|
|
643
|
+
<div className="fixed inset-0 opacity-10 pointer-events-none">
|
|
644
|
+
<div
|
|
645
|
+
className="absolute inset-0"
|
|
646
|
+
style={{
|
|
647
|
+
backgroundImage: `linear-gradient(rgba(0, 217, 255, 0.1) 1px, transparent 1px),
|
|
648
|
+
linear-gradient(90deg, rgba(0, 217, 255, 0.1) 1px, transparent 1px)`,
|
|
649
|
+
backgroundSize: '50px 50px',
|
|
650
|
+
}}
|
|
651
|
+
/>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
<div className="relative z-10 w-full max-w-2xl">
|
|
655
|
+
{/* Logo */}
|
|
656
|
+
<div className="flex flex-col items-center mb-8">
|
|
657
|
+
<LogoIcon size={48} withGlow={true} />
|
|
658
|
+
<h1 className="mt-4 text-2xl font-bold text-white">Agent Relay</h1>
|
|
659
|
+
<p className="mt-2 text-text-muted">
|
|
660
|
+
{state === 'no-workspaces' ? 'Create a workspace to get started' : 'Select a workspace'}
|
|
661
|
+
</p>
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
{error && (
|
|
665
|
+
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-xl">
|
|
666
|
+
<p className="text-error">{error}</p>
|
|
667
|
+
</div>
|
|
668
|
+
)}
|
|
669
|
+
|
|
670
|
+
{/* Workspaces list */}
|
|
671
|
+
{state === 'select-workspace' && workspaces.length > 0 && (
|
|
672
|
+
<div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-6">
|
|
673
|
+
<h2 className="text-lg font-semibold text-white mb-4">Your Workspaces</h2>
|
|
674
|
+
<div className="space-y-3">
|
|
675
|
+
{workspaces.map((workspace) => (
|
|
676
|
+
<div
|
|
677
|
+
key={workspace.id}
|
|
678
|
+
className="flex items-center justify-between p-4 bg-bg-tertiary rounded-xl border border-border-subtle hover:border-accent-cyan/50 transition-colors"
|
|
679
|
+
>
|
|
680
|
+
<div className="flex items-center gap-3">
|
|
681
|
+
<div className={`w-3 h-3 rounded-full ${
|
|
682
|
+
workspace.status === 'running' ? 'bg-success' :
|
|
683
|
+
workspace.status === 'provisioning' ? 'bg-warning animate-pulse' :
|
|
684
|
+
workspace.status === 'error' ? 'bg-error' : 'bg-gray-500'
|
|
685
|
+
}`} />
|
|
686
|
+
<div>
|
|
687
|
+
<h3 className="font-medium text-white">{workspace.name}</h3>
|
|
688
|
+
<p className="text-sm text-text-muted">
|
|
689
|
+
{workspace.status === 'running' ? 'Running' :
|
|
690
|
+
workspace.status === 'provisioning' ? 'Starting...' :
|
|
691
|
+
workspace.status === 'stopped' ? 'Stopped' : 'Error'}
|
|
692
|
+
</p>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
<div>
|
|
696
|
+
{workspace.status === 'running' && workspace.publicUrl ? (
|
|
697
|
+
<button
|
|
698
|
+
onClick={() => connectToWorkspace(workspace)}
|
|
699
|
+
className="py-2 px-4 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-lg hover:shadow-glow-cyan transition-all"
|
|
700
|
+
>
|
|
701
|
+
Connect
|
|
702
|
+
</button>
|
|
703
|
+
) : workspace.status === 'stopped' ? (
|
|
704
|
+
<button
|
|
705
|
+
onClick={() => handleStartWorkspace(workspace)}
|
|
706
|
+
className="py-2 px-4 bg-bg-card border border-border-subtle rounded-lg text-white hover:border-accent-cyan/50 transition-colors"
|
|
707
|
+
>
|
|
708
|
+
Start
|
|
709
|
+
</button>
|
|
710
|
+
) : workspace.status === 'provisioning' ? (
|
|
711
|
+
<span className="text-text-muted text-sm">Starting...</span>
|
|
712
|
+
) : workspace.status === 'error' ? (
|
|
713
|
+
<div className="flex items-center gap-2">
|
|
714
|
+
<button
|
|
715
|
+
onClick={() => handleStartWorkspace(workspace)}
|
|
716
|
+
className="py-2 px-4 bg-accent-cyan/10 border border-accent-cyan/30 rounded-lg text-accent-cyan text-sm font-medium hover:bg-accent-cyan/20 transition-colors"
|
|
717
|
+
>
|
|
718
|
+
Restart
|
|
719
|
+
</button>
|
|
720
|
+
<a
|
|
721
|
+
href="/app/settings/workspace"
|
|
722
|
+
className="py-2 px-3 text-text-muted text-sm hover:text-white transition-colors"
|
|
723
|
+
title="Workspace settings"
|
|
724
|
+
>
|
|
725
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
726
|
+
<circle cx="12" cy="12" r="3" />
|
|
727
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
728
|
+
</svg>
|
|
729
|
+
</a>
|
|
730
|
+
</div>
|
|
731
|
+
) : (
|
|
732
|
+
<span className="text-error text-sm">Failed</span>
|
|
733
|
+
)}
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
))}
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
{/* Show Create button only if there are repos without workspaces */}
|
|
740
|
+
{(() => {
|
|
741
|
+
// Filter out repos that already have workspaces
|
|
742
|
+
// Workspace names are like "Workspace for Owner/repo" or just the repo fullName
|
|
743
|
+
// Extract the repo fullName from workspace name or repositories array
|
|
744
|
+
const workspaceRepoFullNames = new Set(
|
|
745
|
+
workspaces.flatMap(w => {
|
|
746
|
+
const names: string[] = [];
|
|
747
|
+
// Check repositories array first
|
|
748
|
+
if (w.repositories && w.repositories.length > 0) {
|
|
749
|
+
w.repositories.forEach(r => names.push(r.toLowerCase()));
|
|
750
|
+
}
|
|
751
|
+
// Also extract from workspace name (format: "Workspace for Owner/repo" or "Owner/repo")
|
|
752
|
+
const match = w.name.match(/(?:Workspace for\s+)?(.+\/.+)/i);
|
|
753
|
+
if (match) {
|
|
754
|
+
names.push(match[1].toLowerCase());
|
|
755
|
+
}
|
|
756
|
+
return names;
|
|
757
|
+
})
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
const availableRepos = repos.filter(repo => {
|
|
761
|
+
return !workspaceRepoFullNames.has(repo.fullName.toLowerCase());
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
if (availableRepos.length === 0) return null;
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<div className="mt-6 pt-6 border-t border-border-subtle">
|
|
768
|
+
<p className="text-text-muted text-sm mb-3">Or create a new workspace:</p>
|
|
769
|
+
<button
|
|
770
|
+
onClick={() => setState('create-workspace')}
|
|
771
|
+
className="py-2 px-4 bg-bg-card border border-border-subtle rounded-lg text-sm text-white hover:border-accent-cyan/50 transition-colors flex items-center gap-2"
|
|
772
|
+
>
|
|
773
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
774
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
775
|
+
</svg>
|
|
776
|
+
Create
|
|
777
|
+
</button>
|
|
778
|
+
</div>
|
|
779
|
+
);
|
|
780
|
+
})()}
|
|
781
|
+
</div>
|
|
782
|
+
)}
|
|
783
|
+
|
|
784
|
+
{/* No workspaces - create first one */}
|
|
785
|
+
{state === 'no-workspaces' && (
|
|
786
|
+
<div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-6">
|
|
787
|
+
<h2 className="text-lg font-semibold text-white mb-4">Create Your First Workspace</h2>
|
|
788
|
+
<p className="text-text-muted mb-6">
|
|
789
|
+
Select a repository to create a workspace where agents can work on your code.
|
|
790
|
+
</p>
|
|
791
|
+
|
|
792
|
+
{repos.length > 0 ? (
|
|
793
|
+
<div className="space-y-3">
|
|
794
|
+
{repos.map((repo) => (
|
|
795
|
+
<button
|
|
796
|
+
key={repo.id}
|
|
797
|
+
onClick={() => handleCreateWorkspace(repo.fullName)}
|
|
798
|
+
className="w-full flex items-center gap-3 p-4 bg-bg-tertiary rounded-xl border border-border-subtle hover:border-accent-cyan/50 transition-colors text-left"
|
|
799
|
+
>
|
|
800
|
+
<svg className="w-5 h-5 text-text-muted flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
|
801
|
+
<path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z" />
|
|
802
|
+
</svg>
|
|
803
|
+
<div className="flex-1 min-w-0">
|
|
804
|
+
<p className="text-white font-medium truncate">{repo.fullName}</p>
|
|
805
|
+
<p className="text-text-muted text-sm">{repo.isPrivate ? 'Private' : 'Public'}</p>
|
|
806
|
+
</div>
|
|
807
|
+
<svg className="w-5 h-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
808
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
809
|
+
</svg>
|
|
810
|
+
</button>
|
|
811
|
+
))}
|
|
812
|
+
</div>
|
|
813
|
+
) : (
|
|
814
|
+
<div className="text-center py-8">
|
|
815
|
+
<p className="text-text-muted mb-4">No repositories connected yet.</p>
|
|
816
|
+
<a
|
|
817
|
+
href="/connect-repos"
|
|
818
|
+
className="inline-flex items-center gap-2 py-3 px-6 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-xl hover:shadow-glow-cyan transition-all"
|
|
819
|
+
>
|
|
820
|
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
821
|
+
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
|
822
|
+
</svg>
|
|
823
|
+
Connect GitHub
|
|
824
|
+
</a>
|
|
825
|
+
</div>
|
|
826
|
+
)}
|
|
827
|
+
</div>
|
|
828
|
+
)}
|
|
829
|
+
|
|
830
|
+
{/* Navigation */}
|
|
831
|
+
<div className="mt-6 flex justify-center gap-4 text-sm">
|
|
832
|
+
<a href="/connect-repos" className="text-text-muted hover:text-white transition-colors">
|
|
833
|
+
Manage Repositories
|
|
834
|
+
</a>
|
|
835
|
+
<span className="text-text-muted">·</span>
|
|
836
|
+
<button
|
|
837
|
+
onClick={async () => {
|
|
838
|
+
const headers: Record<string, string> = {};
|
|
839
|
+
if (csrfToken) {
|
|
840
|
+
headers['X-CSRF-Token'] = csrfToken;
|
|
841
|
+
}
|
|
842
|
+
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include', headers });
|
|
843
|
+
window.location.href = '/login';
|
|
844
|
+
}}
|
|
845
|
+
className="text-text-muted hover:text-white transition-colors"
|
|
846
|
+
>
|
|
847
|
+
Sign Out
|
|
848
|
+
</button>
|
|
849
|
+
</div>
|
|
850
|
+
</div>
|
|
851
|
+
</div>
|
|
852
|
+
);
|
|
853
|
+
}
|