mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-21 04:45:12 +00:00
* 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.
180 lines
5.8 KiB
TypeScript
180 lines
5.8 KiB
TypeScript
import { ArrowLeft, Home } from 'lucide-react'
|
|
import type { FC } from 'react'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
|
|
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
|
|
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
|
|
import { formatTokens } from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
|
|
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
|
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
|
|
import type { AgentLiveness } from '@/entrypoints/app/agents/LivenessDot'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface ConversationHeaderProps {
|
|
agent: HarnessAgent | null
|
|
fallbackName: string
|
|
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown'
|
|
adapterHealth: AgentAdapterHealth | null
|
|
backLabel: string
|
|
backTarget: 'home' | 'page'
|
|
onGoHome: () => void
|
|
onPinToggle: (next: boolean) => void
|
|
}
|
|
|
|
/**
|
|
* Strip above the chat. Mirrors the `/agents` row card's title row +
|
|
* summary chips so the user gets adapter health, pin state, and status
|
|
* at a glance — but adds the meta line (last used · lifetime tokens ·
|
|
* queued) that's specific to this surface.
|
|
*
|
|
* The mobile `lg:hidden` Back button is preserved so the small-screen
|
|
* collapse keeps a navigable header without a sidebar.
|
|
*/
|
|
export const ConversationHeader: FC<ConversationHeaderProps> = ({
|
|
agent,
|
|
fallbackName,
|
|
fallbackAdapter,
|
|
adapterHealth,
|
|
backLabel,
|
|
backTarget,
|
|
onGoHome,
|
|
onPinToggle,
|
|
}) => {
|
|
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
|
const adapter = agent?.adapter ?? fallbackAdapter
|
|
const status: AgentLiveness = agent?.status ?? 'unknown'
|
|
const lastUsedAt = agent?.lastUsedAt ?? null
|
|
const pinned = agent?.pinned ?? false
|
|
const queueCount = agent?.queue?.length ?? 0
|
|
const tokens = agent?.tokens ?? null
|
|
const lifetimeTotal = tokens
|
|
? tokens.cumulative.input + tokens.cumulative.output
|
|
: 0
|
|
|
|
const metaParts: string[] = []
|
|
if (lastUsedAt !== null) metaParts.push(formatRelativeTime(lastUsedAt))
|
|
if (lifetimeTotal > 0) metaParts.push(`${formatTokens(lifetimeTotal)} tokens`)
|
|
if (queueCount > 0) {
|
|
metaParts.push(queueCount === 1 ? '1 queued' : `${queueCount} queued`)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-[60px] shrink-0 items-center justify-between gap-4 px-5 py-2.5">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onGoHome}
|
|
className="size-8 shrink-0 rounded-xl lg:hidden"
|
|
title={backLabel}
|
|
>
|
|
<BackIcon className="size-4" />
|
|
</Button>
|
|
<div className="group min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-semibold text-[15px] leading-6">
|
|
{agent?.name || fallbackName}
|
|
</span>
|
|
{agent ? (
|
|
<PinToggle pinned={pinned} onToggle={onPinToggle} />
|
|
) : null}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-2">
|
|
<AgentSummaryChips
|
|
adapter={adapter}
|
|
modelLabel={agent?.modelId ?? null}
|
|
reasoningEffort={agent?.reasoningEffort ?? null}
|
|
adapterHealth={adapterHealth}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
<StatusPill
|
|
status={status}
|
|
hasActiveTurn={Boolean(agent?.activeTurnId)}
|
|
/>
|
|
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
|
|
<span className="truncate">
|
|
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface StatusPillProps {
|
|
status: AgentLiveness
|
|
hasActiveTurn: boolean
|
|
}
|
|
|
|
/**
|
|
* Working / Asleep / Attention all get distinctive styling; idle keeps
|
|
* the legacy emerald `Ready` pill so the default state is visually
|
|
* calm. Defensive working: `idle + activeTurnId` falls through to the
|
|
* working pill since the server says a turn is in flight.
|
|
*/
|
|
const StatusPill: FC<StatusPillProps> = ({ status, hasActiveTurn }) => {
|
|
const effective: AgentLiveness =
|
|
status === 'idle' && hasActiveTurn ? 'working' : status
|
|
|
|
const base =
|
|
'inline-flex items-center gap-2 rounded-full border px-3 py-0.5 text-[11px] uppercase tracking-[0.18em]'
|
|
|
|
if (effective === 'working') {
|
|
return (
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn(
|
|
base,
|
|
'border-amber-200 bg-amber-50 text-amber-900 hover:bg-amber-50',
|
|
)}
|
|
>
|
|
<span className="size-1.5 animate-pulse rounded-full bg-amber-500" />
|
|
Working
|
|
</Badge>
|
|
)
|
|
}
|
|
if (effective === 'asleep') {
|
|
return (
|
|
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
|
|
<span className="size-1.5 rounded-full bg-muted-foreground/50" />
|
|
Asleep
|
|
</Badge>
|
|
)
|
|
}
|
|
if (effective === 'error') {
|
|
return (
|
|
<Badge
|
|
variant="destructive"
|
|
className={cn(base, 'border-destructive/30')}
|
|
>
|
|
<span className="size-1.5 rounded-full bg-destructive-foreground" />
|
|
Attention
|
|
</Badge>
|
|
)
|
|
}
|
|
if (effective === 'idle') {
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
base,
|
|
'border-emerald-200 bg-emerald-50 text-emerald-900 hover:bg-emerald-50',
|
|
)}
|
|
>
|
|
<span className="size-1.5 rounded-full bg-emerald-500" />
|
|
Ready
|
|
</Badge>
|
|
)
|
|
}
|
|
return (
|
|
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
|
|
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
|
|
Setup
|
|
</Badge>
|
|
)
|
|
}
|