Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface AgentListProps {
deletingAgentKey: string | null
onCreateAgent: () => void
onDeleteAgent: (agent: AgentListItem) => void
onOpenTerminal?: (agent: AgentListItem) => void
onPinToggle: (agent: AgentListItem, next: boolean) => void
}

Expand All @@ -42,6 +43,7 @@ export const AgentList: FC<AgentListProps> = ({
deletingAgentKey,
onCreateAgent,
onDeleteAgent,
onOpenTerminal,
onPinToggle,
}) => {
const adapterHealth = useMemo(() => {
Expand Down Expand Up @@ -104,6 +106,7 @@ export const AgentList: FC<AgentListProps> = ({
data={data}
deleting={deletingAgentKey === agent.key}
onDelete={onDeleteAgent}
onOpenTerminal={onOpenTerminal}
onPinToggle={onPinToggle}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const AgentRowCard: FC<AgentRowCardProps> = ({
data,
deleting,
onDelete,
onOpenTerminal,
onPinToggle,
}) => {
return (
Expand Down Expand Up @@ -92,6 +93,7 @@ export const AgentRowCard: FC<AgentRowCardProps> = ({
activeTurnId={data.activeTurnId}
deleting={deleting}
onDelete={onDelete}
onOpenTerminal={onOpenTerminal}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,28 @@ import { getAgentServerUrl } from '@/lib/browseros/helpers'

interface AgentTerminalProps {
onBack: () => void
target?: TerminalTargetId
agentId?: string
initialCommand?: string
onSessionExit?: () => void
}

type TerminalTargetId = 'openclaw' | 'claude' | 'codex' | 'hermes'

interface TerminalTargetOption {
id: TerminalTargetId
label: string
workingDir: string
shell: string
}

type TerminalServerMessage =
| { type: 'output'; data: string }
| { type: 'exit'; exitCode: number }
| { type: 'error'; message: string }

const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
const DEFAULT_TARGET: TerminalTargetId = 'openclaw'
const TERMINAL_FONT_FAMILY =
'"Geist Mono", Menlo, Monaco, "Courier New", monospace'

Expand Down Expand Up @@ -118,11 +130,14 @@ function parseTerminalMessage(data: unknown): TerminalServerMessage | null {

export const AgentTerminal: FC<AgentTerminalProps> = ({
onBack,
target = DEFAULT_TARGET,
agentId,
initialCommand,
onSessionExit,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const sentInitialCommandRef = useRef(false)
// Refs keep the mount-once effect from tearing down the PTY when the
// parent re-renders with new inline callbacks.
const initialCommandRef = useRef(initialCommand)
Expand All @@ -131,6 +146,45 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
onSessionExitRef.current = onSessionExit

const [copied, setCopied] = useState(false)
const [selectedTarget, setSelectedTarget] = useState<TerminalTargetId>(target)
const [targets, setTargets] = useState<TerminalTargetOption[]>([])

const selectedTargetInfo = targets.find(
(entry) => entry.id === selectedTarget,
)
const workingDir = selectedTargetInfo?.workingDir ?? TERMINAL_HOME_DIR
const shell = selectedTargetInfo?.shell ?? OPENCLAW_TERMINAL_SHELL

useEffect(() => {
setSelectedTarget(target)
}, [target])

useEffect(() => {
const ac = new AbortController()
const loadTargets = async (): Promise<void> => {
try {
const baseUrl = await getAgentServerUrl()
if (ac.signal.aborted) return
const url = new URL('/terminal/targets', baseUrl)
if (agentId) url.searchParams.set('agentId', agentId)
const res = await fetch(url, { signal: ac.signal })
if (!res.ok) return
const body = (await res.json()) as { targets?: TerminalTargetOption[] }
const nextTargets = body.targets ?? []
setTargets(nextTargets)
setSelectedTarget((current) =>
nextTargets.length > 0 &&
!nextTargets.some((entry) => entry.id === current)
? nextTargets[0].id
: current,
)
} catch {
if (!ac.signal.aborted) setTargets([])
}
}
void loadTargets()
return () => ac.abort()
}, [agentId])

// Copy the current xterm selection to the browser clipboard. No-op
// if nothing is selected — users who want the whole buffer can
Expand All @@ -152,6 +206,8 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
useEffect(() => {
if (!containerRef.current) return

sentInitialCommandRef.current = false

const terminal = new Terminal({
fontSize: 14,
fontFamily: TERMINAL_FONT_FAMILY,
Expand Down Expand Up @@ -226,6 +282,8 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
if (ac.signal.aborted) return
const wsUrl = new URL('/terminal/ws', baseUrl)
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl.searchParams.set('target', selectedTarget)
if (agentId) wsUrl.searchParams.set('agentId', agentId)

ws = new WebSocket(wsUrl)
// If the effect was cleaned up between the await above and now,
Expand All @@ -242,7 +300,10 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
terminal.focus()
sendResize()
const cmd = initialCommandRef.current
if (cmd) sendMessage({ type: 'input', data: `${cmd}\n` })
if (cmd && !sentInitialCommandRef.current) {
sentInitialCommandRef.current = true
sendMessage({ type: 'input', data: `${cmd}\n` })
}
}

ws.onmessage = (event) => {
Expand Down Expand Up @@ -303,7 +364,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
terminal.dispose()
terminalRef.current = null
}
}, [])
}, [agentId, selectedTarget])

return (
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
Expand All @@ -318,10 +379,25 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
Container Terminal
</div>
<div className="truncate text-muted-foreground text-sm">
OpenClaw shell in {TERMINAL_HOME_DIR}
{selectedTargetInfo?.label ?? 'Managed runtime'} shell
</div>
</div>
</div>
{targets.length > 1 && (
<select
value={selectedTarget}
onChange={(event) =>
setSelectedTarget(event.currentTarget.value as TerminalTargetId)
}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
{targets.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
)}
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? (
<Check className="mr-1 size-3.5" />
Expand All @@ -336,10 +412,10 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
<div className="agent-terminal-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background">
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-2.5">
<div className="truncate font-mono text-muted-foreground text-xs">
{TERMINAL_HOME_DIR}
{workingDir}
</div>
<div className="font-mono text-[11px] text-muted-foreground">
{OPENCLAW_TERMINAL_SHELL.split('/').pop()}
{shell.split('/').pop()}
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ import {
} from './useAgents'
import { useOpenClawAgents, useOpenClawMutations } from './useOpenClaw'

type TerminalTargetId = 'openclaw' | 'claude' | 'codex' | 'hermes'

interface TerminalLaunch {
target: TerminalTargetId
agentId?: string
initialCommand?: string
}

export const AgentsPage: FC = () => {
const navigate = useNavigate()
const { providers, defaultProviderId } = useLlmProviders()
Expand Down Expand Up @@ -108,7 +116,9 @@ export const AgentsPage: FC = () => {
const [harnessModelId, setHarnessModelId] = useState('')
const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('')
const [createHermesProviderId, setCreateHermesProviderId] = useState('')
const [showTerminal, setShowTerminal] = useState(false)
const [terminalLaunch, setTerminalLaunch] = useState<TerminalLaunch | null>(
null,
)
const [cliAuthModalOpen, setCliAuthModalOpen] = useState(false)
const [pageError, setPageError] = useState<string | null>(null)
const [createError, setCreateError] = useState<string | null>(null)
Expand Down Expand Up @@ -232,6 +242,20 @@ export const AgentsPage: FC = () => {
setHarnessReasoningEffort(descriptor?.defaultReasoningEffort ?? '')
}

const handleOpenAgentTerminal = (agent: {
agentId: string
runtimeLabel: string
}) => {
const target =
harnessAgentLookup.get(agent.agentId)?.adapter ??
inferTerminalTarget(agent.runtimeLabel)
if (!target) return
setTerminalLaunch({
target,
agentId: agent.agentId,
})
}

const { handleCreate, handleDelete, handleSetup, runWithPageErrorHandling } =
createAgentPageActions({
createProviderId,
Expand All @@ -258,14 +282,22 @@ export const AgentsPage: FC = () => {
setupOpenClaw,
})

if (showTerminal) {
return <AgentTerminal onBack={() => setShowTerminal(false)} />
if (terminalLaunch) {
return (
<AgentTerminal
onBack={() => setTerminalLaunch(null)}
target={terminalLaunch.target}
agentId={terminalLaunch.agentId}
initialCommand={terminalLaunch.initialCommand}
/>
)
}

if (cliAuthModalOpen && authTerminalProvider) {
return (
<AgentTerminal
onBack={() => setCliAuthModalOpen(false)}
target="openclaw"
initialCommand={authTerminalProvider.authLoginCommand}
onSessionExit={() => setCliAuthModalOpen(false)}
/>
Expand Down Expand Up @@ -345,7 +377,7 @@ export const AgentsPage: FC = () => {
<GatewayStatusBar
status={status}
actionInProgress={actionInProgress}
onOpenTerminal={() => setShowTerminal(true)}
onOpenTerminal={() => setTerminalLaunch({ target: 'openclaw' })}
onRestart={() => {
void runWithPageErrorHandling(restartOpenClaw)
}}
Expand All @@ -363,6 +395,7 @@ export const AgentsPage: FC = () => {
onDeleteAgent={(agent) => {
void handleDelete(agent)
}}
onOpenTerminal={handleOpenAgentTerminal}
onPinToggle={(agent, next) => {
// Optimistic mutation; harness-only — gateway-original
// OpenClaw entries are gated server-side via the harness
Expand Down Expand Up @@ -430,3 +463,12 @@ export const AgentsPage: FC = () => {
</div>
)
}

function inferTerminalTarget(label: string): TerminalTargetId | null {
const lower = label.toLowerCase()
if (lower === 'claude code') return 'claude'
if (lower === 'codex') return 'codex'
if (lower === 'hermes') return 'hermes'
Comment on lines 463 to +471
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 inferTerminalTarget matches on display label strings

The fallback path for resolving the terminal target compares runtimeLabel.toLowerCase() against hard-coded strings like 'claude code'. If a display name is updated elsewhere (e.g., in RuntimeDescriptor.displayName), this silently returns null and the "Open terminal" action becomes a no-op without any user feedback. Prefer using the adapter field from harnessAgentLookup exclusively, or derive a stable adapter ID that doesn't depend on UI labels.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx
Line: 463-471

Comment:
**`inferTerminalTarget` matches on display label strings**

The fallback path for resolving the terminal target compares `runtimeLabel.toLowerCase()` against hard-coded strings like `'claude code'`. If a display name is updated elsewhere (e.g., in `RuntimeDescriptor.displayName`), this silently returns `null` and the "Open terminal" action becomes a no-op without any user feedback. Prefer using the `adapter` field from `harnessAgentLookup` exclusively, or derive a stable adapter ID that doesn't depend on UI labels.

How can I resolve this? If you propose a fix, please make it concise.

if (lower === 'openclaw') return 'openclaw'
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'bun:test'
import type { HarnessAdapterDescriptor } from './agent-harness-types'
import { getAdapterReadinessAlert } from './new-agent-dialog.helpers'

const baseAdapter: HarnessAdapterDescriptor = {
id: 'claude',
name: 'Claude Code',
defaultModelId: 'default',
defaultReasoningEffort: 'medium',
modelControl: 'best-effort',
models: [],
reasoningEfforts: [],
}

describe('getAdapterReadinessAlert', () => {
it('blocks creation and explains the selected unhealthy runtime', () => {
expect(
getAdapterReadinessAlert({
...baseAdapter,
health: {
healthy: false,
reason: 'Container is stopped. Call start() first.',
checkedAt: 123,
},
}),
).toEqual({
title: 'Claude Code runtime is not ready',
description: 'Container is stopped. Call start() first.',
})
})

it('does not warn for healthy adapters', () => {
expect(
getAdapterReadinessAlert({
...baseAdapter,
health: { healthy: true, checkedAt: 123 },
}),
).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
HarnessAgentAdapter,
} from './agent-harness-types'
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
import { getAdapterReadinessAlert } from './new-agent-dialog.helpers'
import { ProviderSelector } from './OpenClawControls'
import {
type OpenClawCliProvider,
Expand Down Expand Up @@ -95,6 +96,11 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
}) => {
const selectedHarnessAdapter =
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
const selectedRuntimeAdapter =
createRuntime === 'openclaw'
? undefined
: adapters.find((adapter) => adapter.id === createRuntime)
const adapterReadinessAlert = getAdapterReadinessAlert(selectedRuntimeAdapter)
const isHarnessRuntime = createRuntime !== 'openclaw'
const isHermesRuntime = createRuntime === 'hermes'
const isClassicHarnessRuntime = isHarnessRuntime && !isHermesRuntime
Expand All @@ -112,6 +118,7 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
!openClawBlocked &&
!cliBlocked &&
!hermesBlocked &&
!adapterReadinessAlert &&
(createRuntime === 'openclaw'
? providers.length > 0
: Boolean(selectedHarnessAdapter))
Expand Down Expand Up @@ -176,6 +183,16 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
</Select>
</div>

{adapterReadinessAlert ? (
<Alert>
<AlertCircle className="size-4" />
<AlertTitle>{adapterReadinessAlert.title}</AlertTitle>
<AlertDescription>
{adapterReadinessAlert.description}
</AlertDescription>
</Alert>
) : null}

{createRuntime === 'openclaw' ? (
<>
{openClawBlocked ? (
Expand Down
Loading
Loading