Compare commits

..

3 Commits

Author SHA1 Message Date
Nikhil Sonti
a3f5643875 ci: make build-agent workflow manual-only 2026-04-23 17:20:54 -07:00
Nikhil
c6c902a4ab feat: improve dev watch Lima preflights (#802)
* feat: improve dev watch lima preflights

* fix: note vm cache sync duration

* fix: address review feedback for PR #802
2026-04-23 17:16:50 -07:00
Nikhil
6e37742a5a feat: reuse agent command chat for agents page (#803) 2026-04-23 17:09:49 -07:00
6 changed files with 482 additions and 690 deletions

View File

@@ -13,10 +13,6 @@ on:
required: false
default: false
type: boolean
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- ".github/workflows/build-agent.yml"
env:
BUN_VERSION: "1.3.6"

View File

@@ -74,6 +74,18 @@ const primaryNavItems: NavItem[] = [
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]
function isNavItemActive(item: NavItem, pathname: string): boolean {
if (item.to === '/settings/ai') {
return pathname.startsWith('/settings')
}
if (item.to === '/agents') {
return pathname === '/agents' || pathname.startsWith('/agents/')
}
return pathname === item.to
}
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
expanded = true,
}) => {
@@ -90,10 +102,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
<nav className="space-y-1">
{filteredItems.map((item) => {
const Icon = item.icon
const isActive =
item.to === '/settings/ai'
? location.pathname.startsWith('/settings')
: location.pathname === item.to
const isActive = isNavItemActive(item, location.pathname)
const navItem = (
<NavLink

View File

@@ -113,7 +113,22 @@ export const App: FC = () => {
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
{alphaEnabled ? (
<Route path="agents" element={<AgentsPage />} />
<>
<Route path="agents" element={<AgentsPage />} />
<Route element={<AgentCommandLayout />}>
<Route
path="agents/:agentId"
element={
<AgentCommandConversation
variant="page"
backPath="/agents"
agentPathPrefix="/agents"
createAgentPath="/agents"
/>
}
/>
</Route>
</>
) : null}
{alphaEnabled ? (
<Route path="admin" element={<AdminDashboardPage />} />

View File

@@ -1,4 +1,4 @@
import { Bot, Home, RotateCcw } from 'lucide-react'
import { ArrowLeft, Bot, Home, RotateCcw } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
@@ -11,15 +11,21 @@ import { useAgentConversation } from './useAgentConversation'
function ConversationHeader({
agentName,
backLabel,
backTarget,
status,
onGoHome,
onNavigateBack,
onReset,
}: {
agentName: string
backLabel: string
backTarget: 'home' | 'page'
status: string
onGoHome: () => void
onNavigateBack: () => void
onReset: () => void
}) {
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
return (
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
<div className="flex items-center justify-between gap-3 px-5 py-4">
@@ -27,11 +33,11 @@ function ConversationHeader({
<Button
variant="ghost"
size="icon"
onClick={onGoHome}
onClick={onNavigateBack}
className="rounded-xl"
title="Back to home"
title={backLabel}
>
<Home className="size-4" />
<BackIcon className="size-4" />
</Button>
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Bot className="size-5" />
@@ -85,7 +91,19 @@ function getConversationStatusCopy(
return 'Open agent setup to continue'
}
export const AgentCommandConversation: FC = () => {
interface AgentCommandConversationProps {
variant?: 'command' | 'page'
backPath?: string
agentPathPrefix?: string
createAgentPath?: string
}
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
variant = 'command',
backPath = '/home',
agentPathPrefix = '/home/agents',
createAgentPath = '/agents',
}) => {
const { agentId } = useParams<{ agentId: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
@@ -100,6 +118,8 @@ export const AgentCommandConversation: FC = () => {
useAgentConversation(resolvedAgentId, agentName)
const lastTurn = turns[turns.length - 1]
const lastTurnPartCount = lastTurn?.parts.length ?? 0
const isPageVariant = variant === 'page'
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
useEffect(() => {
if (shouldRedirectHome) return
@@ -131,18 +151,32 @@ export const AgentCommandConversation: FC = () => {
}
const handleSelectAgent = (entry: AgentEntry) => {
navigate(`/home/agents/${entry.agentId}`)
navigate(`${agentPathPrefix}/${entry.agentId}`)
}
const statusCopy = getConversationStatusCopy(status?.status, streaming)
return (
<div className="absolute inset-0 overflow-hidden">
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
<div
className={cn(
'overflow-hidden',
isPageVariant
? 'h-[calc(100vh-7rem)] min-h-[620px]'
: 'absolute inset-0',
)}
>
<div
className={cn(
'fade-in slide-in-from-bottom-5 flex h-full w-full animate-in flex-col gap-3 duration-300',
isPageVariant ? 'mx-auto' : 'mx-auto max-w-3xl px-4 pt-4 pb-2',
)}
>
<ConversationHeader
agentName={agentName}
backLabel={backLabel}
backTarget={isPageVariant ? 'page' : 'home'}
status={statusCopy}
onGoHome={() => navigate('/home')}
onNavigateBack={() => navigate(backPath)}
onReset={resetConversation}
/>
@@ -181,7 +215,7 @@ export const AgentCommandConversation: FC = () => {
onSend={(text) => {
void send(text)
}}
onCreateAgent={() => navigate('/agents')}
onCreateAgent={() => navigate(createAgentPath)}
streaming={streaming}
disabled={status?.status !== 'running'}
status={status?.status}

View File

@@ -1,399 +0,0 @@
import {
ArrowLeft,
Bot,
CheckCircle2,
Loader2,
Send,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import {
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from './useOpenClaw'
interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
type AssistantPart =
| { kind: 'thinking'; text: string; done: boolean }
| { kind: 'tool-batch'; tools: ToolEntry[] }
| { kind: 'text'; text: string }
interface ChatTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
}
interface AgentChatProps {
agentId: string
agentName: string
onBack: () => void
}
export const AgentChat: FC<AgentChatProps> = ({
agentId,
agentName,
onBack,
}) => {
const [turns, setTurns] = useState<ChatTurn[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sessionKeyRef = useRef(crypto.randomUUID())
const streamAbortRef = useRef<AbortController | null>(null)
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const scrollToBottom = () => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
useEffect(() => {
scrollToBottom()
}, [turns])
useEffect(() => {
return () => {
streamAbortRef.current?.abort()
}
}, [])
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool: ToolEntry = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running',
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const status =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId
? {
...t,
status: status as ToolEntry['status'],
durationMs,
}
: t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: ChatTurn = {
id: crypto.randomUUID(),
userText: text,
parts: [],
done: false,
}
setTurns((prev) => [...prev, turn])
setInput('')
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text,
sessionKeyRef.current,
history,
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
setStreaming(false)
}
}
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<h2 className="font-semibold text-lg">{agentName}</h2>
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.map((turn) => (
<div key={turn.id} className="space-y-3">
{/* User message */}
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{/* Assistant response — all parts grouped */}
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">
{tool.name}
</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return (
<MessageResponse key={key}>
{part.text}
</MessageResponse>
)
default:
return null
}
})}
</MessageContent>
</Message>
)}
{/* Streaming indicator when waiting for first part */}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
))}
</div>
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="Send a message..."
className="min-h-[44px] resize-none"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || streaming}
size="icon"
>
{streaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -13,6 +13,7 @@ import {
Wrench,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -32,7 +33,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { AgentChat } from './AgentChat'
import { AgentTerminal } from './AgentTerminal'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import {
@@ -235,7 +235,366 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
)
}
interface AgentsPageHeaderProps {
actionInProgress: boolean
canManageAgents: boolean
controlPlaneBusy: boolean
reconnecting: boolean
status: OpenClawStatus | null
onCreateAgent: () => void
onOpenTerminal: () => void
onReconnect: () => void
onRestart: () => void
onStop: () => void
}
const AgentsPageHeader: FC<AgentsPageHeaderProps> = ({
actionInProgress,
canManageAgents,
controlPlaneBusy,
reconnecting,
status,
onCreateAgent,
onOpenTerminal,
onReconnect,
onRestart,
onStop,
}) => (
<div className="flex items-center justify-between">
<div>
<h1 className="font-bold text-2xl">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw agents running in a local container
</p>
</div>
{status && (
<div className="flex items-center gap-2">
<StatusBadge status={status.status} />
{status.status !== 'uninitialized' && (
<ControlPlaneBadge status={status.controlPlaneStatus} />
)}
{status.status === 'running' && (
<>
{status.controlPlaneStatus !== 'connected' && (
<Button
variant="outline"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={onRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button variant="outline" onClick={onOpenTerminal}>
<TerminalSquare className="mr-1 size-4" />
Terminal
</Button>
<Button onClick={onCreateAgent} disabled={!canManageAgents}>
<Plus className="mr-1 size-4" />
New Agent
</Button>
</>
)}
</div>
)}
</div>
)
function LifecycleAlert({ message }: { message: string }) {
return (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{message}</AlertTitle>
</Alert>
)
}
function InlineErrorAlert({
message,
onDismiss,
}: {
message: string
onDismiss: () => void
}) {
return (
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>OpenClaw action failed</AlertTitle>
<AlertDescription>
<p>{message}</p>
<div className="mt-2">
<Button variant="outline" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
)
}
interface ControlPlaneAlertProps {
actionInProgress: boolean
controlPlaneBusy: boolean
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
reconnecting: boolean
recoveryDetail: string | null
status: OpenClawStatus
onReconnect: () => void
onRestart: () => void
}
const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
actionInProgress,
controlPlaneBusy,
controlPlaneCopy,
reconnecting,
recoveryDetail,
status,
onReconnect,
onRestart,
}) => (
<Alert
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
>
{status.controlPlaneStatus === 'failed' ? (
<ShieldAlert />
) : status.controlPlaneStatus === 'recovering' ? (
<Wrench />
) : (
<WifiOff />
)}
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
<AlertDescription>
<p>{controlPlaneCopy.description}</p>
{recoveryDetail && <p>{recoveryDetail}</p>}
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</AlertDescription>
</Alert>
)
interface GatewayStateCardsProps {
actionInProgress: boolean
status: OpenClawStatus | null
onOpenSetup: () => void
onRestart: () => void
onStart: () => void
}
const GatewayStateCards: FC<GatewayStateCardsProps> = ({
actionInProgress,
status,
onOpenSetup,
onRestart,
onStart,
}) => (
<>
{status?.status === 'uninitialized' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
: 'BrowserOS VM runtime is unavailable on this system.'}
</p>
</div>
{status.podmanAvailable && (
<Button onClick={onOpenSetup}>Set Up Now</Button>
)}
</CardContent>
</Card>
)}
{status?.status === 'stopped' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
</CardContent>
</Card>
)}
{status?.status === 'error' && (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">
{status.error ?? status.lastGatewayError}
</p>
</div>
<div className="flex gap-2">
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
<Button
variant="outline"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</CardContent>
</Card>
)}
</>
)
interface RunningAgentsSectionProps {
agents: AgentEntry[]
agentsLoading: boolean
canManageAgents: boolean
deleting: boolean
status: OpenClawStatus | null
onChatAgent: (agentId: string) => void
onCreateAgent: () => void
onDeleteAgent: (agentId: string) => void
}
const RunningAgentsSection: FC<RunningAgentsSectionProps> = ({
agents,
agentsLoading,
canManageAgents,
deleting,
status,
onChatAgent,
onCreateAgent,
onDeleteAgent,
}) => {
if (status?.status !== 'running') return null
if (agentsLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (agents.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8">
<p className="text-muted-foreground text-sm">
No agents yet. Create one to get started.
</p>
<Button
variant="outline"
onClick={onCreateAgent}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
</Button>
</CardContent>
</Card>
)
}
return (
<div className="space-y-3">
{agents.map((agent) => (
<Card key={agent.agentId}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="flex items-center gap-3">
<Cpu className="size-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base">{agent.name}</CardTitle>
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onChatAgent(agent.agentId)}
disabled={!canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
</Button>
{agent.agentId !== 'main' && (
<Button
variant="ghost"
size="icon"
onClick={() => onDeleteAgent(agent.agentId)}
disabled={!canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</div>
</CardHeader>
</Card>
))}
</div>
)
}
export const AgentsPage: FC = () => {
const navigate = useNavigate()
const {
status,
loading: statusLoading,
@@ -271,7 +630,6 @@ export const AgentsPage: FC = () => {
const [newName, setNewName] = useState('')
const [createProviderId, setCreateProviderId] = useState('')
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [showTerminal, setShowTerminal] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -341,7 +699,7 @@ export const AgentsPage: FC = () => {
const recoveryDetail = status ? getRecoveryDetail(status) : null
const controlPlaneCopy = status
? getControlPlaneCopy(status.controlPlaneStatus)
: null
: FALLBACK_CONTROL_PLANE_COPY
const runWithErrorHandling = async (fn: () => Promise<unknown>) => {
setError(null)
@@ -424,16 +782,6 @@ export const AgentsPage: FC = () => {
return <AgentTerminal onBack={() => setShowTerminal(false)} />
}
if (chatAgent) {
return (
<AgentChat
agentId={chatAgent.agentId}
agentName={chatAgent.name}
onBack={() => setChatAgent(null)}
/>
)
}
if (statusLoading && !status) {
return (
<div className="flex items-center justify-center py-20">
@@ -444,272 +792,61 @@ export const AgentsPage: FC = () => {
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<div className="flex items-center justify-between">
<div>
<h1 className="font-bold text-2xl">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw agents running in a local container
</p>
</div>
<AgentsPageHeader
actionInProgress={actionInProgress}
canManageAgents={canManageAgents}
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
reconnecting={reconnecting}
status={status}
onCreateAgent={() => setCreateOpen(true)}
onOpenTerminal={() => setShowTerminal(true)}
onReconnect={handleReconnect}
onRestart={handleRestart}
onStop={handleStop}
/>
{status && (
<div className="flex items-center gap-2">
<StatusBadge status={status.status} />
{status.status !== 'uninitialized' && (
<ControlPlaneBadge status={status.controlPlaneStatus} />
)}
{status.status === 'running' && (
<>
{status.controlPlaneStatus !== 'connected' && (
<Button
variant="outline"
onClick={handleReconnect}
disabled={
actionInProgress || gatewayUiState.controlPlaneBusy
}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={handleRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button variant="outline" onClick={() => setShowTerminal(true)}>
<TerminalSquare className="mr-1 size-4" />
Terminal
</Button>
<Button
onClick={() => setCreateOpen(true)}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
New Agent
</Button>
</>
)}
</div>
)}
</div>
{lifecycleBanner && (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{lifecycleBanner}</AlertTitle>
</Alert>
)}
{lifecycleBanner && <LifecycleAlert message={lifecycleBanner} />}
{inlineError && (
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>OpenClaw action failed</AlertTitle>
<AlertDescription>
<p>{inlineError}</p>
<div className="mt-2">
<Button
variant="outline"
size="sm"
onClick={() => setError(null)}
>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
<InlineErrorAlert
message={inlineError}
onDismiss={() => setError(null)}
/>
)}
{status && showControlPlaneDegraded && (
<Alert
variant={
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
}
>
{status.controlPlaneStatus === 'failed' ? (
<ShieldAlert />
) : status.controlPlaneStatus === 'recovering' ? (
<Wrench />
) : (
<WifiOff />
)}
<AlertTitle>{controlPlaneCopy?.title}</AlertTitle>
<AlertDescription>
<p>{controlPlaneCopy?.description}</p>
{recoveryDetail && <p>{recoveryDetail}</p>}
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={handleReconnect}
disabled={actionInProgress || gatewayUiState.controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</AlertDescription>
</Alert>
<ControlPlaneAlert
actionInProgress={actionInProgress}
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
controlPlaneCopy={controlPlaneCopy}
reconnecting={reconnecting}
recoveryDetail={recoveryDetail}
status={status}
onReconnect={handleReconnect}
onRestart={handleRestart}
/>
)}
{status?.status === 'uninitialized' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
: 'BrowserOS VM runtime is unavailable on this system.'}
</p>
</div>
{status.podmanAvailable && (
<Button onClick={() => setSetupOpen(true)}>Set Up Now</Button>
)}
</CardContent>
</Card>
)}
<GatewayStateCards
actionInProgress={actionInProgress}
status={status}
onOpenSetup={() => setSetupOpen(true)}
onRestart={handleRestart}
onStart={handleStart}
/>
{status?.status === 'stopped' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={handleStart} disabled={actionInProgress}>
Start Gateway
</Button>
</CardContent>
</Card>
)}
{status?.status === 'error' && (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">
{status.error ?? status.lastGatewayError}
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleStart} disabled={actionInProgress}>
Start Gateway
</Button>
<Button
variant="outline"
onClick={handleRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</CardContent>
</Card>
)}
{status?.status === 'running' && (
<div className="space-y-3">
{agentsLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : agents.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8">
<p className="text-muted-foreground text-sm">
No agents yet. Create one to get started.
</p>
<Button
variant="outline"
onClick={() => setCreateOpen(true)}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
</Button>
</CardContent>
</Card>
) : (
agents.map((agent) => (
<Card key={agent.agentId}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="flex items-center gap-3">
<Cpu className="size-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{agent.name}
</CardTitle>
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
disabled={!canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
</Button>
{agent.agentId !== 'main' && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(agent.agentId)}
disabled={!canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</div>
</CardHeader>
</Card>
))
)}
</div>
)}
<RunningAgentsSection
agents={agents}
agentsLoading={agentsLoading}
canManageAgents={canManageAgents}
deleting={deleting}
status={status}
onChatAgent={(agentId) => navigate(`/agents/${agentId}`)}
onCreateAgent={() => setCreateOpen(true)}
onDeleteAgent={(agentId) => {
void handleDelete(agentId)
}}
/>
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
<DialogContent>