diff --git a/packages/go/.env.example b/packages/go/.env.example new file mode 100644 index 000000000..cf4f0e23d --- /dev/null +++ b/packages/go/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_DEFAULT_WORKSPACE_ID= +NEXT_PUBLIC_DEFAULT_WORKSPACE_TOKEN= diff --git a/packages/go/.gitignore b/packages/go/.gitignore new file mode 100644 index 000000000..6421a7222 --- /dev/null +++ b/packages/go/.gitignore @@ -0,0 +1,15 @@ +# Override root .gitignore exclusions needed for this package +!lib/ +!hooks/ + +node_modules/ +.next/ +.env.local +dist/ +out/ +*.dmg +*.AppImage +*.deb +*.exe +*.snap +package-lock.json diff --git a/packages/go/README.md b/packages/go/README.md new file mode 100644 index 000000000..c0102d027 --- /dev/null +++ b/packages/go/README.md @@ -0,0 +1,32 @@ +# OpenAgents Go + +Electron desktop app for OpenAgents workspaces. + +![Workspace](docs/screenshot-workspace.png) + +![Workspace Selector](docs/screenshot-selector.png) + +## Development + +```bash +cd packages/go +npm install +npm run electron:dev +``` + +## Build & Install + +```bash +npm version patch +npm run electron:build +``` + +Output in `dist/` — `.dmg` (macOS), `.exe` (Windows), `.AppImage` (Linux). + +macOS: open the `.dmg`, drag "OpenAgents Go" to Applications. + +## TODO + +- [ ] Restore last active thread on relaunch +- [ ] Persist light/dark mode preference across sessions +- [ ] Default sidebar to collapsed state diff --git a/packages/go/app/layout.tsx b/packages/go/app/layout.tsx new file mode 100644 index 000000000..696edbecd --- /dev/null +++ b/packages/go/app/layout.tsx @@ -0,0 +1,53 @@ +import type { Metadata, Viewport } from 'next'; +import { Inter } from 'next/font/google'; +import { ThemeProvider } from 'next-themes'; +import { Toaster } from '@/components/ui/sonner'; +import { AuthProvider } from '@/lib/auth-context'; +import { OpenAgentsAuthProvider } from '@/lib/openagents-auth-context'; +import { ElectronInit } from '@/components/layout/electron-init'; +import '@/styles/globals.css'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'OpenAgents Go', + description: 'OpenAgents desktop app', + icons: { + icon: [ + { url: '/favicon.ico', sizes: 'any' }, + { url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' }, + { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' }, + ], + apple: '/apple-touch-icon.png', + }, + manifest: '/site.webmanifest', +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + viewportFit: 'cover', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + {children} + + + + + + + ); +} diff --git a/packages/go/app/not-found.tsx b/packages/go/app/not-found.tsx new file mode 100644 index 000000000..4f66161ab --- /dev/null +++ b/packages/go/app/not-found.tsx @@ -0,0 +1,8 @@ +export default function NotFound() { + return ( +
+

404

+

Workspace not found.

+
+ ); +} diff --git a/packages/go/app/page.tsx b/packages/go/app/page.tsx new file mode 100644 index 000000000..64b1305b4 --- /dev/null +++ b/packages/go/app/page.tsx @@ -0,0 +1,374 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Image from 'next/image'; +import { ArrowRight, ArrowLeft, Link2, Clock, ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { ElectronDragBar } from '@/components/layout/electron-init'; +import type { WorkspaceHistoryEntry } from '@/components/layout/electron-init'; +import { WorkspaceProvider } from '@/lib/workspace-context'; +import { LayoutProvider } from '@/components/layout/layout-context'; +import { Wrapper } from '@/components/layout/wrapper'; +import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; + +/** + * Parse a workspace URL like: + * https://workspace.openagents.org/0048fff6?token=abc123 + * /0048fff6?token=abc123 + * 0048fff6 + */ +function parseWorkspaceUrl(input: string): { workspaceId: string; workspaceToken: string } | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const url = new URL(trimmed, 'https://workspace.openagents.org'); + const segments = url.pathname.split('/').filter(Boolean); + const workspaceId = segments[segments.length - 1]; + const workspaceToken = url.searchParams.get('token') || ''; + if (workspaceId) return { workspaceId, workspaceToken }; + } catch { + // Not a URL — treat as bare workspace ID + } + + if (/^[\w-]+$/.test(trimmed)) { + return { workspaceId: trimmed, workspaceToken: '' }; + } + + return null; +} + +function navigateToWorkspace(workspaceId: string, workspaceToken: string) { + window.location.href = `/${workspaceId}?token=${workspaceToken}`; +} + +// --------------------------------------------------------------------------- +// Workspace View — renders when URL path has a workspace ID +// --------------------------------------------------------------------------- + +function WorkspaceView({ workspaceId, token }: { workspaceId: string; token: string }) { + const { user, idToken, loading: authLoading, isOpenAgentsDomain, signIn } = useOpenAgentsAuth(); + + if (token) { + return ( + + + + + + ); + } + + if (isOpenAgentsDomain) { + if (authLoading) { + return ( +
+
+
+ ); + } + + if (user && idToken) { + return ( + + + + + + ); + } + + return ( +
+

Sign in to access this workspace

+ +
+ ); + } + + return ( +
+

Missing Token

+

+ Add ?token=your_workspace_token to the URL. +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Selector View — renders at root path +// --------------------------------------------------------------------------- + +function SelectorView() { + const router = useRouter(); + const searchParams = useSearchParams(); + const isSwitching = searchParams.get('switch') === '1'; + + const [loading, setLoading] = useState(true); + const [urlInput, setUrlInput] = useState(''); + const [error, setError] = useState(''); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [history, setHistory] = useState([]); + const [currentWorkspace, setCurrentWorkspace] = useState<{ id: string; token: string } | null>(null); + + useEffect(() => { + async function init() { + if (window.electronAPI) { + const settings = await window.electronAPI.settings.load(); + setHistory(settings.workspaceHistory || []); + + if (isSwitching) { + if (settings.workspaceId && settings.workspaceToken) { + setCurrentWorkspace({ id: settings.workspaceId, token: settings.workspaceToken }); + } + setLoading(false); + return; + } + + if (settings.workspaceId && settings.workspaceToken) { + navigateToWorkspace(settings.workspaceId, settings.workspaceToken); + return; + } + } + + if (!isSwitching) { + const envId = process.env.NEXT_PUBLIC_DEFAULT_WORKSPACE_ID; + const envToken = process.env.NEXT_PUBLIC_DEFAULT_WORKSPACE_TOKEN; + if (envId && envToken) { + navigateToWorkspace(envId, envToken); + return; + } + } + + setLoading(false); + } + init(); + }, [router, isSwitching]); + + const connectToWorkspace = async (workspaceId: string, workspaceToken: string) => { + if (window.electronAPI) { + await window.electronAPI.settings.save({ workspaceId, workspaceToken }); + } + navigateToWorkspace(workspaceId, workspaceToken); + }; + + const handleConnect = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + const parsed = parseWorkspaceUrl(urlInput); + if (!parsed || !parsed.workspaceId) { + setError('Please enter a valid workspace URL or ID.'); + return; + } + if (!parsed.workspaceToken) { + setError('URL must include a token parameter (e.g. ?token=...).'); + return; + } + + await connectToWorkspace(parsed.workspaceId, parsed.workspaceToken); + }; + + const handleCancel = () => { + if (currentWorkspace) { + navigateToWorkspace(currentWorkspace.id, currentWorkspace.token); + } + }; + + if (loading) { + return ( + <> + +
Loading...
+ + ); + } + + const recentHistory = history.slice(0, 3); + + return ( + <> + +
+
+
+ OpenAgents +

OpenAgents Workspace

+

+ {isSwitching ? 'Select a workspace or paste a new URL.' : 'Paste your workspace URL to get started.'} +

+
+ + {recentHistory.length > 0 && ( +
+

Recent Workspaces

+
+ {recentHistory.map((entry) => { + const displayName = entry.name && entry.name !== entry.workspaceId + ? entry.name + : entry.workspaceId.slice(0, 8); + const fullUrl = `https://workspace.openagents.org/${entry.workspaceId}?token=${entry.workspaceToken}`; + + return ( + + + + + +

{fullUrl}

+
+
+ ); + })} +
+
+ )} + +
+
+
+ + { setUrlInput(e.target.value); setError(''); setDropdownOpen(false); }} + onFocus={() => history.length > 0 && setDropdownOpen(true)} + className="pl-10 pr-10" + autoFocus + /> + {history.length > 0 && ( + + )} + + {dropdownOpen && history.length > 0 && ( +
+ {history.map((entry) => { + const displayName = entry.name && entry.name !== entry.workspaceId + ? entry.name + : entry.workspaceId.slice(0, 8); + + return ( + + ); + })} +
+ )} +
+ {error &&

{error}

} +
+ +
+ + {isSwitching && currentWorkspace && ( + + )} + +

+ Get a workspace URL by running{' '} + openagents workspace create +

+
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Root — routes based on URL path +// --------------------------------------------------------------------------- + +function AppRouter() { + const [route, setRoute] = useState<{ type: 'loading' } | { type: 'selector' } | { type: 'workspace'; workspaceId: string; token: string }>({ type: 'loading' }); + + useEffect(() => { + const path = window.location.pathname.replace(/\/+$/, ''); + const params = new URLSearchParams(window.location.search); + const token = params.get('token') || ''; + + // Root path or switch mode → selector + if (!path || path === '/' || path === '') { + setRoute({ type: 'selector' }); + return; + } + + // Any other path → treat as workspace ID + const workspaceId = path.split('/').filter(Boolean)[0]; + if (workspaceId) { + setRoute({ type: 'workspace', workspaceId, token }); + } else { + setRoute({ type: 'selector' }); + } + }, []); + + if (route.type === 'loading') { + return ( + <> + +
Loading...
+ + ); + } + + if (route.type === 'workspace') { + return ; + } + + return ; +} + +export default function HomePage() { + return ( + + +
Loading...
+ + }> + +
+ ); +} diff --git a/packages/go/assets/icon.icns b/packages/go/assets/icon.icns new file mode 100644 index 000000000..08e29956e Binary files /dev/null and b/packages/go/assets/icon.icns differ diff --git a/packages/go/assets/icon.ico b/packages/go/assets/icon.ico new file mode 100644 index 000000000..d33d7992d Binary files /dev/null and b/packages/go/assets/icon.ico differ diff --git a/packages/go/assets/icon.png b/packages/go/assets/icon.png new file mode 100644 index 000000000..c2170c41c Binary files /dev/null and b/packages/go/assets/icon.png differ diff --git a/packages/go/components/agents/agent-profile-panel.tsx b/packages/go/components/agents/agent-profile-panel.tsx new file mode 100644 index 000000000..bbe1b00a8 --- /dev/null +++ b/packages/go/components/agents/agent-profile-panel.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { X, Copy, Check, Plus, Globe, Folder, Monitor, UserRoundCog } from 'lucide-react'; +import { useLayout } from '@/components/layout/layout-context'; +import { useWorkspace } from '@/lib/workspace-context'; +import { getAgentColor, getAgentInitials } from '@/lib/helpers'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; +import { workspaceApi } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; + +export function AgentProfilePanel() { + const { selectedAgentName, setSelectedAgentName, isMobile, setViewMode } = useLayout(); + const { agents, refreshWorkspace, createSession } = useWorkspace(); + const { isCopied, copyToClipboard } = useCopyToClipboard(); + + const agent = agents.find((a) => a.agentName === selectedAgentName); + + // Description state — local draft + save + const [description, setDescription] = useState(''); + const [saving, setSaving] = useState(false); + const [descDirty, setDescDirty] = useState(false); + + // Sync description when agent changes + useEffect(() => { + if (agent) { + setDescription(agent.description || ''); + setDescDirty(false); + } + }, [agent?.agentName, agent?.description]); + + const handleSaveDescription = useCallback(async () => { + if (!agent || !descDirty) return; + setSaving(true); + try { + await workspaceApi.updateMember(agent.agentName, { description }); + await refreshWorkspace(); + setDescDirty(false); + toast.success('Description saved'); + } catch { + toast.error('Failed to save description'); + } finally { + setSaving(false); + } + }, [agent, description, descDirty, refreshWorkspace]); + + const handleStartThread = useCallback(async () => { + if (!agent) return; + await createSession({ master: agent.agentName, participants: [agent.agentName] }); + setSelectedAgentName(null); + setViewMode('threads'); + }, [agent, createSession, setSelectedAgentName, setViewMode]); + + if (!agent) return null; + + const agentNames = agents.map((a) => a.agentName); + const color = getAgentColor(agent.agentName, agentNames); + const isOnline = agent.status === 'online'; + + // Capitalize agent type for display (e.g. "claude" → "Claude") + const displayType = agent.agentType + ? agent.agentType.charAt(0).toUpperCase() + agent.agentType.slice(1) + : 'Unknown'; + + const infoItems = [ + { icon: , label: 'Type', value: displayType }, + { icon: , label: 'Server', value: agent.serverHost || '—' }, + { icon: , label: 'Folder', value: agent.workingDir || '—' }, + { icon: , label: 'Agent ID', value: `openagents:${agent.agentName}`, copyable: true }, + ]; + + return ( + <> + {/* Backdrop */} +
setSelectedAgentName(null)} + /> + + {/* Panel — full-width on mobile, 320px sidebar on desktop */} +
+ {/* Close button */} +
+ +
+ + {/* Profile header */} +
+
+
+ {getAgentInitials(agent.agentName)} + +
+
+

{agent.agentName}

+
+ + + {agent.status} + +
+
+
+
+ + {/* Scrollable content */} +
+ {/* Description */} +
+
+ Description +
+
+