mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-20 20:39:10 +00:00
* feat(server): foundation for OpenClaw agent file-output attribution
Phase 1 of TKT-762 — surface files OpenClaw agents produce as
artifacts inline in chat + a per-agent Outputs rail. This commit
lays the storage + I/O foundation only; turn-lifecycle wiring,
HTTP routes, and UI follow in subsequent phases.
- New `produced_files` Drizzle table (FK→agent_definitions with
cascade, unique on (agent, path) so re-modifications upsert).
Migration 0002_chemical_whirlwind.sql. Adapter-agnostic schema
— V1 only enables the watcher for openclaw, V2 can plug Claude
/ Codex into the same table without migrating.
- `ProducedFilesStore` — snapshot/finalize-turn diff API plus
by-turn / by-agent queries and a path-resolver that enforces
workspace-root containment for the download / preview routes.
- `walkWorkspace` — bounded recursive workspace walker; skips
symlinks (no host-fs smuggling), excludes node_modules / .git /
.cache, hard-capped at 50k entries / depth 16.
- `file-preview` helper — extension + magic-byte MIME detection,
bounded text-snippet reader (1 MB cap), inline image base64
reader (4 MB cap). Streaming download path lives in the route
layer (next phase) — this module only handles the small
in-memory reads the preview UX needs.
* feat(server): attribute openclaw turn outputs to the harness layer
Phase 2 of TKT-762 — wire the per-turn workspace diff into the
single dispatch path that owns every turn's lifecycle. Two prior
wiring points the original plan named (the OpenClaw HTTP chat
route + OutboundQueueService.tryDispatch) were collapsed in dev
into agent-harness-service.runDetachedTurn — both direct sends
and queued sends route through it now, so a single hook covers
both. The old `OutboundQueueService` is gone; its successor
`message-queue.ts` re-enters runDetachedTurn for the queued
case, so we still only need to bracket once.
Changes:
- New `produced_files` variant on `AgentStreamEvent` so the
inline artifact card has a wire-format hook independent of the
REST API.
- `ProducedFilesStore` gains `resolveAgentDefinitionId` to bridge
gateway-side openclaw agent names to the harness's
`agent_definitions.id`, handling both the reconciled-row shape
(id == openclaw name) and the BrowserOS-created shape
(id = oc-<uuid>, name = openclaw display name).
- `AgentHarnessService.runDetachedTurn`: snapshot the openclaw
workspace before `runtime.send(...)`, finalize the diff in the
outer finally, push the resulting rows as a `produced_files`
event. Adapter-gated to openclaw only — Claude / Codex agents
write to the user's own filesystem and don't need
attribution.
- Skip attribution on user-cancel (`abort.signal.aborted`) so
the side effects of an aborted turn don't get surfaced as
"outputs you asked for." On runtime errors we still attribute,
because partial outputs are what the user is most likely to
want to recover.
- Lazy-init the store via `tryGetProducedFilesStore()` so tests
that swap in a fake `agentStore` don't trip the
process-wide `getDb()` initialisation guard.
- File attribution extracted into `attributeTurnFiles` helper to
keep `runDetachedTurn`'s cognitive complexity under the lint
ceiling.
Verifications:
- Server tsgo --noEmit clean for changed files.
- 162/162 server-api tests pass.
- Biome lint clean on all three changed files.
* feat(server): expose produced-files HTTP API for /agents
Phase 3 of TKT-762 — surface the rows Phase 2 attributes via four
read-only endpoints under the existing `/agents` router. Mounted
where the agents page already polls so the rail UI doesn't add
a second router/origin to its trust boundary.
Routes:
- GET /agents/:agentId/files
Outputs-rail data, grouped by the assistant turn that
produced each batch, newest first. `?limit=` clamps to N
rows server-side (default 200).
- GET /agents/:agentId/files/turn/:turnId
Per-turn refresh — used by the inline-card consumer to
rebuild metadata after the SSE `produced_files` event lands,
and by direct fetches that missed the live event.
- GET /agents/files/:fileId/preview
Discriminated `FilePreview` JSON: text snippet (≤1MB),
base64 image (≤4MB), pdf metadata, or `binary` placeholder
when neither preview path applies. 404 when the file id is
unknown OR the on-disk file disappeared after attribution.
- GET /agents/files/:fileId/download
Streams raw bytes via `Bun.file().stream()` with
`Content-Disposition: attachment` and the detected MIME
type. The fileId is opaque — the server resolves the agent
and on-disk path; the client never sees a path, so traversal
is impossible by construction.
Service layer:
- `AgentHarnessService` gains `listAgentFiles`,
`listAgentFilesForTurn`, `previewProducedFile`, and
`resolveProducedFileForDownload`. All four are no-ops for
claude / codex adapters (they return null/[]) so the route
contract stays uniform across adapters even though only
openclaw produces rows in v1.
- New `ProducedFileEntry` and `ProducedFilesRailGroup` DTOs —
trimmed wire shapes that strip `agentDefinitionId` and
`sessionKey` from the on-disk row.
Verifications:
- Server tsgo --noEmit clean for changed files (only pre-
existing `Bun` global warning).
- 162/162 server-api tests pass.
- Biome clean on both changed files.
Smoke-test instructions for the route shape live in the plan
under §6 and §8; full end-to-end smoke happens in Phase 6.
* feat(agent): client-side hooks + types for agent file outputs
Phase 4 of TKT-762 — frontend foundation for the inline artifact
card and the per-agent Outputs rail. UI components themselves
land in Phase 5; this commit only adds types, hooks, and shared
helpers so the wiring is in place when the components arrive.
New module: `apps/agent/lib/agent-files/`
- `types.ts` — `ProducedFile`, `ProducedFilesRailGroup`, and the
discriminated `FilePreview` union, mirrored from the server-side
DTOs in `apps/server/src/api/services/agents/agent-harness-service.ts`.
The `agentDefinitionId` / `sessionKey` columns on the on-disk
rows deliberately do NOT exist at the type boundary — clients
refer to files by opaque `id`.
- `file-helpers.ts` — pure helpers: `inferFileKind` (icon
routing), `formatFileSize`, `extensionOf`, `basenameOf`,
`buildFileDownloadUrl`. No React, no fetch, no DOM — anything
stateful belongs in the hooks.
- `useAgentOutputs.ts` — `useAgentOutputs(agentId)` for the rail,
`useAgentTurnFiles(agentId, turnId)` for the inline card,
`useInvalidateAgentOutputs()` for the chat-stream-completion
hook (Phase 5 will plumb this), and `useRefreshAgentOutputs()`
for the rail's manual refresh button.
- `useFilePreview.ts` — `useFilePreview(fileId)` with
`staleTime: Infinity` (previews are immutable for a given id;
no point refetching on focus). Always opt-in (`enabled`) — the
preview only loads when the user clicks a row.
- `index.ts` — barrel re-export so consumers import from one path.
Touched in `apps/agent/entrypoints/app/agents/`:
- `agent-harness-types.ts` — added `produced_files` variant + the
`HarnessProducedFile` type to `AgentHarnessStreamEvent`. Mirrors
the server-side change from Phase 2 so the client SSE consumer
type-narrows correctly.
- `useAgents.ts` — exported the previously-private `agentsFetch`
helper and the `AGENT_QUERY_KEYS` registry so the agent-files
hooks reuse them without duplicating fetch / key conventions.
Three new keys added: `agentOutputs`, `agentTurnFiles`,
`filePreview`.
Verifications:
- Agent tsgo --noEmit clean.
- Biome clean on all touched files.
* feat(agent): inline artifact card + per-agent outputs rail
Wires the chat surface to the produced-files API shipped earlier:
- Inline artifact card under each assistant turn that produced files,
populated by the live `produced_files` SSE event (resumes also stamp
`turnId` so a missed live event can fall back to the per-turn fetch).
- Collapsible right-side Outputs rail on the agent conversation page,
grouped by turn, with Refresh + per-agent open/close persistence in
localStorage. Gated to openclaw adapters in v1.
- Shared file preview Sheet branches on the FilePreview union: text
snippet (markdown for `.md`/`.mdx`, otherwise pre+code), image data
URL, and download-only fallback for pdf/binary/missing.
- Conversation hook invalidates the rail's React Query cache from its
finally block so newly attributed files appear without a manual
refresh.
* feat(agent-files): polish — symlink-safe paths + toast on failures
- `resolveFilePath` now rejects symlink-escapes from the workspace
by realpath-resolving both endpoints and re-checking containment.
Lexical traversal (`..` segments) still fails fast without
touching the filesystem.
- Added `produced-files-store.test.ts` with 6 path-resolution cases
including a symlink whose target lives outside the workspace
root — the prior string-only check would have allowed this.
- File preview Sheet: surfaces preview-load failures in a toast
(in addition to the inline error block, which is easy to miss
when the body has scrolled). Download button now intercepts the
click so a missing baseUrl shows a toast instead of silently
hiding the button.
- Outputs rail: refresh failures fire `toast.error` with the
underlying message.
* fix(agent-files): drop duplicate `/agents` prefix from client paths
`agentsFetch` / `buildAgentApiUrl` already prepend `/agents`, but
the file-output hooks were passing fully-qualified paths
(`/agents/<id>/files`, `/agents/files/<id>/preview`, etc.) which
resolved to `/agents/agents/...` and 404'd. Fixed the four call
sites to pass paths relative to the `/agents` root.
* fix(agents): strip openclaw role envelope from chat history
PR #924 introduced a second `<role>…</role>` prefix for openclaw
turns — a single-line block distinct from the multi-line BrowserOS
role TKT-774 wired the unwrap against. Because TKT-774's
`stripOuterRoleEnvelope` matched the BrowserOS prefix exactly, the
openclaw envelope sailed through unstripped and user messages on
openclaw agents rendered the full preamble in /sessions/main/history
responses.
Make the strip adapter-agnostic: any
`<role …>…</role>\n\n<user_request>\n…\n</user_request>` shape gets
unwrapped. Drops the now-unused BROWSEROS_ACP_AGENT_INSTRUCTIONS
constant and adds a regression test that uses the openclaw form
verbatim.
* feat(agent-files): inline file-card strip with rail deep-link
Replaces Phase 5's row-list ArtifactCard with a horizontal strip
of small file cards under any assistant turn that produced files.
Click a card → opens the FilePreviewSheet directly (preview +
download). Click View / +N → opens the per-agent Outputs rail and
scrolls / expands the matching turn group.
The card strip:
- Caps at 4 visible cards; remainder collapses into a +N pill that
shares the View handler.
- Owns its own FilePreviewSheet instance (parallel to the
deprecated ArtifactCard) so the per-card preview path doesn't
fight with the rail's Sheet.
- Hidden during streaming and absent when producedFiles is empty.
- Adapter-gated upstream: AgentCommandConversation only passes the
open-rail callback when adapter==='openclaw', so claude / codex
agents render no rail-opening affordance.
Rail changes:
- Accepts focusTurnId + onFocusTurnConsumed; the matching
RailTurnGroup expands and scrollIntoView's on focus, then fires
the consumed callback so the parent can drop the URL state.
- ?outputsTurn=<turnId> deep-links work: external nav opens the
rail, sets focusTurnId, and clears the param after consumption.
ArtifactCard is marked @deprecated; remove in a follow-up once
nothing imports it.
* fix(agent-files): keep file-card strip visible after history reload
After Phase 7 the inline FileCardStrip vanished as soon as a turn
finished: `filterTurnsPersistedInHistory` dropped the optimistic
turn once history reloaded, and history items don't carry
`producedFiles`. So the user could see a file produced inside an
assistant message but no card to open it.
Two fixes in tandem so the strip survives both the just-finished
case AND a fresh page load:
- New `selectStripOnlyTurns` keeps persisted turns that still
carry `producedFiles`. `ConversationMessage` learns a
`stripOnly` mode that renders only the trailing strip (no
duplicate user/assistant bubbles, since those are rendered by
`ClawChatMessage`).
- `AgentCommandConversation` now also calls `useAgentOutputs` and
passes `tailStripGroups` to `ClawChat`. Each rail group not
already covered by a live or strip-only turn renders as its own
tail `FileCardStrip` after history. Dedup keys on `turnId` so
the same turn never doubles up.
Adapter-gated upstream — claude / codex agents skip the
useAgentOutputs fetch entirely. The card click still opens the
preview Sheet directly; View / +N still deep-link to the rail at
the matching turn group.
* fix(agent-files): per-turn association + cache invalidation
Two fixes for the inline file-card strip:
1. Strips were stacking at the conversation tail because every
produced-files group rendered as a tail strip after history.
New `mapHistoryToProducedFilesGroups` matches each group to
the assistant history message that came from its turn — by
`group.turnPrompt` vs the first non-blank line of the
preceding user message — and ClawChat renders the strip
directly under that bubble. Groups that don't match any
history pair (orphans) still fall through to the tail.
2. `useInvalidateAgentOutputs` was passing `undefined` as the
baseUrl placeholder to `invalidateQueries({ queryKey })` —
react-query's positional partial-match doesn't treat
undefined as a wildcard, so the cache stayed stale until the
query refetched on its own (e.g. window focus). Switched to
predicate-based invalidation that matches by [agentOutputs
marker, agentId] regardless of baseUrl. Same for the per-turn
files key.
Net effect: send a turn that produces files → strip appears
under the just-finished assistant message; reload the page →
strips still appear under the right bubbles, not bunched at
the bottom.
* fix(agent-files): review feedback — name guard, RFC 5987, limit cap
Three review-flagged issues:
1. Path traversal via agent display name — `getHostWorkspaceDir`
accepted any string and `path.join`'d it, so a name like
`../../tmp` escaped `.openclaw`. The pre-turn snapshot would
then walk that escaped directory and attribute every file to
the new turn; resolveSafeWorkspacePath's containment check is
relative to the same escaped root so it would later serve
arbitrary host paths. Added `isAgentWorkspaceNameSafe` (rejects
`..`, separators, control chars, leading dots, empty); the
builder now throws on unsafe names plus a defensive
realpath-style containment check after the join. Harness
wraps the call so the path-traversal trip just disables file
attribution for the turn instead of failing the whole send.
Six-case regression test pinned.
2. `encodeRfc6266Filename` JSDoc claimed an RFC 5987
`filename*=UTF-8''<percent-encoded>` fallback but the impl
only stripped CRLFs/quotes. Now actually emits the fallback
when non-ASCII is present; helper returns the full
`filename="…"; filename*=UTF-8''…` attribute pair so the call
site doesn't have to wrap in quotes.
3. `/agents/:agentId/files` `?limit=` was forwarded to the DB
uncapped — extracted `parseAgentFilesLimit` that clamps to
[1, 500] before forwarding.
Also extracted `resolveSafeWorkspaceDir` + `snapshotWorkspaceForTurn`
helpers off `runDetachedTurn` so the new safety branch doesn't
push it past biome's cognitive-complexity cap.
524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
import { ArrowLeft, PanelRight } from 'lucide-react'
|
|
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
|
import { Button } from '@/components/ui/button'
|
|
import type {
|
|
HarnessAgent,
|
|
HarnessAgentAdapter,
|
|
} from '@/entrypoints/app/agents/agent-harness-types'
|
|
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
|
import {
|
|
cancelHarnessTurn,
|
|
useAgentAdapters,
|
|
useEnqueueHarnessMessage,
|
|
useHarnessAgents,
|
|
useRemoveHarnessQueuedMessage,
|
|
useUpdateHarnessAgent,
|
|
} from '@/entrypoints/app/agents/useAgents'
|
|
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
|
import { type ProducedFilesRailGroup, useAgentOutputs } from '@/lib/agent-files'
|
|
import { cn } from '@/lib/utils'
|
|
import { AgentRail } from './AgentRail'
|
|
import { useAgentCommandData } from './agent-command-layout'
|
|
import {
|
|
OutputsRail,
|
|
useOutputsRailOpen,
|
|
} from './agent-conversation.outputs-rail'
|
|
import { ClawChat } from './ClawChat'
|
|
import { ConversationHeader } from './ConversationHeader'
|
|
import { ConversationInput } from './ConversationInput'
|
|
import {
|
|
buildChatHistoryFromClawMessages,
|
|
filterTurnsPersistedInHistory,
|
|
flattenHistoryPages,
|
|
mapHistoryToProducedFilesGroups,
|
|
selectStripOnlyTurns,
|
|
} from './claw-chat-types'
|
|
import { consumePendingInitialMessage } from './pending-initial-message'
|
|
import { QueuePanel } from './QueuePanel'
|
|
import { useAgentConversation } from './useAgentConversation'
|
|
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
|
|
|
function AgentConversationController({
|
|
agentId,
|
|
initialMessage,
|
|
onInitialMessageConsumed,
|
|
agents,
|
|
agentPathPrefix,
|
|
createAgentPath,
|
|
onOpenOutputsRail,
|
|
}: {
|
|
agentId: string
|
|
initialMessage: string | null
|
|
onInitialMessageConsumed: () => void
|
|
agents: AgentEntry[]
|
|
agentPathPrefix: string
|
|
createAgentPath: string
|
|
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
|
}) {
|
|
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 isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
|
|
|
// Used to surface produced-files strips on a fresh page load
|
|
// when there's no optimistic turn to carry the data. Disabled
|
|
// for non-openclaw adapters since they don't attribute files.
|
|
const { groups: agentOutputGroups } = useAgentOutputs(
|
|
agentId,
|
|
isOpenClawAgent,
|
|
)
|
|
|
|
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],
|
|
)
|
|
// Persisted turns that still need to surface their FileCardStrip
|
|
// — history items don't carry produced-files data, so without
|
|
// these the strip would vanish on history reload.
|
|
const stripOnlyTurns = useMemo(
|
|
() => selectStripOnlyTurns(turns, historyMessages),
|
|
[historyMessages, turns],
|
|
)
|
|
// Two outputs from the per-turn matcher:
|
|
// - filesByAssistantId → strip rendered directly under the
|
|
// matching assistant history bubble.
|
|
// - tailUnmatched → groups with no history pair (orphans);
|
|
// rendered at the conversation tail.
|
|
// Both are filtered to exclude turnIds already covered by a
|
|
// live or strip-only optimistic turn (those carry their own
|
|
// strip and history hasn't reloaded yet).
|
|
const { filesByAssistantId, tailStripGroups } = useMemo(() => {
|
|
if (!isOpenClawAgent) {
|
|
return {
|
|
filesByAssistantId: new Map<string, ProducedFilesRailGroup>(),
|
|
tailStripGroups: [] as ProducedFilesRailGroup[],
|
|
}
|
|
}
|
|
const coveredTurnIds = new Set<string>()
|
|
for (const turn of turns) {
|
|
if (turn.turnId) coveredTurnIds.add(turn.turnId)
|
|
}
|
|
const eligibleGroups = agentOutputGroups.filter(
|
|
(group) => !coveredTurnIds.has(group.turnId),
|
|
)
|
|
const { byAssistantMessageId, unmatched } = mapHistoryToProducedFilesGroups(
|
|
historyMessages,
|
|
eligibleGroups,
|
|
)
|
|
return {
|
|
filesByAssistantId: byAssistantMessageId,
|
|
tailStripGroups: unmatched,
|
|
}
|
|
}, [agentOutputGroups, isOpenClawAgent, 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(() => {
|
|
if (disabled || !historyReady) return
|
|
|
|
// Registry-first: when the user submitted at /home with
|
|
// attachments, the rich payload is here. URL `?q=` may also be
|
|
// present and is the text-only fallback path; the registry wins
|
|
// when both exist because it carries the binary attachments
|
|
// alongside the text.
|
|
const pending = consumePendingInitialMessage(agentId)
|
|
if (pending) {
|
|
// Mark the dedup ref so the text-only branch below doesn't
|
|
// re-fire on the same render.
|
|
if (initialMessageKey) {
|
|
initialMessageSentRef.current = initialMessageKey
|
|
}
|
|
onInitialMessageConsumedRef.current()
|
|
void sendRef.current({
|
|
text: pending.text,
|
|
attachments: pending.attachments.map((a) => a.payload),
|
|
attachmentPreviews: pending.attachments.map((a) => ({
|
|
id: a.id,
|
|
kind: a.kind,
|
|
mediaType: a.mediaType,
|
|
name: a.name,
|
|
dataUrl: a.dataUrl,
|
|
})),
|
|
})
|
|
return
|
|
}
|
|
|
|
const query = initialMessage?.trim()
|
|
if (!initialMessageKey) {
|
|
// Reset is safe even on the post-registry-fire re-run: consume
|
|
// is destructive, so the registry is already drained — there's
|
|
// nothing left for a third run to re-send.
|
|
initialMessageSentRef.current = null
|
|
return
|
|
}
|
|
|
|
if (!query || initialMessageSentRef.current === initialMessageKey) {
|
|
return
|
|
}
|
|
|
|
initialMessageSentRef.current = initialMessageKey
|
|
onInitialMessageConsumedRef.current()
|
|
void sendRef.current({ text: query })
|
|
}, [agentId, disabled, historyReady, initialMessage, initialMessageKey])
|
|
|
|
const handleSelectAgent = (entry: AgentEntry) => {
|
|
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<ClawChat
|
|
agentName={agentName}
|
|
historyMessages={historyMessages}
|
|
turns={visibleTurns}
|
|
stripOnlyTurns={stripOnlyTurns}
|
|
filesByAssistantId={filesByAssistantId}
|
|
tailStripGroups={tailStripGroups}
|
|
streaming={streaming}
|
|
isInitialLoading={harnessHistoryQuery.isLoading}
|
|
error={error}
|
|
hasNextPage={false}
|
|
isFetchingNextPage={false}
|
|
onFetchNextPage={() => {}}
|
|
onOpenOutputsRail={onOpenOutputsRail}
|
|
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
|
|
}
|
|
|
|
function inferAdapterFromEntry(
|
|
entry: AgentEntry | undefined,
|
|
): HarnessAgentAdapter | 'unknown' {
|
|
if (!entry) return 'unknown'
|
|
if (entry.source === 'agent-harness') {
|
|
// Harness entries don't carry the adapter on AgentEntry; the rail
|
|
// / header read the harness record directly. This branch only runs
|
|
// before the harness query resolves, so 'unknown' is correct — the
|
|
// tile's bot fallback renders until data arrives.
|
|
return 'unknown'
|
|
}
|
|
// OpenClaw-only entries (no harness shadow) are deprecated in
|
|
// practice but the rail still tolerates them.
|
|
return 'openclaw'
|
|
}
|
|
|
|
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 { harnessAgents } = useHarnessAgents()
|
|
const { adapters } = useAgentAdapters()
|
|
const updateAgent = useUpdateHarnessAgent()
|
|
|
|
const shouldRedirectHome = !agentId
|
|
const resolvedAgentId = agentId ?? ''
|
|
const harnessAgent = harnessAgents.find(
|
|
(entry) => entry.id === resolvedAgentId,
|
|
)
|
|
const entry = agents.find((item) => item.agentId === resolvedAgentId)
|
|
const fallbackName = entry?.name || resolvedAgentId || 'Agent'
|
|
const fallbackAdapter = inferAdapterFromEntry(entry)
|
|
const initialMessage = searchParams.get('q')
|
|
const isPageVariant = variant === 'page'
|
|
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
|
|
|
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
|
const [outputsRailOpen, setOutputsRailOpen] =
|
|
useOutputsRailOpen(resolvedAgentId)
|
|
const railVisible = isOpenClawAgent && outputsRailOpen
|
|
|
|
// Deep-link target for the rail. Set when (a) the user clicks
|
|
// View / +N on an inline file-card strip, or (b) an external nav
|
|
// arrived with `?outputsTurn=<turnId>`. Cleared by the rail
|
|
// itself once it has scrolled to + expanded the matching group.
|
|
const urlOutputsTurn = searchParams.get('outputsTurn')
|
|
const [focusTurnId, setFocusTurnId] = useState<string | null>(urlOutputsTurn)
|
|
// If the URL param flips while we're already on this agent, sync.
|
|
useEffect(() => {
|
|
if (!urlOutputsTurn) return
|
|
setFocusTurnId(urlOutputsTurn)
|
|
if (isOpenClawAgent) setOutputsRailOpen(true)
|
|
}, [urlOutputsTurn, isOpenClawAgent, setOutputsRailOpen])
|
|
|
|
const handleOpenOutputsRail = (turnId?: string | null) => {
|
|
if (!isOpenClawAgent) return
|
|
setOutputsRailOpen(true)
|
|
setFocusTurnId(turnId ?? null)
|
|
}
|
|
const handleFocusTurnConsumed = () => {
|
|
setFocusTurnId(null)
|
|
if (urlOutputsTurn) {
|
|
// Drop the URL param so a back-nav doesn't re-trigger the
|
|
// scroll. `replace: true` keeps history clean.
|
|
setSearchParams(
|
|
(prev) => {
|
|
const next = new URLSearchParams(prev)
|
|
next.delete('outputsTurn')
|
|
return next
|
|
},
|
|
{ replace: true },
|
|
)
|
|
}
|
|
}
|
|
|
|
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
|
|
const adapterId = harnessAgent?.adapter
|
|
if (!adapterId) return null
|
|
const descriptor = adapters.find((item) => item.id === adapterId)
|
|
if (!descriptor?.health) return null
|
|
return {
|
|
healthy: descriptor.health.healthy,
|
|
reason: descriptor.health.reason,
|
|
}
|
|
}, [adapters, harnessAgent?.adapter])
|
|
|
|
if (shouldRedirectHome) {
|
|
return <Navigate to="/home" replace />
|
|
}
|
|
|
|
const handleSelectHarnessAgent = (target: HarnessAgent) => {
|
|
navigate(`${agentPathPrefix}/${target.id}`)
|
|
}
|
|
|
|
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
|
|
if (!target) return
|
|
updateAgent.mutate({
|
|
agentId: target.id,
|
|
patch: { pinned: next },
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
|
<div className="mx-auto flex h-full w-full max-w-[1480px] flex-col">
|
|
{/* Shared top band — the rail's "Agents" header and the chat
|
|
header live on one row so they're aligned by construction. */}
|
|
<div className="flex shrink-0 items-stretch border-border/50 border-b">
|
|
<div className="hidden min-h-[60px] w-[288px] shrink-0 items-center gap-3 border-border/50 border-r px-4 lg:flex">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate(backPath)}
|
|
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 className="min-w-0 flex-1">
|
|
<ConversationHeader
|
|
agent={harnessAgent ?? null}
|
|
fallbackName={fallbackName}
|
|
fallbackAdapter={fallbackAdapter}
|
|
adapterHealth={adapterHealth}
|
|
backLabel={backLabel}
|
|
backTarget={isPageVariant ? 'page' : 'home'}
|
|
onGoHome={() => navigate(backPath)}
|
|
onPinToggle={(next) =>
|
|
handlePinToggle(harnessAgent ?? null, next)
|
|
}
|
|
headerExtra={
|
|
isOpenClawAgent ? (
|
|
<Button
|
|
variant={railVisible ? 'secondary' : 'ghost'}
|
|
size="icon"
|
|
className="size-8 rounded-xl"
|
|
onClick={() => setOutputsRailOpen(!railVisible)}
|
|
title={railVisible ? 'Hide outputs' : 'Show outputs'}
|
|
>
|
|
<PanelRight className="size-4" />
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body grid: rail list + chat (+ outputs rail when an
|
|
openclaw agent has it open). Columns share the same top
|
|
edge as the band above so headers can never drift. */}
|
|
<div
|
|
className={cn(
|
|
'grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]',
|
|
railVisible
|
|
? 'lg:grid-cols-[288px_minmax(0,1fr)_320px]'
|
|
: 'lg:grid-cols-[288px_minmax(0,1fr)]',
|
|
)}
|
|
>
|
|
<AgentRail
|
|
agents={harnessAgents}
|
|
adapters={adapters}
|
|
activeAgentId={resolvedAgentId}
|
|
onSelectAgent={handleSelectHarnessAgent}
|
|
onPinToggle={(target, next) => handlePinToggle(target, next)}
|
|
/>
|
|
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
|
<AgentConversationController
|
|
key={resolvedAgentId}
|
|
agentId={resolvedAgentId}
|
|
agents={agents}
|
|
initialMessage={initialMessage}
|
|
onInitialMessageConsumed={() => {
|
|
// Preserve the outputsTurn deep-link if present —
|
|
// dropping all params would erase the rail focus
|
|
// before it had a chance to consume.
|
|
setSearchParams(
|
|
(prev) => {
|
|
const next = new URLSearchParams()
|
|
const turn = prev.get('outputsTurn')
|
|
if (turn) next.set('outputsTurn', turn)
|
|
return next
|
|
},
|
|
{ replace: true },
|
|
)
|
|
}}
|
|
agentPathPrefix={agentPathPrefix}
|
|
createAgentPath={createAgentPath}
|
|
onOpenOutputsRail={isOpenClawAgent ? handleOpenOutputsRail : null}
|
|
/>
|
|
</div>
|
|
|
|
{railVisible ? (
|
|
<OutputsRail
|
|
agentId={resolvedAgentId}
|
|
onClose={() => setOutputsRailOpen(false)}
|
|
focusTurnId={focusTurnId}
|
|
onFocusTurnConsumed={handleFocusTurnConsumed}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|