Skip to content

Commit 84e2739

Browse files
authored
feat(agent): rich rail + header on /agents/:agentId chat (#908)
* feat(agent): rich rail + header on /agents/:agentId chat Replace the chat screen's legacy AgentEntry rail and binary READY header with the same rich data the /agents page already exposes: adapter glyph, liveness dot, pin star, status badge, adapter · model · reasoning chip line, last-used time, lifetime tokens, queue count, and the Adapter Unavailable warning. Source of truth flips from the merged AgentEntry list to useHarnessAgents() directly. Sort order matches /agents (pinned → recency) — not /home (active-first → recency) — because chat is index-shaped and shuffling rows every 5s as turns transition would be jarring while reading. Lift the inline pin-then-recency comparator out of /agents AgentList.tsx into a shared agents-list-order.ts so both surfaces stay on identical sort semantics. * fix(agent): chat header height + composer sticking to bottom Header was clipping descenders because the strip was vertical-content sized at min-h-14 with tight py-2.5; bump padding and lean on natural content height. Drop the AgentTile glyph (the rail row already shows adapter identity) and the cwd path (too long, pushed the meta line off-screen). Header is now name + pin star + status pill, then adapter · model · reasoning, then last-used · tokens · queued. Composer was floating mid-screen on short chats because the chat grid had no grid-template-rows — the implicit auto row collapsed to content height, so the right-column flex wrapper never received the full container height. Add grid-rows-[minmax(0,1fr)] so the single row claims 100% and ClawChat's flex-1 expands to push the composer flush to the bottom. * fix(agent): composer flush to bottom on short chats Match the sidepanel chat's nested-flex pattern. The right-column wrapper got h-full so it expands to the grid row; the conversation controller's root added flex-1 so ClawChat's existing flex-1 has something to actually fill against. Without these, the grid cell stretched but the inner flex columns shrank to content height, leaving the composer floating mid-screen. * fix(agent): align rail header with chat header in shared top band Pull the rail's "Agents" + back-button into the same horizontal strip as the agent identity header. The two halves now sit on a single row that spans both columns, so they can't drift in height as the chat header gains/loses meta lines (last-used, tokens, queued). The rail below the band keeps its scrollable list only; the chat column below holds the conversation + composer. Border-bottom moves from ConversationHeader to the band wrapper so we don't get a double-rule on the boundary. * fix(agent): reserve header height to prevent layout shift on data load The chat header grew from a single line to three lines once the useHarnessAgents() poll resolved (adapter chips + meta line populate asynchronously), shoving the rail and conversation body downward. Lock min-h-[84px] on both the band's left "Agents" cell and the ConversationHeader root, and always render the meta line slot (non-breaking space when empty) so the typographic frame is stable regardless of data state. * refactor(agent): pull status pill + meta to right side of chat header Two-column header layout instead of three stacked rows: name + pin star + adapter chips on the left, status pill stacked on top of the last-used / tokens / queued meta line on the right. Drops min-h from 84px → 60px so the band reclaims ~24px of vertical space and the chat body starts higher on screen. Band's left "Agents" cell matches the new height.
1 parent 974e7e9 commit 84e2739

7 files changed

Lines changed: 627 additions & 215 deletions

File tree

packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandConversation.tsx

Lines changed: 115 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
1-
import { ArrowLeft, Bot, Home } from 'lucide-react'
1+
import { ArrowLeft } from 'lucide-react'
22
import { type FC, useEffect, useMemo, useRef } from 'react'
33
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
44
import { Button } from '@/components/ui/button'
5+
import type {
6+
HarnessAgent,
7+
HarnessAgentAdapter,
8+
} from '@/entrypoints/app/agents/agent-harness-types'
9+
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
510
import {
611
cancelHarnessTurn,
12+
useAgentAdapters,
713
useEnqueueHarnessMessage,
814
useHarnessAgents,
915
useRemoveHarnessQueuedMessage,
16+
useUpdateHarnessAgent,
1017
} from '@/entrypoints/app/agents/useAgents'
11-
import {
12-
type AgentEntry,
13-
getModelDisplayName,
14-
} from '@/entrypoints/app/agents/useOpenClaw'
15-
import { cn } from '@/lib/utils'
18+
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
19+
import { AgentRail } from './AgentRail'
1620
import { useAgentCommandData } from './agent-command-layout'
1721
import { ClawChat } from './ClawChat'
22+
import { ConversationHeader } from './ConversationHeader'
1823
import { ConversationInput } from './ConversationInput'
1924
import {
2025
buildChatHistoryFromClawMessages,
@@ -25,162 +30,6 @@ import { QueuePanel } from './QueuePanel'
2530
import { useAgentConversation } from './useAgentConversation'
2631
import { useHarnessChatHistory } from './useHarnessChatHistory'
2732

28-
function StatusBadge({ status }: { status: string }) {
29-
return (
30-
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1 text-[11px] text-muted-foreground uppercase tracking-[0.18em]">
31-
<span
32-
className={cn(
33-
'size-1.5 rounded-full',
34-
status === 'Working on your request'
35-
? 'bg-amber-500'
36-
: status === 'Ready'
37-
? 'bg-emerald-500'
38-
: status === 'Offline'
39-
? 'bg-muted-foreground/50'
40-
: 'bg-[var(--accent-orange)]',
41-
)}
42-
/>
43-
<span>{status}</span>
44-
</div>
45-
)
46-
}
47-
48-
function AgentIdentity({
49-
name,
50-
meta,
51-
className,
52-
}: {
53-
name: string
54-
meta: string
55-
className?: string
56-
}) {
57-
return (
58-
<div className={cn('min-w-0', className)}>
59-
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
60-
<div className="truncate text-muted-foreground text-xs leading-5">
61-
{meta}
62-
</div>
63-
</div>
64-
)
65-
}
66-
67-
function ConversationHeader({
68-
agentName,
69-
agentMeta,
70-
status,
71-
backLabel,
72-
backTarget,
73-
onGoHome,
74-
}: {
75-
agentName: string
76-
agentMeta: string
77-
status: string
78-
backLabel: string
79-
backTarget: 'home' | 'page'
80-
onGoHome: () => void
81-
}) {
82-
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
83-
84-
return (
85-
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
86-
<div className="flex min-w-0 items-center gap-3">
87-
<Button
88-
variant="ghost"
89-
size="icon"
90-
onClick={onGoHome}
91-
className="size-8 rounded-xl lg:hidden"
92-
title={backLabel}
93-
>
94-
<BackIcon className="size-4" />
95-
</Button>
96-
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
97-
<Bot className="size-4" />
98-
</div>
99-
<AgentIdentity name={agentName} meta={agentMeta} />
100-
</div>
101-
102-
<StatusBadge status={status} />
103-
</div>
104-
)
105-
}
106-
107-
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
108-
return (
109-
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
110-
<div className="flex min-w-0 items-center gap-3">
111-
<Button
112-
variant="ghost"
113-
size="icon"
114-
onClick={onGoHome}
115-
className="size-8 rounded-xl"
116-
title="Back to home"
117-
>
118-
<ArrowLeft className="size-4" />
119-
</Button>
120-
<div className="truncate font-semibold text-[15px] leading-5">
121-
Agents
122-
</div>
123-
</div>
124-
</div>
125-
)
126-
}
127-
128-
function AgentRailList({
129-
activeAgentId,
130-
agents,
131-
onSelectAgent,
132-
}: {
133-
activeAgentId: string
134-
agents: AgentEntry[]
135-
onSelectAgent: (entry: AgentEntry) => void
136-
}) {
137-
return (
138-
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
139-
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
140-
{agents.map((entry) => {
141-
const active = entry.agentId === activeAgentId
142-
const modelName = getAgentEntryMeta(entry)
143-
144-
return (
145-
<button
146-
key={entry.agentId}
147-
type="button"
148-
onClick={() => onSelectAgent(entry)}
149-
className={cn(
150-
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
151-
active
152-
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
153-
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
154-
)}
155-
>
156-
<div className="flex items-center gap-3">
157-
<div
158-
className={cn(
159-
'flex size-9 items-center justify-center rounded-xl',
160-
active
161-
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
162-
: 'bg-muted text-muted-foreground',
163-
)}
164-
>
165-
<Bot className="size-4" />
166-
</div>
167-
<AgentIdentity name={entry.name} meta={modelName} />
168-
</div>
169-
</button>
170-
)
171-
})}
172-
</div>
173-
</aside>
174-
)
175-
}
176-
177-
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
178-
if (agent?.source === 'agent-harness') {
179-
return getModelDisplayName(agent.model) ?? 'ACP agent'
180-
}
181-
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
182-
}
183-
18433
function AgentConversationController({
18534
agentId,
18635
initialMessage,
@@ -289,7 +138,7 @@ function AgentConversationController({
289138
}
290139

291140
return (
292-
<div className="flex min-h-0 flex-col overflow-hidden">
141+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
293142
<ClawChat
294143
agentName={agentName}
295144
historyMessages={historyMessages}
@@ -368,6 +217,22 @@ interface AgentCommandConversationProps {
368217
createAgentPath?: string
369218
}
370219

220+
function inferAdapterFromEntry(
221+
entry: AgentEntry | undefined,
222+
): HarnessAgentAdapter | 'unknown' {
223+
if (!entry) return 'unknown'
224+
if (entry.source === 'agent-harness') {
225+
// Harness entries don't carry the adapter on AgentEntry; the rail
226+
// / header read the harness record directly. This branch only runs
227+
// before the harness query resolves, so 'unknown' is correct — the
228+
// tile's bot fallback renders until data arrives.
229+
return 'unknown'
230+
}
231+
// OpenClaw-only entries (no harness shadow) are deprecated in
232+
// practice but the rail still tolerates them.
233+
return 'openclaw'
234+
}
235+
371236
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
372237
variant = 'command',
373238
backPath = '/home',
@@ -378,60 +243,110 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
378243
const [searchParams, setSearchParams] = useSearchParams()
379244
const navigate = useNavigate()
380245
const { agents } = useAgentCommandData()
246+
const { harnessAgents } = useHarnessAgents()
247+
const { adapters } = useAgentAdapters()
248+
const updateAgent = useUpdateHarnessAgent()
249+
381250
const shouldRedirectHome = !agentId
382251
const resolvedAgentId = agentId ?? ''
383-
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
384-
const agentName = agent?.name || resolvedAgentId || 'Agent'
385-
const agentMeta = getAgentEntryMeta(agent)
252+
const harnessAgent = harnessAgents.find(
253+
(entry) => entry.id === resolvedAgentId,
254+
)
255+
const entry = agents.find((item) => item.agentId === resolvedAgentId)
256+
const fallbackName = entry?.name || resolvedAgentId || 'Agent'
257+
const fallbackAdapter = inferAdapterFromEntry(entry)
386258
const initialMessage = searchParams.get('q')
387259
const isPageVariant = variant === 'page'
388260
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
389261

262+
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
263+
const adapterId = harnessAgent?.adapter
264+
if (!adapterId) return null
265+
const descriptor = adapters.find((item) => item.id === adapterId)
266+
if (!descriptor?.health) return null
267+
return {
268+
healthy: descriptor.health.healthy,
269+
reason: descriptor.health.reason,
270+
}
271+
}, [adapters, harnessAgent?.adapter])
272+
390273
if (shouldRedirectHome) {
391274
return <Navigate to="/home" replace />
392275
}
393276

394-
const handleSelectAgent = (entry: AgentEntry) => {
395-
navigate(`${agentPathPrefix}/${entry.agentId}`)
277+
const handleSelectHarnessAgent = (target: HarnessAgent) => {
278+
navigate(`${agentPathPrefix}/${target.id}`)
396279
}
397280

398-
// Every visible agent runs through the harness now, so per-agent
399-
// runtime status doesn't gate chat the way OpenClaw's legacy
400-
// gateway lifecycle did. Show "Ready" once the agent record is
401-
// resolved from the rail, "Setup" otherwise.
402-
const statusCopy = agent ? 'Ready' : 'Setup'
281+
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
282+
if (!target) return
283+
updateAgent.mutate({
284+
agentId: target.id,
285+
patch: { pinned: next },
286+
})
287+
}
403288

404289
return (
405290
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
406-
<div className="mx-auto grid h-full w-full max-w-[1480px] lg:grid-cols-[288px_minmax(0,1fr)] lg:grid-rows-[3.5rem_minmax(0,1fr)]">
407-
<AgentRailHeader onGoHome={() => navigate(backPath)} />
408-
409-
<ConversationHeader
410-
agentName={agentName}
411-
agentMeta={agentMeta}
412-
status={statusCopy}
413-
backLabel={backLabel}
414-
backTarget={isPageVariant ? 'page' : 'home'}
415-
onGoHome={() => navigate(backPath)}
416-
/>
291+
<div className="mx-auto flex h-full w-full max-w-[1480px] flex-col">
292+
{/* Shared top band — the rail's "Agents" header and the chat
293+
header live on one row so they're aligned by construction. */}
294+
<div className="flex shrink-0 items-stretch border-border/50 border-b">
295+
<div className="hidden min-h-[60px] w-[288px] shrink-0 items-center gap-3 border-border/50 border-r px-4 lg:flex">
296+
<Button
297+
variant="ghost"
298+
size="icon"
299+
onClick={() => navigate(backPath)}
300+
className="size-8 rounded-xl"
301+
title="Back to home"
302+
>
303+
<ArrowLeft className="size-4" />
304+
</Button>
305+
<div className="truncate font-semibold text-[15px] leading-5">
306+
Agents
307+
</div>
308+
</div>
309+
<div className="min-w-0 flex-1">
310+
<ConversationHeader
311+
agent={harnessAgent ?? null}
312+
fallbackName={fallbackName}
313+
fallbackAdapter={fallbackAdapter}
314+
adapterHealth={adapterHealth}
315+
backLabel={backLabel}
316+
backTarget={isPageVariant ? 'page' : 'home'}
317+
onGoHome={() => navigate(backPath)}
318+
onPinToggle={(next) =>
319+
handlePinToggle(harnessAgent ?? null, next)
320+
}
321+
/>
322+
</div>
323+
</div>
417324

418-
<AgentRailList
419-
activeAgentId={resolvedAgentId}
420-
agents={agents}
421-
onSelectAgent={handleSelectAgent}
422-
/>
325+
{/* Body grid: rail list + chat. Both columns share the same
326+
top edge (the band above) so headers can never drift. */}
327+
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] lg:grid-cols-[288px_minmax(0,1fr)]">
328+
<AgentRail
329+
agents={harnessAgents}
330+
adapters={adapters}
331+
activeAgentId={resolvedAgentId}
332+
onSelectAgent={handleSelectHarnessAgent}
333+
onPinToggle={(target, next) => handlePinToggle(target, next)}
334+
/>
423335

424-
<AgentConversationController
425-
key={resolvedAgentId}
426-
agentId={resolvedAgentId}
427-
agents={agents}
428-
initialMessage={initialMessage}
429-
onInitialMessageConsumed={() =>
430-
setSearchParams({}, { replace: true })
431-
}
432-
agentPathPrefix={agentPathPrefix}
433-
createAgentPath={createAgentPath}
434-
/>
336+
<div className="flex h-full min-h-0 flex-col overflow-hidden">
337+
<AgentConversationController
338+
key={resolvedAgentId}
339+
agentId={resolvedAgentId}
340+
agents={agents}
341+
initialMessage={initialMessage}
342+
onInitialMessageConsumed={() =>
343+
setSearchParams({}, { replace: true })
344+
}
345+
agentPathPrefix={agentPathPrefix}
346+
createAgentPath={createAgentPath}
347+
/>
348+
</div>
349+
</div>
435350
</div>
436351
</div>
437352
)

0 commit comments

Comments
 (0)