Files
Dani Akash fbae45eb97 feat(agent): calm composer + redesigned hero (#931)
* feat(agent): calm composer + redesigned hero on /home

Adopt the Variant A redesign aesthetic on /home — hero text and
composer styling only. shadcn primitives and CSS variables
unchanged; conversation-screen composer untouched.

Hero:
- Larger display title (clamp 36→56px, weight 600, tighter
  letter-spacing, balanced wrap).
- Italic muted span around "work on" — small typographic accent
  that makes the hero read as designed rather than default.

Composer (variant="home" only):
- Internal dashed divider between the typing area and the footer
  chip row. The visual cornerstone of the calm aesthetic.
- Footer chips become 24px pill-shaped (rounded-full), ghost-on-
  idle / muted-bg-on-hover. Workspace and Tabs show muted trailing
  values inline (none / 0).
- Agent selector on the far left of the footer gets a filled-pill
  trigger variant (bordered, accent/40 background, mono name) to
  visually anchor the row. AgentSelector exposes a triggerVariant
  prop (ghost | pill); chat surface keeps the existing ghost.
- Subtle 1px vertical divider between the agent pill and the rest.
- Right-aligned keyboard hint (↵ to run · ⇧↵ new line) using kbd
  elements with the existing accent/border tokens.
- Outer shell gains a soft accent-orange focus-within ring.

Out of scope (future PRs): TRY suggestion chips, eyebrow strip,
recent-agents redesign, activity log.

* fix(agent): textarea bg leaks in dark mode

* style(agent): paint hero italic span in accent orange

* feat(agent): adopt calm composer aesthetic on chat-screen too

Bring the calm-composer footer (dashed divider, pill chips,
keyboard hint) over from /home to /agents/:agentId so both
surfaces share one design language.

- Rename HomeContextControls → CalmContextControls; the agent
  selector is conditional via showAgentSelector, so chat hides it
  while home keeps the filled agent pill on the left.
- Drop the legacy ContextControls function entirely (~140 LOC) and
  collapse the variant branching at the call site to a single
  CalmContextControls render.
- Add the same focus-within accent ring to ConversationShell that
  HomeShell already has, so the focus signal is consistent.

The chat composer's Stop button (between textarea and voice mic)
is unchanged — it lives outside the footer chip row and only
surfaces while streaming.

---------

Co-authored-by: DaniAkash <DaniAkash@users.noreply.function>
2026-05-05 14:18:29 +05:30

219 lines
7.5 KiB
TypeScript

import { Plus } from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import type {
HarnessAdapterDescriptor,
HarnessAgent,
} from '@/entrypoints/app/agents/agent-harness-types'
import {
useAgentAdapters,
useHarnessAgents,
} from '@/entrypoints/app/agents/useAgents'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
import { AgentCardDock } from './AgentCardDock'
import { useAgentCommandData } from './agent-command-layout'
import {
ConversationInput,
type ConversationInputSendInput,
} from './ConversationInput'
import { orderHomeAgents } from './home-agent-card.helpers'
import { setPendingInitialMessage } from './pending-initial-message'
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
return (
<Card className="border-border/60 bg-card/90 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Plus className="size-5" />
</div>
<div className="space-y-2">
<h2 className="font-semibold text-lg">No agents yet</h2>
<p className="max-w-md text-muted-foreground text-sm leading-6">
Create an agent to start using BrowserOS as an agent-first new tab.
</p>
</div>
<Button variant="outline" onClick={onOpenAgents} className="rounded-xl">
Create agent
</Button>
</CardContent>
</Card>
)
}
function RecentThreads({
activeAgentId,
agents,
adapters,
onOpenAgents,
onSelectAgent,
}: {
activeAgentId?: string | null
agents: HarnessAgent[]
adapters: HarnessAdapterDescriptor[]
onOpenAgents: () => void
onSelectAgent: (agentId: string) => void
}) {
if (agents.length === 0) return null
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-semibold text-base">Recent agents</h2>
<p className="text-muted-foreground text-sm">
Continue from where you left off.
</p>
</div>
<Button
variant="outline"
onClick={onOpenAgents}
className="rounded-xl"
size="sm"
>
Manage agents
</Button>
</div>
<AgentCardDock
agents={agents}
adapters={adapters}
activeAgentId={activeAgentId ?? undefined}
onSelectAgent={onSelectAgent}
onCreateAgent={onOpenAgents}
/>
</section>
)
}
export const AgentCommandHome: FC = () => {
const navigate = useNavigate()
const activeHint = useActiveHint()
// The conversation input still consumes the merged AgentEntry list
// from the layout context (handles legacy /claw/agents entries that
// haven't yet been backfilled into the harness store). The Recent
// Agents grid below reads the richer harness payload directly.
const { agents: legacyAgents, status } = useAgentCommandData()
const { harnessAgents } = useHarnessAgents()
const { adapters } = useAgentAdapters()
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const orderedAgents = useMemo(
() => orderHomeAgents(harnessAgents),
[harnessAgents],
)
useEffect(() => {
if (legacyAgents.length === 0) {
if (selectedAgentId) setSelectedAgentId(null)
return
}
if (
!selectedAgentId ||
!legacyAgents.some((agent) => agent.agentId === selectedAgentId)
) {
setSelectedAgentId(legacyAgents[0].agentId)
}
}, [legacyAgents, selectedAgentId])
const handleSend = (input: ConversationInputSendInput) => {
if (!selectedAgentId) return
// Stash text + attachments in the in-memory registry. Text also
// travels in `?q=` so a hard refresh / shareable URL still works
// for text-only prompts; attachments are registry-only because a
// multi-megabyte dataUrl can't ride a URL search param. The chat
// screen prefers the registry when both are present.
setPendingInitialMessage({
agentId: selectedAgentId,
text: input.text,
attachments: input.attachments,
createdAt: Date.now(),
})
navigate(
`/home/agents/${selectedAgentId}?q=${encodeURIComponent(input.text)}`,
)
}
const handleSelectAgent = (agent: AgentEntry) => {
setSelectedAgentId(agent.agentId)
}
const selectedAgent = legacyAgents.find(
(agent) => agent.agentId === selectedAgentId,
)
const selectedAgentReady = selectedAgent
? selectedAgent.source === 'agent-harness' || status?.status === 'running'
: false
const selectedAgentStatus =
selectedAgent?.source === 'agent-harness' ? 'running' : status?.status
const selectedAgentName =
selectedAgent?.name ?? orderedAgents[0]?.name ?? 'your agent'
const hasAgents = legacyAgents.length > 0
return (
<div className="min-h-full px-4 py-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
{hasAgents ? (
<>
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
<div className="space-y-3">
<h1 className="font-semibold text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.08] tracking-[-0.025em] [text-wrap:balance]">
What should your agent{' '}
<span className="font-medium text-[var(--accent-orange)] italic">
work on
</span>{' '}
next?
</h1>
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6 [text-wrap:pretty]">
Start a task, continue a thread, or hand off to a different
agent all without leaving this tab.
</p>
</div>
<div className="w-full max-w-3xl">
<ConversationInput
variant="home"
agents={legacyAgents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
onSend={handleSend}
onCreateAgent={() => navigate('/agents')}
streaming={false}
disabled={!selectedAgentReady}
status={selectedAgentStatus}
attachmentsEnabled={true}
placeholder={
selectedAgentReady
? `Ask ${selectedAgentName} to handle a task...`
: 'Agent runtime is not running...'
}
/>
</div>
</div>
<Separator />
<RecentThreads
activeAgentId={selectedAgentId}
agents={orderedAgents}
adapters={adapters}
onOpenAgents={() => navigate('/agents')}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
/>
</>
) : (
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
)}
</div>
{activeHint === 'signin' ? <SignInHint /> : null}
{activeHint === 'import' ? <ImportDataHint /> : null}
</div>
)
}