mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-21 04:45:12 +00:00
* feat(agents): durable per-agent chat message queue + composer Stop button
* fix(agents): tighten queue UI — smaller Stop, drop empty indicator, live drain attach
User feedback round 1 on the message-queue UX:
1) The Stop button matched the send/voice mics at h-10 w-10 with a
solid destructive fill, which read as alarming. Shrunk to h-8 w-8,
ghost variant with a soft destructive/10 background, smaller
filled square glyph. Reads as a calm 'stop' affordance instead of
a panic button.
2) The QueueItem's leading <QueueItemIndicator> dot was decorative
only — no state, no interaction. Dropped it from QueuePanel along
with the import; queue items now render as a clean preview line
with the trailing X remove action.
3) When the server drained the queue and started the next turn, the
chat panel didn't pick up the live stream until the user
navigated away and back. The hook's resume effect previously
only fired on agent change, not on listing-observed activeTurnId
change. Surface activeTurnId from useHarnessAgents into
useAgentConversation; effect now re-runs when the id changes,
calls /chat/active, and attaches to the new turn — so a queued
message starts streaming the moment the server drain pops it.
* fix(agents): don't reset streaming state from the resume effect's no-op paths
The Stop button was disappearing while the agent was actively
streaming, even though events were still flowing into the chat. Root
cause: the resume effect's `finally` block reset `streaming`,
`turnIdRef`, and `lastSeqRef` unconditionally — including on the
early-return paths (no active turn, or another mechanism already
owns the stream).
Sequence that triggered it:
1) User sends a message → send() sets streamAbortRef + streaming=true
and starts consuming the SSE.
2) User enqueues another message → enqueue mutation invalidates the
listing query.
3) Listing refetches with the live activeTurnId → the resume
effect re-fires (deps include activeTurnIdDep).
4) attemptResume hits `if (streamAbortRef.current) return` because
send() owns it.
5) The finally clause fires anyway and calls setStreaming(false),
clobbering the live state set by send(). The SSE consumer keeps
running (refs are intact) so text keeps streaming, but the React
flag is wrong, so the Stop button gates off.
Fix: track whether *this* run actually started a stream
(`weStartedStream`). The finally only resets state when it does.
Early-return / no-active-turn paths now leave streaming/turnIdRef/
lastSeqRef alone for whoever does own them.
Also widens the Stop button's visibility (`canStop` prop on
ConversationInput) so it stays steady across the brief gap between
turns when a queue drain is mid-flight; the parent computes
`streaming || activeTurnId !== null || queue.length > 0`. The
visibility widening is independent of the streaming-state fix above
— both are now in place.
* revert: drop canStop widening — Stop only shows while streaming
Reverts the canStop prop on ConversationInput and the OR-with-queue
visibility from AgentCommandConversation. Stop is gated solely on
`streaming` again. Between turns (queue draining) the button stays
hidden — only the actively-streaming turn is interruptible from the
composer, which matches what the user actually expects.
* fix(agents): persist the kicking-off prompt on active turns so the resume placeholder isn't empty
When a queued message drained and started a new turn, the chat
panel's resume effect staged a placeholder turn with userText: ''
because the hook had no way to know what message kicked off the
turn — only the agent-side stream was visible, and the user bubble
above it was blank until the user navigated away and back (at which
point the session record's history loaded normally).
Fix: ActiveTurnRegistry.register now accepts an optional `prompt`
that's stashed on the turn and surfaced via describe() / the
ActiveTurnInfo response. AgentHarnessService.startTurn passes the
incoming message into register. /chat/active returns it. The chat
hook's resume effect uses active.prompt as the placeholder
turn's userText, so the user bubble shows the queued message text
the moment streaming begins. Falls back to '' for older clients
that haven't been refetched yet.
* fix(agents): always release streamAbortRef on resume cleanup, even when cancelled
Greptile P1 follow-up. The previous `weStartedStream` guard correctly
stopped the resume effect's no-op early-returns from clobbering an
in-flight `send()` stream — but it also stopped a *cancelled*
mid-stream resume from clearing its own `streamAbortRef`. When the
cleanup fires (e.g. the 5s listing poll captures a new queue-drain
turn id while the SSE for the prior turn is still finishing), the
next effect run hits the `if (streamAbortRef.current) return` guard
against the now-aborted controller and never reattaches, leaving
`streaming === true` with no live stream until the user navigates
away.
Split the finally block: always release `streamAbortRef` when we
owned the controller (so the next run can take over), but only
reset the streaming flag / turn id / lastSeq on a clean exit (the
new run will set those itself, so resetting on cancel would just
flicker).
439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
import { ArrowLeft, Bot, Home } from 'lucide-react'
|
|
import { type FC, useEffect, useMemo, useRef } from 'react'
|
|
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
cancelHarnessTurn,
|
|
useEnqueueHarnessMessage,
|
|
useHarnessAgents,
|
|
useRemoveHarnessQueuedMessage,
|
|
} from '@/entrypoints/app/agents/useAgents'
|
|
import {
|
|
type AgentEntry,
|
|
getModelDisplayName,
|
|
} from '@/entrypoints/app/agents/useOpenClaw'
|
|
import { cn } from '@/lib/utils'
|
|
import { useAgentCommandData } from './agent-command-layout'
|
|
import { ClawChat } from './ClawChat'
|
|
import { ConversationInput } from './ConversationInput'
|
|
import {
|
|
buildChatHistoryFromClawMessages,
|
|
filterTurnsPersistedInHistory,
|
|
flattenHistoryPages,
|
|
} from './claw-chat-types'
|
|
import { QueuePanel } from './QueuePanel'
|
|
import { useAgentConversation } from './useAgentConversation'
|
|
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
return (
|
|
<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]">
|
|
<span
|
|
className={cn(
|
|
'size-1.5 rounded-full',
|
|
status === 'Working on your request'
|
|
? 'bg-amber-500'
|
|
: status === 'Ready'
|
|
? 'bg-emerald-500'
|
|
: status === 'Offline'
|
|
? 'bg-muted-foreground/50'
|
|
: 'bg-[var(--accent-orange)]',
|
|
)}
|
|
/>
|
|
<span>{status}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentIdentity({
|
|
name,
|
|
meta,
|
|
className,
|
|
}: {
|
|
name: string
|
|
meta: string
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={cn('min-w-0', className)}>
|
|
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
|
|
<div className="truncate text-muted-foreground text-xs leading-5">
|
|
{meta}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ConversationHeader({
|
|
agentName,
|
|
agentMeta,
|
|
status,
|
|
backLabel,
|
|
backTarget,
|
|
onGoHome,
|
|
}: {
|
|
agentName: string
|
|
agentMeta: string
|
|
status: string
|
|
backLabel: string
|
|
backTarget: 'home' | 'page'
|
|
onGoHome: () => void
|
|
}) {
|
|
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
|
|
|
return (
|
|
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onGoHome}
|
|
className="size-8 rounded-xl lg:hidden"
|
|
title={backLabel}
|
|
>
|
|
<BackIcon className="size-4" />
|
|
</Button>
|
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
|
<Bot className="size-4" />
|
|
</div>
|
|
<AgentIdentity name={agentName} meta={agentMeta} />
|
|
</div>
|
|
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
|
|
return (
|
|
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onGoHome}
|
|
className="size-8 rounded-xl"
|
|
title="Back to home"
|
|
>
|
|
<ArrowLeft className="size-4" />
|
|
</Button>
|
|
<div className="truncate font-semibold text-[15px] leading-5">
|
|
Agents
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentRailList({
|
|
activeAgentId,
|
|
agents,
|
|
onSelectAgent,
|
|
}: {
|
|
activeAgentId: string
|
|
agents: AgentEntry[]
|
|
onSelectAgent: (entry: AgentEntry) => void
|
|
}) {
|
|
return (
|
|
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
|
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
|
{agents.map((entry) => {
|
|
const active = entry.agentId === activeAgentId
|
|
const modelName = getAgentEntryMeta(entry)
|
|
|
|
return (
|
|
<button
|
|
key={entry.agentId}
|
|
type="button"
|
|
onClick={() => onSelectAgent(entry)}
|
|
className={cn(
|
|
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
|
|
active
|
|
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
|
|
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={cn(
|
|
'flex size-9 items-center justify-center rounded-xl',
|
|
active
|
|
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
|
|
: 'bg-muted text-muted-foreground',
|
|
)}
|
|
>
|
|
<Bot className="size-4" />
|
|
</div>
|
|
<AgentIdentity name={entry.name} meta={modelName} />
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
|
|
if (agent?.source === 'agent-harness') {
|
|
return getModelDisplayName(agent.model) ?? 'ACP agent'
|
|
}
|
|
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
|
}
|
|
|
|
function AgentConversationController({
|
|
agentId,
|
|
initialMessage,
|
|
onInitialMessageConsumed,
|
|
agents,
|
|
agentPathPrefix,
|
|
createAgentPath,
|
|
}: {
|
|
agentId: string
|
|
initialMessage: string | null
|
|
onInitialMessageConsumed: () => void
|
|
agents: AgentEntry[]
|
|
agentPathPrefix: string
|
|
createAgentPath: string
|
|
}) {
|
|
const navigate = useNavigate()
|
|
const initialMessageSentRef = useRef<string | null>(null)
|
|
const onInitialMessageConsumedRef = useRef(onInitialMessageConsumed)
|
|
const agent = agents.find((entry) => entry.agentId === agentId)
|
|
const agentName = agent?.name || agentId || 'Agent'
|
|
// Routing is now harness-only. Every OpenClaw agent has a harness
|
|
// record post the gateway → harness backfill, so the chat panel
|
|
// always talks to /agents/<id>/chat. The legacy ClawChat surface
|
|
// was deleted with the /claw/agents/:id/chat server route.
|
|
const harnessHistoryQuery = useHarnessChatHistory(agentId, Boolean(agent))
|
|
|
|
const historyMessages = useMemo(
|
|
() =>
|
|
flattenHistoryPages(
|
|
harnessHistoryQuery.data ? [harnessHistoryQuery.data] : [],
|
|
),
|
|
[harnessHistoryQuery.data],
|
|
)
|
|
const chatHistory = useMemo(
|
|
() => buildChatHistoryFromClawMessages(historyMessages),
|
|
[historyMessages],
|
|
)
|
|
|
|
// Listing query feeds queue + active-turn state for this agent. We
|
|
// already poll it every 5s for the rail; reusing the same cache
|
|
// keeps cross-tab queue state in sync without a second poll.
|
|
const { harnessAgents } = useHarnessAgents()
|
|
const harnessAgent = harnessAgents.find((entry) => entry.id === agentId)
|
|
const queue = harnessAgent?.queue ?? []
|
|
const activeTurnId = harnessAgent?.activeTurnId ?? null
|
|
|
|
const { turns, streaming, send } = useAgentConversation(agentId, {
|
|
runtime: 'agent-harness',
|
|
sessionKey: null,
|
|
history: chatHistory,
|
|
activeTurnId,
|
|
onComplete: () => {
|
|
void harnessHistoryQuery.refetch()
|
|
},
|
|
onSessionKeyChange: () => {},
|
|
})
|
|
const enqueueMessage = useEnqueueHarnessMessage()
|
|
const removeQueuedMessage = useRemoveHarnessQueuedMessage()
|
|
|
|
const handleStop = () => {
|
|
void cancelHarnessTurn(agentId, {
|
|
turnId: activeTurnId ?? undefined,
|
|
reason: 'user pressed stop',
|
|
})
|
|
}
|
|
const visibleTurns = useMemo(
|
|
() => filterTurnsPersistedInHistory(turns, historyMessages),
|
|
[historyMessages, turns],
|
|
)
|
|
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
|
|
|
const disabled = !agent
|
|
const historyReady =
|
|
harnessHistoryQuery.isFetched || harnessHistoryQuery.isError
|
|
const initialMessageKey = initialMessage
|
|
? `${agentId}:${initialMessage}`
|
|
: null
|
|
const error = harnessHistoryQuery.error ?? null
|
|
|
|
const sendRef = useRef(send)
|
|
sendRef.current = send
|
|
|
|
useEffect(() => {
|
|
const query = initialMessage?.trim()
|
|
if (!initialMessageKey) {
|
|
initialMessageSentRef.current = null
|
|
return
|
|
}
|
|
|
|
if (
|
|
!query ||
|
|
initialMessageSentRef.current === initialMessageKey ||
|
|
disabled ||
|
|
!historyReady
|
|
) {
|
|
return
|
|
}
|
|
|
|
initialMessageSentRef.current = initialMessageKey
|
|
onInitialMessageConsumedRef.current()
|
|
void sendRef.current({ text: query })
|
|
}, [disabled, historyReady, initialMessage, initialMessageKey])
|
|
|
|
const handleSelectAgent = (entry: AgentEntry) => {
|
|
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-col overflow-hidden">
|
|
<ClawChat
|
|
agentName={agentName}
|
|
historyMessages={historyMessages}
|
|
turns={visibleTurns}
|
|
streaming={streaming}
|
|
isInitialLoading={harnessHistoryQuery.isLoading}
|
|
error={error}
|
|
hasNextPage={false}
|
|
isFetchingNextPage={false}
|
|
onFetchNextPage={() => {}}
|
|
onRetry={() => {
|
|
void harnessHistoryQuery.refetch()
|
|
}}
|
|
/>
|
|
|
|
<div className="border-border/50 border-t bg-background/88 px-4 py-3 backdrop-blur-md">
|
|
<div className="mx-auto max-w-3xl space-y-3">
|
|
{queue.length > 0 ? (
|
|
<QueuePanel
|
|
queue={queue}
|
|
onRemove={(messageId) =>
|
|
removeQueuedMessage.mutate({ agentId, messageId })
|
|
}
|
|
/>
|
|
) : null}
|
|
<ConversationInput
|
|
variant="conversation"
|
|
agents={agents}
|
|
selectedAgentId={agentId}
|
|
onSelectAgent={handleSelectAgent}
|
|
onSend={(input) => {
|
|
const attachments = input.attachments.map((a) => a.payload)
|
|
const attachmentPreviews = input.attachments.map((a) => ({
|
|
id: a.id,
|
|
kind: a.kind,
|
|
mediaType: a.mediaType,
|
|
name: a.name,
|
|
dataUrl: a.dataUrl,
|
|
}))
|
|
// When the agent already has an in-flight turn, route
|
|
// the new message into the durable queue instead of
|
|
// starting a parallel turn. Drains automatically as
|
|
// soon as the active turn ends.
|
|
if (streaming || activeTurnId) {
|
|
enqueueMessage.mutate({
|
|
agentId,
|
|
message: input.text,
|
|
attachments,
|
|
})
|
|
return
|
|
}
|
|
void send({ text: input.text, attachments, attachmentPreviews })
|
|
}}
|
|
onCreateAgent={() => navigate(createAgentPath)}
|
|
onStop={handleStop}
|
|
streaming={streaming}
|
|
disabled={disabled}
|
|
status="running"
|
|
attachmentsEnabled={true}
|
|
placeholder={
|
|
streaming
|
|
? `Type to queue another message for ${agentName}...`
|
|
: `Message ${agentName}...`
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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()
|
|
const { agents } = useAgentCommandData()
|
|
const shouldRedirectHome = !agentId
|
|
const resolvedAgentId = agentId ?? ''
|
|
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
|
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
|
const agentMeta = getAgentEntryMeta(agent)
|
|
const initialMessage = searchParams.get('q')
|
|
const isPageVariant = variant === 'page'
|
|
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
|
|
|
if (shouldRedirectHome) {
|
|
return <Navigate to="/home" replace />
|
|
}
|
|
|
|
const handleSelectAgent = (entry: AgentEntry) => {
|
|
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
|
}
|
|
|
|
// Every visible agent runs through the harness now, so per-agent
|
|
// runtime status doesn't gate chat the way OpenClaw's legacy
|
|
// gateway lifecycle did. Show "Ready" once the agent record is
|
|
// resolved from the rail, "Setup" otherwise.
|
|
const statusCopy = agent ? 'Ready' : 'Setup'
|
|
|
|
return (
|
|
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
|
<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)]">
|
|
<AgentRailHeader onGoHome={() => navigate(backPath)} />
|
|
|
|
<ConversationHeader
|
|
agentName={agentName}
|
|
agentMeta={agentMeta}
|
|
status={statusCopy}
|
|
backLabel={backLabel}
|
|
backTarget={isPageVariant ? 'page' : 'home'}
|
|
onGoHome={() => navigate(backPath)}
|
|
/>
|
|
|
|
<AgentRailList
|
|
activeAgentId={resolvedAgentId}
|
|
agents={agents}
|
|
onSelectAgent={handleSelectAgent}
|
|
/>
|
|
|
|
<AgentConversationController
|
|
key={resolvedAgentId}
|
|
agentId={resolvedAgentId}
|
|
agents={agents}
|
|
initialMessage={initialMessage}
|
|
onInitialMessageConsumed={() =>
|
|
setSearchParams({}, { replace: true })
|
|
}
|
|
agentPathPrefix={agentPathPrefix}
|
|
createAgentPath={createAgentPath}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|