Compare commits

..

1 Commits

Author SHA1 Message Date
Nikhil Sonti
1f504702ec feat(build-tools): add cache:sync:dev for local tarball seeding
Seeds ~/.browseros-dev/cache/vm/ from ./dist/ without touching R2, so
devs can test the server against a freshly-built tarball before anything
is published to cdn.browseros.com. Hardcodes arm64 since all devs are on
Apple Silicon; refuses to run unless NODE_ENV=development; idempotent
(skips copy on sha256 match).

Also fixes the R2_BUCKET default in .env.sample from browseros-artifacts
to browseros to match the actual bucket.
2026-04-23 10:30:04 -07:00
683 changed files with 23845 additions and 62401 deletions

View File

@@ -1,152 +0,0 @@
---
name: ask-internal
description: Answer questions about BrowserOS internal stuff (setup, features, architecture, design decisions) by reading the private internal-docs submodule and the codebase. Use for "how do I X", "where is Y", "what is the deal with Z", or any question that mixes ops/setup knowledge with code knowledge. Can execute steps with per-command confirmation.
allowed-tools: Bash, Read, Grep, Glob, Edit, Write
---
# Ask Internal
Answer team-internal questions by reading `.internal-docs/` and the codebase, synthesizing a direct answer with file:line citations, and optionally running surfaced commands with confirmation.
**Announce at start:** "I'm using the ask-internal skill to answer this from internal-docs and the codebase."
## When to use
- "How do I reset my dogfood profile?"
- "What's the deal with the OpenClaw VM startup?"
- "Where do we configure release signing?"
- Any question whose answer lives in setup runbooks, feature notes, architecture docs, or the code that produced them.
## Hard rules — never do these
- NEVER execute a state-mutating command without per-command `y` confirmation from the user.
- NEVER edit BrowserOS code in response to an ask-internal question. The skill answers; it does not modify code. Use `/document-internal` for writes.
- NEVER guess. If grep finds nothing useful in docs or code, say so plainly.
- NEVER run this skill if `.internal-docs/` is missing. Stop with the init command.
- NEVER cite a file or line number you have not actually read.
## Voice rules
Apply the same voice rules as `document-internal` to the synthesized answer:
- Lead with the point.
- Concrete nouns. Name files, functions, commands.
- Short sentences. Active voice. No em dashes.
- Banned words: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, leverage, utilize.
- No filler intros.
## Workflow
### Step 0: Pre-flight
```bash
if git submodule status .internal-docs 2>/dev/null | grep -q '^-'; then
echo "internal-docs submodule not initialized. Run: git submodule update --init .internal-docs"
exit 0
fi
[ -d .internal-docs ] && [ -n "$(ls -A .internal-docs 2>/dev/null)" ] || {
echo ".internal-docs/ missing or empty. Submodule not configured?"
exit 0
}
```
### Step 1: Parse the question
Pull the keywords from the user's question. Drop stop words. Identify intent:
- **Setup-question** ("how do I", "how to", "where do I configure"): bias the search toward `setup/`.
- **Feature-question** ("what is X", "why does X work this way"): bias toward `features/` and `architecture/`.
- **Free-form** ("anything about Y"): search all categories.
### Step 2: Multi-source search
Run grep in parallel across two sources.
**Internal docs:**
```bash
grep -rni --include='*.md' '<keyword>' .internal-docs/
```
Search each keyword separately. Collect top hits by relevance (more keyword matches = higher).
**Codebase (skip vendored Chromium and `node_modules`):**
```bash
grep -rni --include='*.ts' --include='*.tsx' --include='*.js' --include='*.json' --include='*.sh' \
--exclude-dir=node_modules --exclude-dir=chromium --exclude-dir=.grove \
'<keyword>' packages/ scripts/ .config/ .github/
```
Read the top 3-5 doc hits and top 3-5 code hits. Do not skim — read the relevant section fully so citations are accurate.
### Step 3: Synthesize answer
Structure the response:
1. **Direct answer.** First sentence answers the question. No preamble.
2. **Steps if applicable.** Numbered list with exact commands.
3. **Citations.** Every factual claim references `path/to/file.md:42` or `path/to/code.ts:117`. Run the voice self-check before printing.
If multiple docs cover the topic at different layers (e.g., a setup runbook and a feature note both mention dogfood profiles), reconcile them in the answer rather than dumping both.
### Step 4: Offer execution (only if commands surfaced)
If Step 3 produced executable commands the user could run, ask:
> Run these for you? (y / n / dry-run)
- **y:** Execute one at a time. For any command that mutates state (writes a file, modifies config, kills a process, deletes anything), ask "run this? <command>" before each. Read-only commands (`ls`, `cat`, `git status`) run without per-command confirmation but still print before running.
- **n:** Skip. Done.
- **dry-run:** Print the full sequence as a `bash` block. Do not execute.
### Step 5: Doc-not-found path
If Step 2 returned nothing useful (no doc hits AND no clear code answer):
1. Tell the user: "No doc covers this. Tangentially relevant files: <list>."
2. Ask: "Draft a new doc and open a PR to internal-docs?"
3. On yes: invoke the full `/document-internal` flow (four sharp questions, draft, voice check, PR), forced to `setup/` doc type, with the code-grep findings handed in as initial context.
### Step 6: Completion status
Report one of:
- **DONE** — answer delivered, citations verified.
- **DONE_WITH_CONCERNS** — answered, but flag uncertainty (e.g., docs and code disagreed; user should reconcile).
- **BLOCKED** — submodule missing or other pre-flight failure.
- **NEEDS_CONTEXT** — question too vague to search effectively. Ask one clarifying question.
## Citation discipline
Every "X is at Y" claim in the answer must point to a file:line that the skill actually read. Do not approximate. If you didn't read it, don't cite it.
If a doc says one thing and the code says another, surface the conflict explicitly:
> The setup runbook (`setup/dogfood-profile.md:23`) says to delete `~/.cache/browseros/dogfood`, but the actual code path in `packages/cli/src/cleanup.ts:47` removes `~/.local/share/browseros/dogfood`. The doc looks stale. Recommend updating it.
## Common Mistakes
**Skimming and then citing**
- **Problem:** Citation points to a line that doesn't actually contain the claim.
- **Fix:** Read the section fully before citing. If you didn't read line 117, don't cite line 117.
**Executing without per-command confirmation for mutations**
- **Problem:** User says "y" to "run all", skill blasts through `rm -rf`-style commands.
- **Fix:** "y" means "run this sequence with per-mutation confirmations". Per-command y is required for writes.
**Searching only docs, not code**
- **Problem:** Doc says X but code does Y; answer is wrong.
- **Fix:** Always grep both sources in Step 2.
## Red Flags
**Never:**
- Cite a file:line you haven't read.
- Run mutations without per-command confirmation.
- Modify BrowserOS code from this skill (use `/document-internal` for writes).
**Always:**
- Pre-flight check before any search.
- Reconcile doc vs code conflicts in the answer, don't hide them.
- Plain "no doc covers this" when grep is empty — never invent.

View File

@@ -1,208 +0,0 @@
---
name: document-internal
description: Draft a 1-page internal doc (feature, architecture, or design) for the private browseros-ai/internal-docs repo. Use when wrapping up a feature on a branch, after the PR is open or about to be opened. Skill drafts from the diff, asks four sharp questions, enforces voice rules, and opens a PR to internal-docs.
allowed-tools: Bash, Read, Write, Edit, Grep, Glob
---
# Document Internal
Draft a 1-page internal doc (feature note, architecture note, or design spec) from the current branch's diff and open a PR to `browseros-ai/internal-docs`.
**Announce at start:** "I'm using the document-internal skill to draft a doc for internal-docs."
## When to use
After finishing implementation on a feature branch, when the work is doc-worthy (a major feature, a new subsystem, a setup runbook for something internal, or a design decision that future engineers need to know).
## Hard rules — never do these
- NEVER `git add -A` or `git add .` inside the tmp clone of internal-docs. Always specific paths.
- NEVER write outside the tmp clone (no spillover into the OSS repo's working tree).
- NEVER fabricate filler content for empty template sections. Empty stays empty.
- NEVER touch the OSS repo's `.gitmodules` or submodule pointer — the sync workflow handles that.
- NEVER run this skill if `.internal-docs/` is missing. Stop with the init command.
- NEVER push to `internal-docs/main` directly. Always a feature branch + PR.
## Voice rules — enforced by Step 4
The skill MUST follow these and refuse to draft otherwise. After generation, scan for violations and regenerate offending sentences (max 3 attempts).
- Lead with the point. First sentence answers "what is this?"
- Concrete nouns. Name files, functions, commands. Not "the system" or "the component".
- Short sentences. Average <20 words. No deeply nested clauses.
- Active voice. "X does Y" not "Y is done by X".
- No em dashes. Use commas, periods, or rephrase.
- Banned words: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, leverage, utilize.
- "110 IQ" target. Write for a smart engineer who has not seen this code yet.
- No filler intros ("This document describes..."). Start with the substance.
- Empty sections stay empty. Do not write "N/A" or fabricate content.
## Workflow
### Step 0: Pre-flight
Bail with a clear message on any failure.
```bash
# Submodule must be initialized
if git submodule status .internal-docs 2>/dev/null | grep -q '^-'; then
echo "internal-docs submodule not initialized. Run: git submodule update --init .internal-docs"
exit 0
fi
[ -d .internal-docs ] || { echo ".internal-docs/ missing. Submodule not configured?"; exit 0; }
# Must be on a feature branch
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "dev" ]; then
echo "On $BRANCH. Run from a feature branch."
exit 0
fi
# Determine base branch (default: dev for this repo, fall back to main).
# Suppress rev-parse's SHA output on stdout so it doesn't get captured into BASE.
BASE=$(git rev-parse --verify origin/dev >/dev/null 2>&1 && echo dev || echo main)
# Gather context
git log "$BASE..HEAD" --oneline
git diff "$BASE...HEAD" --stat
gh pr view --json body -q .body 2>/dev/null # may be empty if no PR yet
```
### Step 1: Identify the doc
Ask the user for three things in one prompt:
1. **Doc type:** `feature` (default for `feat/*` branches), `architecture`, or `design`
2. **Slug:** kebab-case, short (e.g., `cowork-mcp`, `auto-skill-suggest`)
3. **Owner:** GitHub handle (default = `git config user.name` or current `gh api user --jq .login`)
### Step 2: Decision brief — four sharp questions
Ask one question at a time. Each answer constrains the next. These force compression before drafting.
1. "In one sentence: what can someone now DO that they could not before?"
2. "What is the one design decision a future engineer needs to know?"
3. "Which 3-5 files are the heart of this change?" (suggest candidates from the diff)
4. "Any sharp edges or gotchas? (or 'none')"
Skip any question that is N/A for the doc type. Architecture notes don't need question 1; design specs don't need question 4.
### Step 3: Draft from the template
Read the matching template from `.internal-docs/_templates/`:
- `feature` `feature-note.md`
- `architecture` `architecture-note.md`
- `design` `design-spec.md`
If `.internal-docs/_templates/` does not exist (first run, before seeding), fall back to the seeds bundled with this skill at `.claude/skills/document-internal/seeds/_templates/`.
Generate the 1-pager from the template, the four answers, and the diff context.
### Step 4: Voice self-check
Scan the draft for violations:
- Em dash present (`—`).
- Any banned word from the list.
- Average sentence length > 20 words.
- Body line count > 60 (feature notes only — architecture/design have no cap).
If any violation found, regenerate the offending sentences in place. Max 3 attempts. If still failing after 3 attempts, stop and report which rules are violated.
If the body is over 60 lines for a feature note, ask: "This is N lines, target is 60. Trim, or promote to `architecture/` (no length cap)?"
### Step 5: Show + iterate
Print the full draft. Ask:
> Edit needed? Paste any changes, or say "looks good".
Apply user edits with the Edit tool. Re-run Step 4. Loop until the user approves.
### Step 6: Open PR to internal-docs
Use a tmp clone. Never the user's `.internal-docs` checkout — keeps the user's submodule clean.
```bash
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT # cleans up even if any step below fails
git clone -b main git@github.com:browseros-ai/internal-docs.git "$TMP"
cd "$TMP"
git checkout -b "docs/<slug>"
# Write the doc
mkdir -p "<type>" # features, architecture, designs, or setup
cat > "<type>/$(date -u +%Y-%m)-<slug>.md" <<'DOC'
<draft content>
DOC
# Update the root README index — insert one line under the matching section
# Use Edit tool to add: "- [<title>](<type>/YYYY-MM-<slug>.md) — <one-line description>"
git add "<type>/$(date -u +%Y-%m)-<slug>.md" README.md
git commit -m "docs(<type>): <slug>"
git push -u origin "docs/<slug>"
PR_URL=$(gh pr create -R browseros-ai/internal-docs --base main \
--head "docs/<slug>" \
--title "docs(<type>): <slug>" \
--body "$(cat <<'BODY'
## Summary
<one-line of what this doc covers>
## Source
- BrowserOS branch: <branch>
- Related PR: <#NNN if any>
BODY
)")
cd -
echo "PR opened: $PR_URL"
# trap above cleans up $TMP on EXIT
```
If the slug contains characters that won't shell-escape cleanly, sanitize before substitution.
### Step 7: Completion status
Report one of:
- **DONE** — file written, branch pushed, PR opened. Print PR URL.
- **DONE_WITH_CONCERNS** — same as DONE but list concerns (e.g., voice check needed multiple regens, user skipped a question).
- **BLOCKED** — submodule missing, auth fail, or template missing. State exactly what's needed.
## Doc type defaults
| Branch pattern | Default doc type | Default location |
|----------------|------------------|------------------|
| `feat/*` | feature | `features/` |
| `arch/*` or refactor branches with >10 files in `packages/` | architecture | `architecture/` |
| `rfc/*` or `design/*` | design | `designs/` |
| Otherwise | ask | ask |
## Common Mistakes
**Drafting before asking the four questions**
- **Problem:** Output is generic filler that says nothing concrete.
- **Fix:** Always ask Step 2 first, even if the diff "looks obvious".
**Touching `.internal-docs/` directly**
- **Problem:** User's submodule HEAD moves, parent repo shows dirty state.
- **Fix:** Always use the tmp clone in Step 6.
**Skipping voice check on user edits**
- **Problem:** User pastes prose with em dashes or filler; ships as-is.
- **Fix:** Re-run Step 4 after every user edit.
## Red Flags
**Never:**
- Push to `internal-docs/main`. Always branch + PR.
- Modify the OSS repo's `.gitmodules` or submodule pointer.
- Fabricate content for empty template sections.
**Always:**
- Pre-flight check before doing any work.
- One-pager rule for feature notes (60-line body cap).
- File:line citations when referencing code.

View File

@@ -1,51 +0,0 @@
# BrowserOS Internal Docs
Private team docs for `browseros-ai`. Mounted as a submodule into the public OSS repo at `.internal-docs/`.
If you are reading this from a public clone of BrowserOS without team access — this submodule is for the BrowserOS internal team. Nothing here is required to build or use BrowserOS.
## How to find what you need
- Setup task ("how do I X locally") → look in [`setup/`](setup/)
- Recently shipped feature → look in [`features/`](features/)
- Cross-cutting subsystem → look in [`architecture/`](architecture/)
- A design decision or RFC → look in [`designs/`](designs/)
Or run `/ask-internal "<your question>"` from any BrowserOS checkout. The skill greps these docs and the codebase, then synthesizes an answer with citations.
## How to add a doc
Run `/document-internal` from a feature branch. The skill drafts a 1-pager from your branch's diff, asks four sharp questions, enforces voice rules, and opens a PR back to this repo.
## Index
### Setup
<!-- one line per setup runbook: -->
<!-- - [Dev environment](setup/dev-environment.md): first-time machine setup -->
### Features
<!-- one line per shipped feature, newest first: -->
<!-- - [Cowork MCP](features/2026-04-cowork-mcp.md): bring outside MCPs into the BrowserOS agent -->
### Architecture
<!-- one line per cross-cutting subsystem: -->
<!-- - [Chrome fork overview](architecture/chrome-fork-overview.md): what we patched and why -->
### Designs
<!-- one line per design spec, newest first: -->
<!-- - [Internal docs submodule](designs/2026-04-30-internal-docs-submodule.md): this system -->
## Templates
When `/document-internal` runs, it reads from [`_templates/`](_templates/). Edit the templates here when the team's preferred shape changes.
## Voice
Docs in this repo follow these rules. The `/document-internal` skill enforces them; humans editing by hand should match.
- Lead with the point.
- Concrete nouns. Name files, functions, commands.
- Short sentences, active voice, no em dashes.
- No filler words: delve, crucial, robust, comprehensive, nuanced, multifaceted, leverage, utilize, etc.
- Empty sections stay empty. Do not write "N/A" or fake content.
- Feature notes target one screen, body 60 lines max.

View File

@@ -1,31 +0,0 @@
---
title: <subsystem name>
owner: <github handle>
status: current | deprecated
date: YYYY-MM-DD
related-features: [feature-slug-1, feature-slug-2]
---
# <subsystem name>
## What this subsystem does
<1-2 paragraphs. The top-level responsibility. Boundaries.>
## Architecture
<Diagram (ASCII or mermaid) plus prose. Components and how they talk.>
## Constraints
<Hard rules the design enforces. "X must never call Y" type statements.>
## Decisions made
<Numbered list of non-obvious decisions and the reason for each.>
## Key files
- `path/to/file.ts` — role
- `path/to/dir/` — what lives here
## How to evolve this
<Where to add things. Which tests to expect to update. What NOT to touch.>
## Open questions
<What is still being figured out. Empty if none.>

View File

@@ -1,34 +0,0 @@
---
title: <design name>
owner: <github handle>
status: proposed | accepted | rejected | superseded
date: YYYY-MM-DD
supersedes: <design-slug or none>
---
# <design name>
## Goal
<2-4 sentences. What this design is trying to accomplish.>
## Context
<1-2 paragraphs. The current state, what is failing, why this needs to change.>
## Selected Approach
<The chosen design at a high level. Architecture, components, data flow.>
## Alternatives Considered
### 1. <name>
<2-3 sentences on what this would look like, then pro/con and why rejected (or deferred).>
### 2. <name>
<Same shape.>
## Out of Scope
<What this design does NOT cover. Defer references.>
## Rollout
<Numbered steps from "nothing exists" to "fully shipped".>
## Open Questions
<Resolved during design? Empty. Unresolved? List with owner.>

View File

@@ -1,29 +0,0 @@
---
title: <feature name>
owner: <github handle>
status: shipped | wip | deprecated
date: YYYY-MM-DD
prs: ["#NNN"]
tags: [agent, browser, mcp]
---
# <feature name>
## What it does
<2-3 sentences. What can someone now do that they could not before. Lead with user-facing impact, not implementation.>
## Why we built it
<1-2 sentences. Motivation. What pain it removed or what unlocked.>
## How it works
<3-6 sentences. The flow at a high level. Name the key files.>
## Key files
- `path/to/file.ts` — what it does
- `path/to/other.ts` — what it does
## How to run / test it locally
<bullet list of commands. Empty section if N/A do not fake.>
## Gotchas
<known sharp edges. "If you see X, that's why." Empty if N/A.>

157
.github/workflows/build-agent.yml vendored Normal file
View File

@@ -0,0 +1,157 @@
name: build-agent
on:
workflow_dispatch:
inputs:
agent:
description: "Agent name from bundle.json"
required: true
type: string
default: openclaw
publish:
description: "Upload to R2 and merge manifest slice"
required: false
default: false
type: boolean
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- ".github/workflows/build-agent.yml"
env:
BUN_VERSION: "1.3.6"
PKG_DIR: packages/browseros-agent/packages/build-tools
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools typecheck
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools test
build:
needs: check
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Build tarball
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
OUT: ${{ github.workspace }}/dist/images
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
- uses: actions/upload-artifact@v4
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images/
retention-days: 7
smoke:
needs: build
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v4
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-arm64
path: dist/images
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Smoke test tarball
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
run: |
set -euo pipefail
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-arm64.tar.gz" -print -quit)"
if [ -z "$tarball" ]; then
echo "missing arm64 tarball artifact for ${AGENT}" >&2
exit 1
fi
bun run smoke:tarball -- --agent "$AGENT" --arch arm64 --tarball "$tarball"
publish:
needs: [build, smoke]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
runs-on: ubuntu-24.04
environment: release
concurrency:
group: r2-manifest-publish
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v4
with:
pattern: tarball-*
path: dist/images
merge-multiple: true
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Upload tarballs to R2
working-directory: ${{ env.PKG_DIR }}
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
base="$(basename "$file")"
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
done
- name: Merge agent slice into manifest
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
mkdir -p dist/images
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
bun run emit-manifest -- \
--slice "agents:${AGENT}" \
--dist-dir dist \
--merge-from dist/baseline-manifest.json \
--out dist/manifest.json
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"

View File

@@ -14,7 +14,7 @@ on:
config:
description: 'Eval config file (relative to apps/eval/)'
required: false
default: 'configs/legacy/browseros-agent-weekly.json'
default: 'configs/browseros-agent-weekly.json'
permissions:
contents: read
@@ -30,9 +30,8 @@ jobs:
- name: Install BrowserOS
run: |
# Rolling stable channel — see https://cdn.browseros.com/download/BrowserOS.deb
wget -q -O BrowserOS.deb https://cdn.browseros.com/download/BrowserOS.deb
sudo dpkg -i BrowserOS.deb
wget -q https://github.com/browseros-ai/BrowserOS/releases/download/v0.44.0.1/BrowserOS_v0.44.0.1_amd64.deb
sudo dpkg -i BrowserOS_v0.44.0.1_amd64.deb
browseros --version || echo "BrowserOS installed at $(which browseros)"
- name: Install Bun
@@ -42,28 +41,7 @@ jobs:
- name: Install dependencies
working-directory: packages/browseros-agent
run: bun install --ignore-scripts
- name: Install Claude Code CLI
working-directory: packages/browseros-agent/apps/eval
env:
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/legacy/browseros-agent-weekly.json' }}
run: |
if bun -e "const config = await Bun.file(process.env.EVAL_CONFIG).json(); process.exit(config.agent?.type === 'claude-code' ? 0 : 1)"; then
npm install -g @anthropic-ai/claude-code@2.1.119
echo "Claude Code CLI installed at $(command -v claude)"
claude --version
else
echo "Eval config does not use Claude Code; skipping Claude Code CLI install"
fi
- name: Install Python eval dependencies
# agisdk pinned so silent upstream releases can't shift task definitions
# or grader behavior. Bump intentionally with a documented re-baseline.
run: pip install agisdk==0.3.5 requests
- name: Clone WebArena-Infinity
run: git clone --depth 1 https://github.com/web-arena-x/webarena-infinity.git /tmp/webarena-infinity
run: bun install --ignore-scripts && bun run build:agent-sdk
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
@@ -75,44 +53,19 @@ jobs:
curl -sL -o /tmp/nopecha.zip https://github.com/NopeCHALLC/nopecha-extension/releases/latest/download/chromium_automation.zip
unzip -qo /tmp/nopecha.zip -d extensions/nopecha
- name: Run eval and publish to R2
- name: Run eval
working-directory: packages/browseros-agent/apps/eval
env:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION || 'us-west-2' }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
BROWSEROS_BINARY: /usr/bin/browseros
WEBARENA_INFINITY_DIR: /tmp/webarena-infinity
# OpenClaw container runtime is macOS-only; opt the Linux runner
# into the no-op stub so the server can boot and the eval can run.
BROWSEROS_SKIP_OPENCLAW: '1'
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/legacy/browseros-agent-weekly.json' }}
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
echo "Running eval with config: $EVAL_CONFIG"
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts suite --config "$EVAL_CONFIG"
# Capture the run directory so report.html can be generated before the R2 publish step.
SUMMARY_PATH="$(find results -name summary.json -type f -print | sort | tail -n 1)"
if [ -z "$SUMMARY_PATH" ]; then
echo "No eval run summary found"
exit 1
fi
RUN_DIR="$(dirname "$SUMMARY_PATH")"
echo "EVAL_RUN_DIR=$RUN_DIR" >> "$GITHUB_ENV"
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts -c "$EVAL_CONFIG"
- name: Generate run analysis report
if: success()
working-directory: packages/browseros-agent/apps/eval
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
run: |
echo "Generating run report for $EVAL_RUN_DIR"
bun scripts/generate-report.ts --input "$EVAL_RUN_DIR" --output "$EVAL_RUN_DIR/report.html"
- name: Publish eval run to R2
- name: Upload runs to R2
if: success()
working-directory: packages/browseros-agent/apps/eval
env:
@@ -121,12 +74,13 @@ jobs:
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
run: bun run src/index.ts publish --run "$EVAL_RUN_DIR" --target r2
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
CONFIG_NAME=$(basename "$EVAL_CONFIG" .json)
bun scripts/upload-run.ts "results/$CONFIG_NAME"
- name: Generate trend report
if: success()
timeout-minutes: 5
continue-on-error: true
working-directory: packages/browseros-agent
env:
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
@@ -136,17 +90,9 @@ jobs:
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
- name: Upload trend report as artifact
- name: Upload report as artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: eval-report-${{ github.run_id }}
path: /tmp/eval-report.html
- name: Upload server stderr logs (for post-mortem on startup failures)
if: always()
uses: actions/upload-artifact@v4
with:
name: browseros-server-logs-${{ github.run_id }}
path: /tmp/browseros-server-logs/
if-no-files-found: ignore

View File

@@ -1,11 +1,168 @@
name: Release BrowserOS Agent SDK (disabled)
name: Release BrowserOS Agent SDK
on:
workflow_dispatch:
concurrency:
group: release-agent-sdk
cancel-in-progress: false
jobs:
disabled:
if: ${{ false }}
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
defaults:
run:
working-directory: packages/browseros-agent/packages/agent-sdk
steps:
- run: echo "Agent SDK publishing is disabled."
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v6
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun ci
working-directory: packages/browseros-agent
- name: Build
run: bun run build
- name: Test
run: bun test
- name: Get version
id: version
run: |
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SDK_PATH="packages/browseros-agent/packages/agent-sdk"
CURRENT_TAG="agent-sdk-v${{ steps.version.outputs.version }}"
# Find the previous tag, excluding the current version's tag
# (which may already exist from a prior failed run)
PREV_TAG=$(git tag -l "agent-sdk-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release" > /tmp/release-notes.md
else
# Get commits scoped to the SDK directory
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$SDK_PATH")
if [ -z "$COMMITS" ]; then
echo "No notable changes." > /tmp/release-notes.md
else
echo "## What's Changed" > /tmp/release-notes.md
echo "" >> /tmp/release-notes.md
# For each commit, find the associated PR and format with author
CONTRIBUTORS=""
while IFS= read -r SHA; do
# Get commit subject and author
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
AUTHOR=$(git log -1 --pretty=format:"%an" "$SHA")
GITHUB_USER=$(gh api "/repos/${{ github.repository }}/commits/${SHA}" --jq '.author.login // empty' 2>/dev/null)
# Find associated PR number
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
# Format line: skip PR number if already in the commit subject
# (squash merges include "(#123)" in the subject automatically)
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
else
echo "- ${SUBJECT}" >> /tmp/release-notes.md
fi
done <<< "$COMMITS"
fi
fi
working-directory: ${{ github.workspace }}
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="agent-sdk-v${{ steps.version.outputs.version }}"
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
TITLE="BrowserOS Agent SDK - v${{ steps.version.outputs.version }}"
# Create or reuse tag (idempotent for re-runs)
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping tag creation"
else
git tag "$TAG" "$RELEASE_SHA"
fi
# Push tag (skip if already on remote)
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag $TAG already on remote, skipping push"
else
git push origin "$TAG"
fi
# Create or update release
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists, updating"
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
else
gh release create "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
fi
working-directory: ${{ github.workspace }}
- name: Update CHANGELOG.md via PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
DATE=$(date -u +"%Y-%m-%d")
BRANCH="docs/agent-sdk-changelog-v${VERSION}"
CHANGELOG="packages/browseros-agent/packages/agent-sdk/CHANGELOG.md"
# Return to main before branching
git checkout main
# Use head/tail to safely insert without sed quoting issues
{
head -n 1 "$CHANGELOG"
echo ""
echo "## v${VERSION} (${DATE})"
echo ""
cat /tmp/release-notes.md
echo ""
tail -n +2 "$CHANGELOG"
} > /tmp/new-changelog.md
mv /tmp/new-changelog.md "$CHANGELOG"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add "$CHANGELOG"
git commit -m "docs: update agent-sdk changelog for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--title "docs: update agent-sdk changelog for v${VERSION}" \
--body "Auto-generated changelog update for BrowserOS Agent SDK v${VERSION}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --auto || true
working-directory: ${{ github.workspace }}

View File

@@ -1,62 +0,0 @@
name: Sync internal-docs submodule
on:
schedule:
- cron: '0 */4 * * *'
workflow_dispatch:
jobs:
sync:
name: Bump internal-docs submodule pointer on dev
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Rewrite SSH submodule URL to HTTPS-with-token
env:
TOKEN: ${{ secrets.INTERNAL_DOCS_SYNC_TOKEN }}
run: |
git config --global "url.https://x-access-token:${TOKEN}@github.com/.insteadOf" "git@github.com:"
- uses: actions/checkout@v4
with:
token: ${{ secrets.INTERNAL_DOCS_SYNC_TOKEN }}
submodules: true
ref: dev
fetch-depth: 50
- name: Open auto-merge PR if internal-docs has new commits
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
# Skip if submodule not yet configured (handoff window before someone adds it)
if ! git config --file .gitmodules --get-regexp '^submodule\..internal-docs\.path$' >/dev/null 2>&1; then
echo "internal-docs submodule not yet configured in .gitmodules. Skipping."
exit 0
fi
git submodule update --remote --merge .internal-docs
if git diff --quiet .internal-docs; then
echo "No internal-docs changes to sync."
exit 0
fi
BRANCH="bot/sync-internal-docs-$(date -u +%Y%m%d-%H%M%S)"
git config user.name "browseros-bot"
git config user.email "bot@browseros.ai"
git checkout -b "$BRANCH"
git add .internal-docs
git commit -m "chore: sync internal-docs submodule"
git push -u origin "$BRANCH"
PR_URL=$(gh pr create \
--base dev \
--head "$BRANCH" \
--title "chore: sync internal-docs submodule" \
--body "Automated bump of the \`.internal-docs\` submodule pointer. Auto-merging.")
gh pr merge "$PR_URL" --auto --squash --delete-branch

View File

@@ -54,24 +54,28 @@ jobs:
command: (cd apps/server && bun run test:integration)
junit_path: test-results/server-integration.xml
needs_browser: true
- suite: server-lib
command: (cd apps/server && bun run test:lib)
junit_path: test-results/server-lib.xml
needs_browser: false
- suite: server-sdk
command: (cd apps/server && bun run test:sdk)
junit_path: test-results/server-sdk.xml
needs_browser: true
- suite: server-root
command: (cd apps/server && bun run test:root)
junit_path: test-results/server-root.xml
needs_browser: false
- suite: agent
command: (cd apps/agent && bun run test)
command: bun run test:agent
junit_path: test-results/agent.xml
needs_browser: false
- suite: eval
command: (cd apps/eval && bun run test)
command: bun run test:eval
junit_path: test-results/eval.xml
needs_browser: false
- suite: agent-sdk
command: bun run test:agent-sdk
junit_path: test-results/agent-sdk.xml
needs_browser: false
- suite: build
command: bun run ./scripts/run-bun-test.ts ./scripts/build
command: bun run test:build
junit_path: test-results/build.xml
needs_browser: false

4
.gitmodules vendored
View File

@@ -1,4 +0,0 @@
[submodule ".internal-docs"]
path = .internal-docs
url = git@github.com:browseros-ai/internal-docs.git
branch = main

Submodule .internal-docs deleted from 590799ae1c

View File

@@ -188,21 +188,6 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) — BrowserOS uses some patches for enhanced privacy. Thanks to everyone behind this project!
- [The Chromium Project](https://www.chromium.org/) — at the core of BrowserOS, making it possible to exist in the first place.
## Citation
If you use BrowserOS in your research or project, please cite:
```bibtex
@software{browseros2025,
author = {Nithin Sonti and Nikhil Sonti and {BrowserOS-team}},
title = {BrowserOS: The open-source Agentic browser},
url = {https://github.com/browseros-ai/BrowserOS},
year = {2025},
publisher = {GitHub},
license = {AGPL-3.0},
}
```
## License
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).

View File

@@ -180,7 +180,6 @@ packages/*/dist
browseros-server
browseros-server.exe
browseros-server-*
tools/dogfood/browseros-dogfood
tools/dev/browseros-dev
log.txt

View File

@@ -1,6 +1,6 @@
# BrowserOS Agent
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, and evaluation framework.
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, evaluation framework, and SDK.
## Monorepo Structure
@@ -12,6 +12,7 @@ apps/
eval/ # Evaluation framework for benchmarking agents
packages/
agent-sdk/ # Node.js SDK (@browseros-ai/agent-sdk)
cdp-protocol/ # Type-safe Chrome DevTools Protocol bindings
shared/ # Shared constants (ports, timeouts, limits)
```
@@ -22,6 +23,7 @@ packages/
| `apps/agent` | Agent UI — Chrome extension for the chat interface |
| `apps/cli` | Go CLI — control BrowserOS from the terminal or AI coding agents |
| `apps/eval` | Benchmark framework — WebVoyager, Mind2Web evaluation |
| `packages/agent-sdk` | Node.js SDK for browser automation with natural language |
| `packages/cdp-protocol` | Auto-generated CDP type bindings used by the server |
| `packages/shared` | Shared constants used across packages |
@@ -73,21 +75,26 @@ packages/
### Setup
Requires [process-compose](https://github.com/F1bonacc1/process-compose):
```bash
brew install process-compose
```
```bash
# Copy environment files for each package
cp apps/server/.env.example apps/server/.env.development
cp apps/agent/.env.example apps/agent/.env.development
cp apps/server/.env.production.example apps/server/.env.production
# Install deps and generate agent code
bun run dev:setup
# Start the full dev environment
bun run dev:watch
process-compose up
```
`dev:watch` starts the server immediately. OpenClaw VM/image prewarm runs from
the server startup path and pulls the configured GHCR image on demand.
The `process-compose up` command runs the following in order:
1. `bun install` — installs dependencies
2. `bun --cwd apps/agent codegen` — generates agent code
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
### Environment Variables
@@ -157,14 +164,9 @@ bun run build:server # Build production server resource artifacts and u
bun run build:agent # Build agent extension
# Test
bun run test # Run all tests
bun run test:all # Run all tests
bun run test:main # Run key server tools and integration tests
# App-specific test groups (from packages/browseros-agent)
cd apps/server && bun run test:tools
cd apps/server && bun run test:cdp
cd apps/server && bun run test:integration
bun run test # Run standard tests
bun run test:cdp # Run CDP-based tests
bun run test:integration # Run integration tests
# Quality
bun run lint # Check with Biome

View File

@@ -1,40 +0,0 @@
import { cloudSyncSignInLinks } from '@/lib/constants/productUrls'
import { cn } from '@/lib/utils'
interface CloudSyncDisclosureProps {
className?: string
}
export function CloudSyncDisclosure({ className }: CloudSyncDisclosureProps) {
const [termsLink, privacyLink, cloudSyncLink] = cloudSyncSignInLinks
return (
<p
className={cn(
'text-center text-muted-foreground text-xs leading-relaxed',
className,
)}
>
By signing in, you agree to the <DisclosureLink link={termsLink} /> and
acknowledge the <DisclosureLink link={privacyLink} />.{' '}
<DisclosureLink link={cloudSyncLink} />.
</p>
)
}
function DisclosureLink({
link,
}: {
link: (typeof cloudSyncSignInLinks)[number]
}) {
return (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-2 hover:text-foreground"
>
{link.label}
</a>
)
}

View File

@@ -1,50 +0,0 @@
import type { Provider } from './chatComponentTypes'
export interface ProviderOptionGroup {
key: 'llm' | 'acp'
label: string
options: Provider[]
}
export function groupProviderOptions(
providers: Provider[],
): ProviderOptionGroup[] {
const llm = providers.filter((provider) => provider.kind !== 'acp')
const acp = providers.filter((provider) => provider.kind === 'acp')
return [
...(llm.length
? [{ key: 'llm' as const, label: 'AI Providers', options: llm }]
: []),
...(acp.length
? [{ key: 'acp' as const, label: 'Agents', options: acp }]
: []),
]
}
export function getProviderSearchValue(
provider: Provider,
groupLabel: string,
): string {
return [
provider.id,
provider.name,
provider.type,
groupLabel,
provider.adapterName,
provider.modelLabel,
]
.filter(Boolean)
.join(' ')
}
export function getProviderSubtitle(provider: Provider): string | undefined {
if (provider.kind !== 'acp') return undefined
return [
provider.adapterName,
provider.modelLabel,
provider.modelControl === 'best-effort' ? 'best effort' : undefined,
]
.filter(Boolean)
.join(' · ')
}

View File

@@ -1,72 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
getProviderSearchValue,
getProviderSubtitle,
groupProviderOptions,
} from './ChatProviderSelector.helpers'
import type { Provider } from './chatComponentTypes'
const options: Provider[] = [
{ kind: 'llm', id: 'browseros', name: 'BrowserOS', type: 'browseros' },
{
kind: 'llm',
id: 'anthropic-sonnet',
name: 'Anthropic Sonnet',
type: 'anthropic',
},
{
kind: 'acp',
id: 'agent-claude-review',
name: 'Review Bot',
type: 'acp',
adapterName: 'Claude Code',
modelLabel: 'Haiku',
modelControl: 'best-effort',
},
{
kind: 'acp',
id: 'agent-codex-browser',
name: 'Browser Driver',
type: 'acp',
adapterName: 'Codex',
modelLabel: 'GPT-5.5',
modelControl: 'runtime-supported',
},
]
describe('groupProviderOptions', () => {
it('groups normal providers separately from created agents', () => {
expect(groupProviderOptions(options)).toEqual([
{
key: 'llm',
label: 'AI Providers',
options: [options[0], options[1]],
},
{
key: 'acp',
label: 'Agents',
options: [options[2], options[3]],
},
])
})
})
describe('getProviderSearchValue', () => {
it('matches created-agent group labels and item labels', () => {
expect(getProviderSearchValue(options[2], 'Agents')).toContain('Agents')
expect(getProviderSearchValue(options[2], 'Agents')).toContain('Review Bot')
expect(getProviderSearchValue(options[2], 'Agents')).toContain(
'Claude Code',
)
})
})
describe('getProviderSubtitle', () => {
it('describes created-agent runtime context without model-target copy', () => {
expect(getProviderSubtitle(options[2])).toBe(
'Claude Code · Haiku · best effort',
)
expect(getProviderSubtitle(options[3])).toBe('Codex · GPT-5.5')
expect(getProviderSubtitle(options[0])).toBeUndefined()
})
})

View File

@@ -1,4 +1,4 @@
import { Bot, Check, Plus } from 'lucide-react'
import { Check, Plus } from 'lucide-react'
import type { FC, PropsWithChildren } from 'react'
import { useState } from 'react'
import {
@@ -17,11 +17,6 @@ import {
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
import { cn } from '@/lib/utils'
import {
getProviderSearchValue,
getProviderSubtitle,
groupProviderOptions,
} from './ChatProviderSelector.helpers'
import type { Provider } from './chatComponentTypes'
interface ChatProviderSelectorProps {
@@ -34,58 +29,54 @@ export const ChatProviderSelector: FC<
PropsWithChildren<ChatProviderSelectorProps>
> = ({ children, providers, selectedProvider, onSelectProvider }) => {
const [open, setOpen] = useState(false)
const groups = groupProviderOptions(providers)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-64 p-0">
<PopoverContent side="bottom" align="start" className="w-48 p-0">
<Command>
<CommandInput
placeholder="Search providers or agents..."
className="h-9"
/>
<CommandInput placeholder="Search providers..." className="h-9" />
<CommandList>
<div className="my-2 px-2 font-semibold text-muted-foreground text-xs uppercase tracking-wide">
AI Provider
</div>
<CommandEmpty>No provider found</CommandEmpty>
{groups.map((group) => (
<CommandGroup key={group.key} heading={group.label}>
{group.options.map((provider) => {
const isSelected = selectedProvider.id === provider.id
const subtitle = getProviderSubtitle(provider)
return (
<CommandItem
key={provider.id}
value={getProviderSearchValue(provider, group.label)}
onSelect={() => {
onSelectProvider(provider)
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-3 rounded-md p-2 transition-colors',
isSelected && 'bg-[var(--accent-orange)]/10',
<CommandGroup>
{providers.map((provider) => {
const isSelected = selectedProvider.id === provider.id
return (
<CommandItem
key={provider.id}
value={`${provider.id} ${provider.name}`}
onSelect={() => {
onSelectProvider(provider)
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-3 rounded-md p-2 transition-colors',
isSelected && 'bg-[var(--accent-orange)]/10',
)}
>
<span className="text-muted-foreground">
{provider.type === 'browseros' ? (
<BrowserOSIcon size={18} />
) : (
<ProviderIcon
type={provider.type as ProviderType}
size={18}
/>
)}
>
<span className="text-muted-foreground">
<ProviderOptionIcon provider={provider} />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm">
{provider.name}
</span>
{subtitle && (
<span className="block truncate text-muted-foreground text-xs">
{subtitle}
</span>
)}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-orange)]" />
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</span>
<span className="flex-1 text-left text-sm">
{provider.name}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-orange)]" />
)}
</CommandItem>
)
})}
</CommandGroup>
<div className="border-border border-t p-1">
<button
type="button"
@@ -105,9 +96,3 @@ export const ChatProviderSelector: FC<
</Popover>
)
}
function ProviderOptionIcon({ provider }: { provider: Provider }) {
if (provider.kind === 'acp') return <Bot size={18} />
if (provider.type === 'browseros') return <BrowserOSIcon size={18} />
return <ProviderIcon type={provider.type as ProviderType} size={18} />
}

View File

@@ -1,14 +1,7 @@
import type { ProviderType } from '@/lib/llm-providers/types'
export type ChatProviderType = ProviderType | 'acp'
export interface Provider {
id: string
name: string
type: ChatProviderType
kind: 'llm' | 'acp'
agentId?: string
adapterName?: string
modelLabel?: string
modelControl?: 'runtime-supported' | 'best-effort'
type: ProviderType
}

View File

@@ -80,11 +80,6 @@ const primarySettingsSections: NavSection[] = [
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{
name: 'Reset Data',
to: '/settings/reset-data',
icon: RotateCcw,
},
{
name: 'Tool Approvals',
to: '/settings/approvals',

View File

@@ -74,18 +74,6 @@ const primaryNavItems: NavItem[] = [
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]
function isNavItemActive(item: NavItem, pathname: string): boolean {
if (item.to === '/settings/ai') {
return pathname.startsWith('/settings')
}
if (item.to === '/agents') {
return pathname === '/agents' || pathname.startsWith('/agents/')
}
return pathname === item.to
}
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
expanded = true,
}) => {
@@ -102,7 +90,10 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
<nav className="space-y-1">
{filteredItems.map((item) => {
const Icon = item.icon
const isActive = isNavItemActive(item, location.pathname)
const isActive =
item.to === '/settings/ai'
? location.pathname.startsWith('/settings')
: location.pathname === item.to
const navItem = (
<NavLink

View File

@@ -30,7 +30,6 @@ import { MagicLinkCallback } from './login/MagicLinkCallback'
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
import { MemoryPage } from './memory/MemoryPage'
import { ProfilePage } from './profile/ProfilePage'
import { ResetDataPage } from './reset-data/ResetDataPage'
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
@@ -114,22 +113,7 @@ export const App: FC = () => {
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
{alphaEnabled ? (
<>
<Route path="agents" element={<AgentsPage />} />
<Route element={<AgentCommandLayout />}>
<Route
path="agents/:agentId"
element={
<AgentCommandConversation
variant="page"
backPath="/agents"
agentPathPrefix="/agents"
createAgentPath="/agents"
/>
}
/>
</Route>
</>
<Route path="agents" element={<AgentsPage />} />
) : null}
{alphaEnabled ? (
<Route path="admin" element={<AdminDashboardPage />} />
@@ -144,7 +128,6 @@ export const App: FC = () => {
<Route path="chat" element={<LlmHubPage />} />
<Route path="mcp" element={<MCPSettingsPage />} />
<Route path="customization" element={<CustomizationPage />} />
<Route path="reset-data" element={<ResetDataPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />

View File

@@ -0,0 +1,114 @@
import { Bot } from 'lucide-react'
import type { FC } from 'react'
import type { AgentCardData } from '@/lib/agent-conversations/types'
import { cn } from '@/lib/utils'
interface AgentCardProps {
agent: AgentCardData
onClick: () => void
active?: boolean
}
function formatTimestamp(timestamp?: number): string {
if (!timestamp) return 'No activity yet'
const diff = Date.now() - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function getStatusLabel(status: AgentCardData['status']): string {
if (status === 'working') return 'Working'
if (status === 'error') return 'Error'
return 'Ready'
}
function getStatusTone(status: AgentCardData['status']): string {
if (status === 'working') return 'bg-amber-500'
if (status === 'error') return 'bg-destructive'
return 'bg-emerald-500'
}
export const AgentCardExpanded: FC<AgentCardProps> = ({
agent,
onClick,
active,
}) => (
<button
type="button"
onClick={onClick}
className={cn(
'group flex min-h-32 w-full min-w-0 flex-col rounded-2xl border p-4 text-left shadow-sm transition-all duration-200',
active
? 'border-border/80 bg-card shadow-md ring-1 ring-[var(--accent-orange)]/20'
: 'border-border/60 bg-card/85 hover:border-border hover:bg-card hover:shadow-md',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-xl',
active
? 'bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]'
: 'bg-muted text-muted-foreground',
)}
>
<Bot className="size-5" />
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">{agent.name}</div>
<div className="truncate text-muted-foreground text-xs">
{agent.model ?? 'OpenClaw agent'}
</div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-[11px] text-muted-foreground">
<span
className={cn('size-2 rounded-full', getStatusTone(agent.status))}
/>
<span>{getStatusLabel(agent.status)}</span>
</div>
</div>
<div className="mt-4 flex-1">
<p className="line-clamp-2 text-foreground/90 text-sm">
{agent.lastMessage ??
'Start a conversation to see recent work and summaries.'}
</p>
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-muted-foreground text-xs">
<span>{formatTimestamp(agent.lastMessageTimestamp)}</span>
<span>Open conversation</span>
</div>
</button>
)
export const AgentCardCompact: FC<AgentCardProps> = ({
agent,
onClick,
active,
}) => (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors',
active
? 'border-border bg-card shadow-sm ring-1 ring-[var(--accent-orange)]/20'
: 'border-border/60 bg-card/85 text-foreground hover:border-border hover:bg-card',
)}
>
<span
className={cn(
'size-2 rounded-full',
active ? 'bg-[var(--accent-orange)]' : getStatusTone(agent.status),
)}
/>
<span className="truncate">{agent.name}</span>
</button>
)

View File

@@ -1,71 +1,70 @@
import { Plus } from 'lucide-react'
import type { FC } from 'react'
import type {
HarnessAdapterDescriptor,
HarnessAdapterHealth,
HarnessAgent,
HarnessAgentAdapter,
} from '@/entrypoints/app/agents/agent-harness-types'
import type { AgentCardData } from '@/lib/agent-conversations/types'
import { cn } from '@/lib/utils'
import { HomeAgentCard } from './HomeAgentCard'
import { AgentCardCompact, AgentCardExpanded } from './AgentCard'
interface AgentCardDockProps {
agents: HarnessAgent[]
adapters: HarnessAdapterDescriptor[]
agents: AgentCardData[]
activeAgentId?: string
onSelectAgent: (agentId: string) => void
onCreateAgent?: () => void
compact?: boolean
}
function CreateAgentButton({ onCreateAgent }: { onCreateAgent: () => void }) {
function CreateAgentButton({
compact,
onCreateAgent,
}: {
compact?: boolean
onCreateAgent: () => void
}) {
return (
<button
type="button"
onClick={onCreateAgent}
className={cn(
'flex min-h-32 shrink-0 items-center justify-center gap-2 rounded-2xl border border-dashed px-5 py-4 text-muted-foreground transition-colors',
'hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
'flex shrink-0 items-center justify-center gap-2 border border-dashed text-muted-foreground transition-colors hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
compact
? 'rounded-full px-3 py-2 text-sm'
: 'min-h-32 rounded-2xl px-5 py-4',
)}
>
<Plus className="size-5" />
<span>Create agent</span>
<Plus className={compact ? 'size-3.5' : 'size-5'} />
<span>{compact ? 'New' : 'Create agent'}</span>
</button>
)
}
/**
* 3-column grid of HomeAgentCards plus a trailing "Create agent"
* tile. The previous `compact` mode (rendered a horizontal pill rail)
* had no callers and was dropped along with the legacy AgentCard.
*/
export const AgentCardDock: FC<AgentCardDockProps> = ({
agents,
adapters,
activeAgentId,
onSelectAgent,
onCreateAgent,
compact,
}) => {
if (agents.length === 0 && !onCreateAgent) return null
const adapterHealth = new Map<HarnessAgentAdapter, HarnessAdapterHealth>()
for (const descriptor of adapters) {
if (descriptor.health) adapterHealth.set(descriptor.id, descriptor.health)
}
const Card = compact ? AgentCardCompact : AgentCardExpanded
return (
<div className="grid gap-4 md:grid-cols-3">
<div
className={cn(
compact
? 'flex items-center gap-2 overflow-x-auto pb-1'
: 'grid gap-4 md:grid-cols-3',
)}
>
{agents.map((agent) => (
<HomeAgentCard
key={agent.id}
<Card
key={agent.agentId}
agent={agent}
adapter={agent.adapter}
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
active={agent.id === activeAgentId}
onClick={() => onSelectAgent(agent.id)}
active={agent.agentId === activeAgentId}
onClick={() => onSelectAgent(agent.agentId)}
/>
))}
{onCreateAgent ? (
<CreateAgentButton onCreateAgent={onCreateAgent} />
<CreateAgentButton compact={compact} onCreateAgent={onCreateAgent} />
) : null}
</div>
)

View File

@@ -1,521 +1,192 @@
import { ArrowLeft, PanelRight } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { Bot, Home, RotateCcw } from 'lucide-react'
import { type FC, useEffect, useRef } 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 { ConversationMessage } from './ConversationMessage'
import { useAgentConversation } from './useAgentConversation'
import { useHarnessChatHistory } from './useHarnessChatHistory'
function AgentConversationController({
agentId,
initialMessage,
onInitialMessageConsumed,
agents,
agentPathPrefix,
createAgentPath,
onOpenOutputsRail,
function ConversationHeader({
agentName,
status,
onGoHome,
onReset,
}: {
agentId: string
initialMessage: string | null
onInitialMessageConsumed: () => void
agents: AgentEntry[]
agentPathPrefix: string
createAgentPath: string
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
agentName: string
status: string
onGoHome: () => void
onReset: () => void
}) {
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 className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onGoHome}
className="rounded-xl"
title="Back to home"
>
<Home className="size-4" />
</Button>
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Bot className="size-5" />
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">{agentName}</div>
<div className="truncate text-muted-foreground text-sm">
{status}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onReset}
className="rounded-xl text-muted-foreground"
>
<RotateCcw className="mr-2 size-4" />
New conversation
</Button>
</div>
</div>
)
}
interface AgentCommandConversationProps {
variant?: 'command' | 'page'
backPath?: string
agentPathPrefix?: string
createAgentPath?: string
function EmptyConversationState({ agentName }: { agentName: string }) {
return (
<div className="flex min-h-full items-center justify-center py-10">
<div className="max-w-md rounded-[1.5rem] border border-border/60 bg-card/90 px-8 py-10 text-center shadow-sm backdrop-blur">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Bot className="size-6" />
</div>
<h2 className="mt-4 font-semibold text-lg">{agentName}</h2>
<p className="mt-2 text-muted-foreground text-sm">
Send a message to start a focused conversation with this agent.
</p>
</div>
</div>
)
}
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'
function getConversationStatusCopy(
status: string | undefined,
streaming: boolean,
): string {
if (streaming) return 'Working on your request'
if (status === 'running') return 'Ready for the next task'
if (status === 'starting') return 'Connecting to OpenClaw'
if (status === 'error') return 'OpenClaw needs attention'
if (status === 'stopped') return 'OpenClaw is offline'
return 'Open agent setup to continue'
}
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
variant = 'command',
backPath = '/home',
agentPathPrefix = '/home/agents',
createAgentPath = '/agents',
}) => {
export const AgentCommandConversation: FC = () => {
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 scrollRef = useRef<HTMLDivElement>(null)
const initialQuerySent = useRef(false)
const { status, agents } = useAgentCommandData()
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 agent = agents.find((entry) => entry.agentId === resolvedAgentId)
const agentName = agent?.name || resolvedAgentId || 'Agent'
const { turns, streaming, loading, send, resetConversation } =
useAgentConversation(resolvedAgentId, agentName)
const lastTurn = turns[turns.length - 1]
const lastTurnPartCount = lastTurn?.parts.length ?? 0
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])
if (shouldRedirectHome) return
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 query = searchParams.get('q')
if (query && !initialQuerySent.current && !loading) {
initialQuerySent.current = true
setSearchParams({}, { replace: true })
void send(query)
}
}
}, [loading, searchParams, send, setSearchParams, shouldRedirectHome])
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,
useEffect(() => {
if (
shouldRedirectHome ||
(turns.length === 0 && lastTurnPartCount === 0 && !streaming)
) {
return
}
}, [adapters, harnessAgent?.adapter])
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
})
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
if (shouldRedirectHome) {
return <Navigate to="/home" replace />
}
const handleSelectHarnessAgent = (target: HarnessAgent) => {
navigate(`${agentPathPrefix}/${target.id}`)
const handleSelectAgent = (entry: AgentEntry) => {
navigate(`/home/agents/${entry.agentId}`)
}
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
if (!target) return
updateAgent.mutate({
agentId: target.id,
patch: { pinned: next },
})
}
const statusCopy = getConversationStatusCopy(status?.status, streaming)
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>
<div className="absolute inset-0 overflow-hidden">
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
<ConversationHeader
agentName={agentName}
status={statusCopy}
onGoHome={() => navigate('/home')}
onReset={resetConversation}
/>
{/* 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
<main
ref={scrollRef}
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)]',
'styled-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-[1.5rem] border border-border/50 bg-card/85 px-5 py-5 shadow-sm',
'[&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
)}
>
<AgentRail
agents={harnessAgents}
adapters={adapters}
activeAgentId={resolvedAgentId}
onSelectAgent={handleSelectHarnessAgent}
onPinToggle={(target, next) => handlePinToggle(target, next)}
{loading ? (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading conversation...
</div>
) : turns.length === 0 ? (
<EmptyConversationState agentName={agentName} />
) : (
<div className="w-full space-y-4">
{turns.map((turn, index) => (
<ConversationMessage
key={turn.id}
turn={turn}
streaming={streaming && index === turns.length - 1}
/>
))}
</div>
)}
</main>
<div className="w-full flex-shrink-0">
<ConversationInput
variant="conversation"
agents={agents}
selectedAgentId={resolvedAgentId}
onSelectAgent={handleSelectAgent}
onSend={(text) => {
void send(text)
}}
onCreateAgent={() => navigate('/agents')}
streaming={streaming}
disabled={status?.status !== 'running'}
status={status?.status}
placeholder={`Message ${agentName}...`}
/>
<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>

View File

@@ -1,214 +1,178 @@
import { Plus } from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { ArrowRight } from 'lucide-react'
import { type FC, useEffect, 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 { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
import { ScheduleResults } from '@/entrypoints/newtab/index/ScheduleResults'
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
import { TopSites } from '@/entrypoints/newtab/index/TopSites'
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'
import { ConversationInput } from './ConversationInput'
import { useAgentCardData } from './useAgentCardData'
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
function AgentCommandSetupState({
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
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
Set up OpenClaw agents to turn your new tab into an agent command
center.
</p>
<Button onClick={onOpenAgents} className="gap-2">
Open Agent Setup
<ArrowRight className="size-4" />
</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
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
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
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
OpenClaw is running, but you do not have any agents yet.
</p>
<Button variant="outline" onClick={onOpenAgents}>
Create your first agent
</Button>
</div>
<AgentCardDock
agents={agents}
adapters={adapters}
activeAgentId={activeAgentId ?? undefined}
onSelectAgent={onSelectAgent}
onCreateAgent={onOpenAgents}
/>
</section>
</CardContent>
</Card>
)
}
function OpenClawUnavailableState({
onOpenAgents,
}: {
onOpenAgents: () => void
}) {
return (
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
OpenClaw is unavailable right now. Open the Agents page to restart the
gateway or review setup.
</p>
<Button onClick={onOpenAgents} className="gap-2">
Open Agent Setup
<ArrowRight className="size-4" />
</Button>
</CardContent>
</Card>
)
}
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 { status, agents } = useAgentCommandData()
const [mounted, setMounted] = useState(false)
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const orderedAgents = useMemo(
() => orderHomeAgents(harnessAgents),
[harnessAgents],
)
const cardData = useAgentCardData(agents, status?.status)
useEffect(() => {
if (legacyAgents.length === 0) {
if (selectedAgentId) setSelectedAgentId(null)
setMounted(true)
}, [])
useEffect(() => {
if (agents.length === 0) {
if (selectedAgentId) {
setSelectedAgentId(null)
}
return
}
if (
!selectedAgentId ||
!legacyAgents.some((agent) => agent.agentId === selectedAgentId)
!agents.some((agent) => agent.agentId === selectedAgentId)
) {
setSelectedAgentId(legacyAgents[0].agentId)
setSelectedAgentId(agents[0].agentId)
}
}, [legacyAgents, selectedAgentId])
}, [agents, selectedAgentId])
const handleSend = (input: ConversationInputSendInput) => {
const handleSend = (text: string) => {
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)}`,
)
navigate(`/home/agents/${selectedAgentId}?q=${encodeURIComponent(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
const openClawStatus = status?.status
const isSetup = openClawStatus != null && openClawStatus !== 'uninitialized'
const shouldShowUnavailableState =
openClawStatus != null &&
openClawStatus !== 'running' &&
openClawStatus !== 'uninitialized' &&
cardData.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="pt-[max(25vh,16px)]">
<div className="relative w-full space-y-8 md:w-3xl">
<NewTabBranding />
<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>
<ConversationInput
variant="home"
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
onSend={handleSend}
onCreateAgent={() => navigate('/agents')}
streaming={false}
disabled={status?.status !== 'running'}
status={status?.status}
placeholder={
status?.status === 'running'
? undefined
: 'OpenClaw is not running...'
}
/>
<Separator />
{mounted ? <NewTabTip /> : null}
<RecentThreads
activeAgentId={selectedAgentId}
agents={orderedAgents}
adapters={adapters}
{isSetup ? (
shouldShowUnavailableState ? (
<OpenClawUnavailableState
onOpenAgents={() => navigate('/agents')}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
/>
</>
) : cardData.length > 0 ? (
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Agents</h2>
<p className="text-muted-foreground text-sm">
Pick up where your agents left off.
</p>
</div>
</div>
<AgentCardDock
agents={cardData}
activeAgentId={selectedAgentId ?? undefined}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
onCreateAgent={() => navigate('/agents')}
/>
</section>
) : (
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
)
) : (
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
)}
{mounted ? <TopSites /> : null}
{mounted ? <ScheduleResults /> : null}
</div>
{activeHint === 'signin' ? <SignInHint /> : null}

View File

@@ -1,65 +0,0 @@
import { type FC, useMemo } from 'react'
import type {
HarnessAdapterDescriptor,
HarnessAgent,
HarnessAgentAdapter,
} from '@/entrypoints/app/agents/agent-harness-types'
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
import { orderAgentsByPinThenRecency } from '@/entrypoints/app/agents/agents-list-order'
import { AgentRailRow } from './AgentRailRow'
interface AgentRailProps {
agents: HarnessAgent[]
adapters: HarnessAdapterDescriptor[]
activeAgentId: string
onSelectAgent: (agent: HarnessAgent) => void
onPinToggle: (agent: HarnessAgent, next: boolean) => void
}
/**
* Left-column scrollable list of agents. The "Agents" label + back
* button live in the shared top band above (so the rail header and
* the chat header sit on a single aligned strip rather than as two
* separately-sized headers per column). Sort matches `/agents`:
* pinned-first → recency, so the rail doesn't reshuffle as turns
* transition every 5 s.
*/
export const AgentRail: FC<AgentRailProps> = ({
agents,
adapters,
activeAgentId,
onSelectAgent,
onPinToggle,
}) => {
const adapterHealth = useMemo(() => {
const map = new Map<HarnessAgentAdapter, AgentAdapterHealth>()
for (const adapter of adapters) {
if (adapter.health) {
map.set(adapter.id, {
healthy: adapter.health.healthy,
reason: adapter.health.reason,
})
}
}
return map
}, [adapters])
const ordered = useMemo(() => orderAgentsByPinThenRecency(agents), [agents])
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-1.5 overflow-y-auto px-3 py-3">
{ordered.map((agent) => (
<AgentRailRow
key={agent.id}
agent={agent}
active={agent.id === activeAgentId}
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
onSelect={() => onSelectAgent(agent)}
onPinToggle={(next) => onPinToggle(agent, next)}
/>
))}
</div>
</aside>
)
}

View File

@@ -1,102 +0,0 @@
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { adapterLabel } from '@/entrypoints/app/agents/AdapterIcon'
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
import { AgentTile } from '@/entrypoints/app/agents/agent-row/AgentTile'
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
import { cn } from '@/lib/utils'
interface AgentRailRowProps {
agent: HarnessAgent
active: boolean
adapterHealth: AgentAdapterHealth | null
onSelect: () => void
onPinToggle: (next: boolean) => void
}
/**
* Compact rail row for the chat-screen sidebar. Slims `<AgentRowCard>`
* down to the essentials that fit a ~280 px rail: tile + name + status
* badge + pin star, with the adapter / model / reasoning chips on a
* second line. Token totals, sparkline, last-message preview all stay
* on the `/agents` page where rows are full-width.
*/
export const AgentRailRow: FC<AgentRailRowProps> = ({
agent,
active,
adapterHealth,
onSelect,
onPinToggle,
}) => {
const status = agent.status ?? 'unknown'
const lastUsedAt = agent.lastUsedAt ?? null
const pinned = agent.pinned ?? false
return (
<button
type="button"
onClick={onSelect}
className={cn(
'group w-full rounded-2xl border px-3 py-3 text-left transition-colors',
active
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8'
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
)}
>
<div className="flex min-w-0 items-start gap-3">
<AgentTile
adapter={agent.adapter}
status={status}
lastUsedAt={lastUsedAt}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-semibold text-[14px] leading-5">
{agent.name}
</span>
{status === 'working' && (
<Badge
variant="secondary"
className="h-5 bg-amber-50 px-1.5 text-[10px] text-amber-900 hover:bg-amber-50"
>
Working
</Badge>
)}
{status === 'asleep' && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] text-muted-foreground"
>
Asleep
</Badge>
)}
{status === 'error' && (
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">
Attention
</Badge>
)}
<div className="ml-auto">
<PinToggle pinned={pinned} onToggle={onPinToggle} />
</div>
</div>
<AgentSummaryChips
adapter={agent.adapter}
modelLabel={agent.modelId ?? null}
reasoningEffort={agent.reasoningEffort ?? null}
adapterHealth={adapterHealth}
/>
</div>
</div>
</button>
)
}
/**
* Tooltip-only label helper kept exported in case the tile row needs to
* show "Codex agent" or similar in a future state. Inlined fallback for
* the rare `unknown` adapter rendering path.
*/
export function railRowAdapterLabel(agent: HarnessAgent): string {
return adapterLabel(agent.adapter)
}

View File

@@ -27,14 +27,6 @@ interface AgentSelectorProps {
onSelectAgent: (agent: AgentEntry) => void
onCreateAgent?: () => void
status?: string
/**
* `'pill'` renders the filled-pill variant used by the calm
* composer on `/home` — bordered, slightly elevated background,
* mono agent name, used as the visual anchor on the left of the
* footer chip row. Default `'ghost'` keeps the existing flat
* shadcn ghost-button trigger used by the chat surface.
*/
triggerVariant?: 'ghost' | 'pill'
}
function getStatusDot(status?: string) {
@@ -50,49 +42,31 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
onSelectAgent,
onCreateAgent,
status,
triggerVariant = 'ghost',
}) => {
const [open, setOpen] = useState(false)
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
const triggerNode =
triggerVariant === 'pill' ? (
<button
type="button"
className={cn(
'inline-flex h-6 max-w-[180px] items-center gap-1.5 rounded-full border border-border bg-accent/40 pr-2 pl-2.5 text-[11.5px] text-foreground transition-colors',
'hover:border-border hover:bg-accent/70 data-[state=open]:border-border data-[state=open]:bg-accent/70',
)}
>
<span className={cn('size-1.5 rounded-full', getStatusDot(status))} />
<span className="truncate font-medium font-mono text-[11.5px] tracking-[-0.01em]">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
) : (
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Bot className="h-4 w-4" />
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
<span className="max-w-32 truncate">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Bot className="h-4 w-4" />
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
<span className="max-w-32 truncate">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-72 p-0">
<Command>
<CommandInput placeholder="Search agents..." className="h-9" />

View File

@@ -1,233 +0,0 @@
import { Bot, Loader2, RefreshCw } from 'lucide-react'
import { type FC, Fragment, useEffect, useRef } from 'react'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FileCardStrip } from './agent-conversation.file-card-strip'
import { ClawChatMessage } from './ClawChatMessage'
import { ConversationMessage } from './ConversationMessage'
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
interface ClawChatProps {
agentName: string
historyMessages: ClawChatMessageModel[]
turns: AgentConversationTurn[]
/**
* Persisted turns that still need to render their FileCardStrip
* because the history items they were filtered against don't
* carry produced-files data. Rendered between history and the
* live `turns` so the strip lands at the bottom of the
* corresponding assistant turn.
*/
stripOnlyTurns?: AgentConversationTurn[]
/**
* Maps each assistant history message id → the produced-files
* group that came from its turn. Built by
* `mapHistoryToProducedFilesGroups` upstream so the strip
* renders directly under the matching message instead of
* stacking at the conversation tail.
*/
filesByAssistantId?: Map<string, ProducedFilesRailGroup>
/**
* Produced-files groups that didn't match any persisted history
* pair (e.g. orphaned turns where history loaded after the
* group was attributed). Rendered at the conversation tail as
* a fallback so the user can still see them.
*/
tailStripGroups?: ReadonlyArray<ProducedFilesRailGroup>
streaming: boolean
isInitialLoading: boolean
error: Error | null
hasNextPage: boolean
isFetchingNextPage: boolean
onFetchNextPage: () => void
onRetry: () => void
/** Wired through to the inline file-card strip on each assistant turn. */
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
className?: string
}
function EmptyConversationState({ agentName }: { agentName: string }) {
return (
<div className="flex h-full items-center justify-center px-6 py-12">
<div className="max-w-md text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-3xl bg-muted text-muted-foreground">
<Bot className="size-6" />
</div>
<h2 className="mt-5 font-semibold text-xl">{agentName}</h2>
<p className="mt-2 text-muted-foreground text-sm leading-6">
Ask {agentName} to start a task.
</p>
</div>
</div>
)
}
function LoadingConversationState() {
return (
<div className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin" />
Loading conversation...
</div>
)
}
function ConversationErrorState({
message,
onRetry,
}: {
message: string
onRetry: () => void
}) {
return (
<div className="flex h-full items-center justify-center px-6 py-12">
<div className="max-w-md rounded-2xl border border-border/60 bg-card px-5 py-4 text-center shadow-sm">
<p className="text-sm">{message}</p>
<button
type="button"
onClick={onRetry}
className="mt-3 inline-flex items-center gap-2 rounded-lg border border-border/60 px-3 py-1.5 font-medium text-muted-foreground text-xs transition-colors hover:bg-accent hover:text-foreground"
>
<RefreshCw className="size-3.5" />
Retry
</button>
</div>
</div>
)
}
export const ClawChat: FC<ClawChatProps> = ({
agentName,
historyMessages,
turns,
stripOnlyTurns,
filesByAssistantId,
tailStripGroups,
streaming,
isInitialLoading,
error,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
onRetry,
onOpenOutputsRail,
className,
}) => {
const topSentinelRef = useRef<HTMLDivElement>(null)
const onFetchNextPageRef = useRef(onFetchNextPage)
onFetchNextPageRef.current = onFetchNextPage
const hasMessages = historyMessages.length > 0 || turns.length > 0
useEffect(() => {
const sentinel = topSentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries
if (!entry?.isIntersecting || !hasNextPage || isFetchingNextPage) {
return
}
onFetchNextPageRef.current()
},
{
root: null,
rootMargin: '160px 0px 0px 0px',
threshold: 0,
},
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage])
return (
<div
className={cn('flex min-h-0 flex-1 flex-col overflow-hidden', className)}
>
<Conversation
className={cn(
'bg-background',
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
)}
>
<ConversationContent className="min-h-full px-5 py-5">
{isInitialLoading ? (
<LoadingConversationState />
) : error && !hasMessages ? (
<ConversationErrorState message={error.message} onRetry={onRetry} />
) : !hasMessages ? (
<EmptyConversationState agentName={agentName} />
) : (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
<div ref={topSentinelRef} aria-hidden="true" className="h-px" />
{isFetchingNextPage ? (
<div className="flex justify-center py-2 text-muted-foreground text-xs">
<Loader2 className="mr-2 size-3.5 animate-spin" />
Loading older messages...
</div>
) : null}
{!hasNextPage && historyMessages.length > 0 ? (
<div className="py-1 text-center text-muted-foreground text-xs">
Start of conversation
</div>
) : null}
{historyMessages.map((message) => {
const matched = filesByAssistantId?.get(message.id)
return (
<Fragment key={message.id}>
<ClawChatMessage message={message} />
{matched ? (
<FileCardStrip
turnId={matched.turnId}
files={matched.files}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
) : null}
</Fragment>
)
})}
{(tailStripGroups ?? []).map((group) => (
<FileCardStrip
key={`tail-strip-${group.turnId}`}
turnId={group.turnId}
files={group.files}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
))}
{(stripOnlyTurns ?? []).map((turn) => (
<ConversationMessage
key={`strip-${turn.id}`}
turn={turn}
streaming={false}
stripOnly
onOpenOutputsRail={onOpenOutputsRail}
/>
))}
{turns.map((turn, index) => (
<ConversationMessage
key={turn.id}
turn={turn}
streaming={streaming && index === turns.length - 1}
onOpenOutputsRail={onOpenOutputsRail}
/>
))}
{error ? (
<div className="rounded-xl border border-border/60 bg-card px-4 py-3 text-muted-foreground text-sm">
{error.message}
</div>
) : null}
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</div>
)
}

View File

@@ -1,248 +0,0 @@
import { CheckCircle2, Copy, Loader2, Wrench, XCircle } from 'lucide-react'
import { type FC, useCallback, useMemo } from 'react'
import {
Message,
MessageAction,
MessageActions,
MessageAttachment,
MessageAttachments,
MessageContent,
MessageResponse,
MessageToolbar,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import {
Task,
TaskContent,
TaskItem,
TaskTrigger,
} from '@/components/ai-elements/task'
import { cn } from '@/lib/utils'
import type {
ClawChatMessagePart,
ClawChatMessage as ClawChatMessageType,
} from './claw-chat-types'
function formatCost(usd: number): string {
if (usd < 0.005) return `$${usd.toFixed(4)}`
return `$${usd.toFixed(2)}`
}
type ToolCallPart = Extract<ClawChatMessagePart, { type: 'tool-call' }>
type AttachmentPart = Extract<ClawChatMessagePart, { type: 'attachment' }>
interface RenderEntry {
kind: 'text' | 'reasoning' | 'meta' | 'task' | 'attachments'
partIndex: number
part?: ClawChatMessagePart
tools?: ToolCallPart[]
attachments?: AttachmentPart[]
}
/**
* Build a render plan that groups all tool-call parts into a single Task
* collapsible and all attachment parts into a single attachment strip at
* their respective first-appearance positions. Other parts render in place.
*/
function buildRenderEntries(parts: ClawChatMessagePart[]): RenderEntry[] {
const entries: RenderEntry[] = []
const tools: ToolCallPart[] = []
const attachments: AttachmentPart[] = []
let taskInserted = false
let attachmentsInserted = false
parts.forEach((part, partIndex) => {
if (part.type === 'tool-call') {
tools.push(part)
if (!taskInserted) {
entries.push({ kind: 'task', partIndex, tools })
taskInserted = true
}
} else if (part.type === 'attachment') {
attachments.push(part)
if (!attachmentsInserted) {
entries.push({ kind: 'attachments', partIndex, attachments })
attachmentsInserted = true
}
} else if (part.type === 'text') {
entries.push({ kind: 'text', partIndex, part })
} else if (part.type === 'reasoning') {
entries.push({ kind: 'reasoning', partIndex, part })
} else if (part.type === 'meta') {
entries.push({ kind: 'meta', partIndex, part })
}
})
return entries
}
function ToolStatusIcon({ status }: { status: ToolCallPart['status'] }) {
if (status === 'running' || status === 'pending') {
return (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
)
}
if (status === 'completed') {
return <CheckCircle2 className="size-3.5 shrink-0 text-green-500" />
}
return <XCircle className="size-3.5 shrink-0 text-destructive" />
}
interface ClawChatMessageProps {
message: ClawChatMessageType
}
export const ClawChatMessage: FC<ClawChatMessageProps> = ({ message }) => {
const messageText = message.parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('\n')
const handleCopy = useCallback(() => {
if (messageText) navigator.clipboard.writeText(messageText)
}, [messageText])
const entries = useMemo(
() => buildRenderEntries(message.parts),
[message.parts],
)
return (
<Message
from={message.role}
className="max-w-full group-[.is-user]:max-w-[80%]"
>
<MessageContent className="max-w-full overflow-hidden group-[.is-assistant]:w-full group-[.is-user]:max-w-full">
{entries.map((entry) => {
const key = `${message.id}-entry-${entry.partIndex}`
if (entry.kind === 'attachments' && entry.attachments) {
return (
<MessageAttachments key={key}>
{entry.attachments.map((attachment, idx) => (
<MessageAttachment
// biome-ignore lint/suspicious/noArrayIndexKey: attachment order is stable within a finalized message
key={`${attachment.kind}-${idx}`}
data={{
type: 'file',
url: attachment.dataUrl ?? '',
mediaType: attachment.mediaType,
filename: attachment.name,
}}
/>
))}
</MessageAttachments>
)
}
if (entry.kind === 'text' && entry.part?.type === 'text') {
return (
<MessageResponse
key={key}
// Historical messages are finalized — render immediately.
// Streamdown's default "streaming" mode uses an idle-callback
// debounce (300ms / 500ms idle) that paints empty content
// first, which made history flash blank tool collapsibles
// before text on every load.
mode="static"
parseIncompleteMarkdown={false}
className={cn(
'max-w-full overflow-hidden break-words',
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto',
'[&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
'[&_table]:w-max [&_table]:min-w-full',
)}
>
{entry.part.text}
</MessageResponse>
)
}
if (entry.kind === 'reasoning' && entry.part?.type === 'reasoning') {
return (
<Reasoning
key={key}
className="w-full"
defaultOpen={false}
duration={entry.part.duration}
>
<ReasoningTrigger />
<ReasoningContent>{entry.part.text}</ReasoningContent>
</Reasoning>
)
}
if (entry.kind === 'meta' && entry.part?.type === 'meta') {
return (
<div key={key} className="text-muted-foreground text-xs">
{entry.part.label}: {entry.part.value}
</div>
)
}
if (entry.kind === 'task' && entry.tools) {
const tools = entry.tools
const errorCount = tools.filter((t) => t.status === 'failed').length
const taskTitle = `Agent activity (${tools.length} ${tools.length === 1 ? 'action' : 'actions'}${errorCount > 0 ? `, ${errorCount} failed` : ''})`
return (
<Task key={key} defaultOpen={false}>
<TaskTrigger title={taskTitle} TriggerIcon={Wrench} />
<TaskContent>
{tools.map((tool, idx) => (
<TaskItem
// biome-ignore lint/suspicious/noArrayIndexKey: tool order is stable within a finalized historical message
key={`${tool.name}-${tool.status}-${idx}`}
className="flex items-center gap-2"
>
<ToolStatusIcon status={tool.status} />
<span className="text-foreground text-xs">
{tool.label}
</span>
{tool.subject ? (
<span className="ml-1.5 truncate text-muted-foreground/70 text-xs">
· {tool.subject}
</span>
) : null}
{tool.error ? (
<span className="ml-2 truncate text-destructive text-xs">
{tool.error}
</span>
) : null}
{tool.durationMs != null ? (
<span className="ml-auto text-muted-foreground/60 text-xs tabular-nums">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
) : null}
</TaskItem>
))}
</TaskContent>
</Task>
)
}
return null
})}
{message.role === 'assistant' && messageText ? (
<MessageToolbar>
<MessageActions>
<MessageAction tooltip="Copy" onClick={handleCopy}>
<Copy className="size-3.5" />
</MessageAction>
</MessageActions>
{message.costUsd ? (
<span className="text-[11px] text-muted-foreground/50 tabular-nums">
{formatCost(message.costUsd)}
</span>
) : null}
</MessageToolbar>
) : null}
</MessageContent>
</Message>
)
}

View File

@@ -1,187 +0,0 @@
import { ArrowLeft, Home } from 'lucide-react'
import type { FC, ReactNode } 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' | 'hermes' | 'unknown'
adapterHealth: AgentAdapterHealth | null
backLabel: string
backTarget: 'home' | 'page'
onGoHome: () => void
onPinToggle: (next: boolean) => void
/** Optional trailing slot — currently used for the Outputs rail toggle. */
headerExtra?: ReactNode
}
/**
* 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,
headerExtra,
}) => {
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 items-center gap-3">
<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>
{headerExtra ? (
<div className="flex shrink-0 items-center">{headerExtra}</div>
) : null}
</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>
)
}

View File

@@ -2,33 +2,20 @@ import {
ArrowRight,
Bot,
ChevronDown,
FileText,
Folder,
Layers,
Loader2,
Mic,
Paperclip,
Square,
X,
} from 'lucide-react'
import {
type DragEvent,
type FC,
type ReactNode,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { type FC, type ReactNode, useEffect, useState } from 'react'
import { AppSelector } from '@/components/elements/AppSelector'
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
import { type StagedAttachment, stageAttachments } from '@/lib/attachments'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
@@ -37,57 +24,36 @@ import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { AgentSelector } from './AgentSelector'
export interface ConversationInputSendInput {
text: string
attachments: StagedAttachment[]
}
interface ConversationInputProps {
agents: AgentEntry[]
selectedAgentId: string | null
onSelectAgent: (agent: AgentEntry) => void
onSend: (input: ConversationInputSendInput) => void
onSend: (text: string) => void
onCreateAgent?: () => void
streaming: boolean
disabled?: boolean
status?: string
placeholder?: string
attachmentsEnabled?: boolean
variant?: 'home' | 'conversation'
/**
* When set, a Stop button surfaces to the left of the voice mic
* while `streaming === true`. Click cancels the active turn
* server-side via the chat-cancel endpoint. Absent → no Stop
* button (legacy behaviour for the home composer).
*/
onStop?: () => void
}
function InputActionButton({
disabled,
onClick,
streaming,
hasContent,
}: {
disabled: boolean
onClick: () => void
streaming: boolean
hasContent: boolean
}) {
// Show the spinner while streaming only when there's nothing to
// send — once the user types something, the icon flips back to the
// paper-plane so it reads as "queue this message" instead of
// "still working".
const showSpinner = streaming && !hasContent
return (
<Button
onClick={onClick}
size="icon"
disabled={disabled}
title={streaming && hasContent ? 'Queue message' : undefined}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
{showSpinner ? (
{streaming ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowRight className="h-5 w-5" />
@@ -96,22 +62,6 @@ function InputActionButton({
)
}
function StopButton({ onStop }: { onStop: () => void }) {
return (
<Button
type="button"
size="icon"
variant="ghost"
onClick={onStop}
title="Stop current turn — queued messages will start next."
aria-label="Stop current turn"
className="h-8 w-8 flex-shrink-0 rounded-lg bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15 hover:text-destructive"
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
)
}
function VoiceButton({
isRecording,
isTranscribing,
@@ -164,16 +114,7 @@ function VoiceButton({
)
}
/**
* Calm-composer footer shared by both `/home` (`variant="home"`) and
* the chat surface at `/agents/:agentId` (`variant="conversation"`).
* Pill-shaped chips on an internal dashed divider, with a right-
* aligned keyboard hint. The agent selector is conditional via
* `showAgentSelector`: home shows it as a filled pill on the left,
* the chat surface hides it (the agent is locked once you're in the
* conversation).
*/
function CalmContextControls({
function ContextControls({
agents,
onCreateAgent,
onSelectAgent,
@@ -182,9 +123,6 @@ function CalmContextControls({
onToggleTab,
showAgentSelector,
status,
onAttachClick,
attachDisabled,
attachmentsEnabled,
}: {
agents: AgentEntry[]
onCreateAgent?: () => void
@@ -194,9 +132,6 @@ function CalmContextControls({
onToggleTab: (tab: chrome.tabs.Tab) => void
showAgentSelector: boolean
status?: string
onAttachClick: () => void
attachDisabled: boolean
attachmentsEnabled: boolean
}) {
const { supports } = useCapabilities()
const { selectedFolder } = useWorkspace()
@@ -210,128 +145,96 @@ function CalmContextControls({
)?.is_authenticated
})
const showApps = supports(Feature.MANAGED_MCP_SUPPORT)
const showWorkspace = supports(Feature.WORKSPACE_FOLDER_SUPPORT)
return (
<div className="mx-3 flex items-center gap-1 border-border/60 border-t border-dashed py-2">
{showAgentSelector ? (
<>
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
<div className="flex items-center gap-1">
{showAgentSelector ? (
<AgentSelector
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={onSelectAgent}
onCreateAgent={onCreateAgent}
status={status}
triggerVariant="pill"
/>
<span
aria-hidden="true"
className="mx-1 inline-block h-3.5 w-px shrink-0 bg-border"
/>
</>
) : null}
{showWorkspace ? (
<WorkspaceSelector>
<button
type="button"
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
<Folder className="size-3" />
<span>Workspace</span>
<span className="font-mono text-[10.5px] text-muted-foreground/70">
{selectedFolder?.name ?? 'none'}
</span>
</button>
</WorkspaceSelector>
) : null}
<TabPickerPopover
variant="selector"
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
>
<button
type="button"
className={cn(
'inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] transition-colors data-[state=open]:bg-accent data-[state=open]:text-foreground',
selectedTabs.length > 0
? 'bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
) : null}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
<WorkspaceSelector>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Folder className="h-4 w-4" />
<span>{selectedFolder?.name || 'Add workspace'}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</WorkspaceSelector>
) : null}
<TabPickerPopover
variant="selector"
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
>
<Layers className="size-3" />
<span>Tabs</span>
<span
<Button
className={cn(
'font-mono text-[10.5px]',
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
selectedTabs.length > 0
? 'text-white/80'
: 'text-muted-foreground/70',
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
{selectedTabs.length}
</span>
</button>
</TabPickerPopover>
<button
type="button"
onClick={onAttachClick}
disabled={attachDisabled || !attachmentsEnabled}
title="Attach files"
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<Paperclip className="size-3" />
<span>Attach</span>
</button>
{showApps ? (
<AppSelector side="bottom">
<button
type="button"
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
{connectedManagedServers.length > 0 ? (
<span className="flex items-center -space-x-1.5">
<Layers className="h-4 w-4" />
<span>Tabs</span>
</Button>
</TabPickerPopover>
</div>
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
<div className="ml-auto flex items-center gap-1.5">
<AppSelector side="bottom">
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<div className="flex items-center -space-x-1.5">
{connectedManagedServers.slice(0, 4).map((server) => (
<span
<div
key={server.id}
className="rounded-full ring-2 ring-card"
>
<McpServerIcon
serverName={server.managedServerName ?? ''}
size={12}
size={16}
/>
</span>
</div>
))}
</span>
) : (
<FileText className="size-3" />
)}
<span>Apps</span>
<ChevronDown className="size-3" />
</button>
</AppSelector>
</div>
{connectedManagedServers.length > 4 ? (
<span className="text-xs">
+{connectedManagedServers.length - 4}
</span>
) : null}
<span>Apps</span>
<ChevronDown className="h-3 w-3" />
</Button>
</AppSelector>
</div>
) : null}
<div className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground/70">
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
</kbd>
<span>to run</span>
<span className="text-muted-foreground/40">·</span>
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
</kbd>
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
</kbd>
<span>new line</span>
</div>
</div>
)
}
function HomeShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_1px_2px_rgba(15,23,42,0.04)]">
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
{children}
</div>
)
@@ -339,7 +242,7 @@ function HomeShell({ children }: { children: ReactNode }) {
function ConversationShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_10px_30px_rgba(15,23,42,0.06)]">
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
{children}
</div>
)
@@ -355,64 +258,14 @@ export const ConversationInput: FC<ConversationInputProps> = ({
disabled,
status,
placeholder,
attachmentsEnabled = true,
variant = 'conversation',
onStop,
}) => {
const [input, setInput] = useState('')
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
const [isExpandedDraft, setIsExpandedDraft] = useState(false)
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
const [attachmentError, setAttachmentError] = useState<string | null>(null)
const [isStaging, setIsStaging] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const voice = useVoiceInput()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
const isConversation = variant === 'conversation'
const stageFiles = async (files: File[]) => {
if (files.length === 0) return
if (!attachmentsEnabled) {
setAttachmentError('Attachments are not supported for this agent yet.')
return
}
setIsStaging(true)
setAttachmentError(null)
try {
const result = await stageAttachments(files, attachments.length)
if (result.staged.length > 0) {
setAttachments((prev) => [...prev, ...result.staged])
}
if (result.errors.length > 0) {
setAttachmentError(result.errors.map((e) => e.message).join(' \u2022 '))
}
} finally {
setIsStaging(false)
}
}
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id))
setAttachmentError(null)
}
useLayoutEffect(() => {
const element = textareaRef.current
if (!element) return
const maxHeight = isConversation ? 176 : 100
const collapsedHeight = isConversation ? 56 : 72
element.style.height = '0px'
const nextHeight = Math.min(element.scrollHeight, maxHeight)
element.style.height = `${nextHeight}px`
element.style.overflowY =
element.scrollHeight > maxHeight ? 'auto' : 'hidden'
setIsExpandedDraft(nextHeight > collapsedHeight)
})
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
@@ -421,12 +274,6 @@ export const ConversationInput: FC<ConversationInputProps> = ({
}
}, [voice.transcript, voice.isTranscribing, voice])
useEffect(() => {
if (attachmentsEnabled) return
setAttachments([])
setAttachmentError(null)
}, [attachmentsEnabled])
const toggleTab = (tab: chrome.tabs.Tab) => {
setSelectedTabs((prev) => {
const isSelected = prev.some((selected) => selected.id === tab.id)
@@ -437,77 +284,11 @@ export const ConversationInput: FC<ConversationInputProps> = ({
})
}
const hasContent = input.trim().length > 0 || attachments.length > 0
// Queue-aware composers (the conversation panel passes `onStop`)
// accept input while streaming — the parent decides whether the
// submission opens a new turn or enqueues onto the active one.
// Surfaces without a Stop hook (home) keep the legacy behaviour
// and block input until the current turn finishes.
const queueAware = Boolean(onStop)
const handleSend = () => {
const text = input.trim()
if (disabled || isStaging) return
if (streaming && !queueAware) return
if (!text && attachments.length === 0) return
onSend({ text, attachments })
if (!text || streaming || disabled) return
onSend(text)
setInput('')
setAttachments([])
setAttachmentError(null)
}
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = event.clipboardData?.items
if (!items) return
const files: File[] = []
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) files.push(file)
}
}
if (files.length > 0) {
event.preventDefault()
void stageFiles(files)
}
}
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
setIsDragOver(false)
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length > 0) {
void stageFiles(files)
}
}
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer?.types.includes('Files')) return
event.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
return
}
setIsDragOver(false)
}
const openFilePicker = () => {
if (!attachmentsEnabled) {
setAttachmentError('Attachments are not supported for this agent yet.')
return
}
fileInputRef.current?.click()
}
const handleFileInputChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(event.target.files ?? [])
event.target.value = ''
if (files.length > 0) void stageFiles(files)
}
const shell = variant === 'home' ? HomeShell : ConversationShell
@@ -515,192 +296,62 @@ export const ConversationInput: FC<ConversationInputProps> = ({
return (
<Shell>
<section
// Drag/drop on a region isn't a click affordance — wrap the
// composer in a labeled <section> so the a11y rule is satisfied
// without misrepresenting the surface as interactive.
aria-label="Message composer"
className={cn('relative', isDragOver && 'ring-2 ring-primary/60')}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex items-center gap-3 px-5 py-4">
<BotInputIcon variant={variant} />
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,image/gif,text/*,application/json"
className="hidden"
onChange={handleFileInputChange}
/>
{attachments.length > 0 || attachmentError ? (
<AttachmentStrip
attachments={attachments}
onRemove={removeAttachment}
error={attachmentError}
/>
) : null}
<div
className={cn(
'flex gap-3',
variant === 'home' ? 'px-4 py-3' : 'px-4 py-3',
isExpandedDraft ? 'items-end' : 'items-center',
)}
>
<BotInputIcon variant={variant} />
<div className="flex-1">
<Textarea
ref={textareaRef}
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}}
onPaste={handlePaste}
rows={1}
placeholder={
voice.isTranscribing
? 'Transcribing...'
: (placeholder ??
`Message ${selectedAgent?.name ?? 'agent'}...`)
}
disabled={disabled || voice.isTranscribing}
className={cn(
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent',
'[field-sizing:fixed]',
variant === 'home'
? 'min-h-[40px] py-2 leading-6'
: 'min-h-[40px] py-2 leading-6',
'placeholder:text-muted-foreground/80',
)}
/>
</div>
{streaming && onStop ? <StopButton onStop={onStop} /> : null}
<VoiceButton
isRecording={voice.isRecording}
isTranscribing={voice.isTranscribing}
onStart={() => {
void voice.startRecording()
}}
onStop={() => {
void voice.stopRecording()
}}
/>
<InputActionButton
disabled={
!hasContent ||
isStaging ||
!!disabled ||
voice.isRecording ||
voice.isTranscribing ||
(streaming && !queueAware)
type="text"
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleSend()
}
onClick={handleSend}
// Spinner stays the user-facing "agent is busy" hint; with the
// queue active we still spin while a turn is in flight.
streaming={streaming}
hasContent={hasContent}
/>
</div>
{voice.error ? (
<div className="px-5 pb-2 text-destructive text-xs">
{voice.error}
</div>
) : null}
<CalmContextControls
agents={agents}
onCreateAgent={onCreateAgent}
onSelectAgent={onSelectAgent}
selectedAgentId={selectedAgentId}
selectedTabs={selectedTabs}
onToggleTab={toggleTab}
showAgentSelector={variant === 'home'}
status={status}
onAttachClick={openFilePicker}
attachDisabled={attachments.length >= 10 || isStaging || !!disabled}
attachmentsEnabled={attachmentsEnabled}
}}
placeholder={
voice.isTranscribing
? 'Transcribing...'
: (placeholder ?? `Message ${selectedAgent?.name ?? 'agent'}...`)
}
disabled={disabled || voice.isTranscribing}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
/>
{isDragOver ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[inherit] bg-background/80 font-medium text-foreground text-sm backdrop-blur-sm">
Drop files to attach
</div>
) : null}
</section>
</Shell>
)
}
function AttachmentStrip({
attachments,
onRemove,
error,
}: {
attachments: StagedAttachment[]
onRemove: (id: string) => void
error: string | null
}) {
return (
<div className="border-border/40 border-b px-4 pt-3 pb-2">
{attachments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{attachments.map((attachment) => (
<AttachmentChip
key={attachment.id}
attachment={attachment}
onRemove={() => onRemove(attachment.id)}
/>
))}
</div>
) : null}
{error ? (
<div className="mt-2 text-destructive text-xs">{error}</div>
) : null}
</div>
)
}
function AttachmentChip({
attachment,
onRemove,
}: {
attachment: StagedAttachment
onRemove: () => void
}) {
if (attachment.kind === 'image' && attachment.dataUrl) {
return (
<div className="group relative size-16 overflow-hidden rounded-md border border-border/60">
<img
src={attachment.dataUrl}
alt={attachment.name}
className="size-full object-cover"
<VoiceButton
isRecording={voice.isRecording}
isTranscribing={voice.isTranscribing}
onStart={() => {
void voice.startRecording()
}}
onStop={() => {
void voice.stopRecording()
}}
/>
<InputActionButton
disabled={
!input.trim() ||
streaming ||
!!disabled ||
voice.isRecording ||
voice.isTranscribing
}
onClick={handleSend}
streaming={streaming}
/>
<button
type="button"
onClick={onRemove}
className="absolute top-1 right-1 inline-flex size-5 items-center justify-center rounded-full bg-background/80 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
aria-label={`Remove ${attachment.name}`}
>
<X className="size-3" />
</button>
</div>
)
}
return (
<div className="group flex max-w-[220px] items-center gap-2 rounded-md border border-border/60 bg-background/60 px-2 py-1.5">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs">{attachment.name}</span>
<button
type="button"
onClick={onRemove}
className="ml-1 inline-flex size-4 items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={`Remove ${attachment.name}`}
>
<X className="size-3" />
</button>
</div>
{voice.error ? (
<div className="px-5 pb-2 text-destructive text-xs">{voice.error}</div>
) : null}
<ContextControls
agents={agents}
onCreateAgent={onCreateAgent}
onSelectAgent={onSelectAgent}
selectedAgentId={selectedAgentId}
selectedTabs={selectedTabs}
onToggleTab={toggleTab}
showAgentSelector={variant === 'home'}
status={status}
/>
</Shell>
)
}
@@ -710,8 +361,8 @@ function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
className={cn(
'flex items-center justify-center text-[var(--accent-orange)]',
variant === 'home'
? 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10'
: 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10',
? 'h-10 w-10 rounded-xl bg-[var(--accent-orange)]/10'
: 'h-9 w-9 rounded-xl bg-[var(--accent-orange)]/12',
)}
>
<Bot className="h-4 w-4" />

View File

@@ -1,9 +1,7 @@
import { Bot, CheckCircle2, Loader2, Wrench, XCircle } from 'lucide-react'
import { type FC, useMemo } from 'react'
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'
import type { FC } from 'react'
import {
Message,
MessageAttachment,
MessageAttachments,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
@@ -12,228 +10,96 @@ import {
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import {
Task,
TaskContent,
TaskItem,
TaskTrigger,
} from '@/components/ai-elements/task'
import type {
AgentConversationTurn,
ToolEntry,
} from '@/lib/agent-conversations/types'
import { FileCardStrip } from './agent-conversation.file-card-strip'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
interface ConversationMessageProps {
turn: AgentConversationTurn
streaming: boolean
/**
* Forwarded to the inline file-card strip's "View" / "+N"
* button. Wired up by AgentCommandConversation so the strip can
* deep-link straight into the Outputs rail at the matching turn
* group. `null` here disables the strip's deep-link affordance
* — the cards still open the preview Sheet directly.
*/
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
/**
* Render only the trailing FileCardStrip for this turn — used
* when the turn's user / assistant text is already rendered
* elsewhere (e.g. by `ClawChatMessage` from persisted history)
* but the produced-files affordance would otherwise be lost.
*/
stripOnly?: boolean
}
interface RenderEntry {
kind: 'thinking' | 'text' | 'task'
partIndex: number
text?: string
done?: boolean
tools?: ToolEntry[]
}
/**
* Build the render plan for an assistant turn:
* - thinking and text parts render in place
* - all tool-batch parts collapse into a single Task entry at their first
* appearance position, with tools listed in arrival order
*/
function buildRenderEntries(turn: AgentConversationTurn): RenderEntry[] {
const entries: RenderEntry[] = []
const aggregatedTools: ToolEntry[] = []
let taskInserted = false
turn.parts.forEach((part, partIndex) => {
if (part.kind === 'thinking') {
entries.push({
kind: 'thinking',
partIndex,
text: part.text,
done: part.done,
})
} else if (part.kind === 'text') {
entries.push({ kind: 'text', partIndex, text: part.text })
} else if (part.kind === 'tool-batch') {
aggregatedTools.push(...part.tools)
if (!taskInserted) {
entries.push({
kind: 'task',
partIndex,
tools: aggregatedTools,
})
taskInserted = true
}
}
})
return entries
}
function ToolStatusIcon({ status }: { status: ToolEntry['status'] }) {
if (status === 'running') {
return (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
)
}
if (status === 'completed') {
return <CheckCircle2 className="size-3.5 shrink-0 text-green-500" />
}
return <XCircle className="size-3.5 shrink-0 text-destructive" />
}
export const ConversationMessage: FC<ConversationMessageProps> = ({
turn,
streaming,
onOpenOutputsRail,
stripOnly,
}) => {
const entries = useMemo(() => buildRenderEntries(turn), [turn])
}) => (
<div className="space-y-3">
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
if (stripOnly) {
if (!turn.producedFiles || turn.producedFiles.length === 0) return null
return (
<FileCardStrip
turnId={turn.turnId ?? null}
files={turn.producedFiles}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
)
}
return (
<div className="space-y-3">
<Message from="user">
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.userAttachments && turn.userAttachments.length > 0 && (
<MessageAttachments>
{turn.userAttachments.map((attachment) => (
<MessageAttachment
key={attachment.id}
data={{
type: 'file',
url: attachment.dataUrl ?? '',
mediaType: attachment.mediaType,
filename: attachment.name,
}}
/>
))}
</MessageAttachments>
)}
{turn.userText && (
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
)}
</MessageContent>
</Message>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
{entries.length > 0 && (
<Message from="assistant">
<MessageContent>
{entries.map((entry) => {
const key = `${turn.id}-entry-${entry.partIndex}`
if (entry.kind === 'thinking') {
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!entry.done}
defaultOpen={!entry.done}
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{entry.text ?? ''}</ReasoningContent>
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
}
if (entry.kind === 'text') {
case 'tool-batch':
return (
<MessageResponse key={key}>
{entry.text ?? ''}
</MessageResponse>
)
}
const tools = entry.tools ?? []
const allDone = tools.every((t) => t.status !== 'running')
const taskTitle = allDone
? `Agent activity (${tools.length} ${tools.length === 1 ? 'action' : 'actions'})`
: `Working… (${tools.length} ${tools.length === 1 ? 'action' : 'actions'})`
return (
<Task key={key} defaultOpen={!turn.done}>
<TaskTrigger title={taskTitle} TriggerIcon={Wrench} />
<TaskContent>
{tools.map((tool) => (
<TaskItem
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2"
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
<ToolStatusIcon status={tool.status} />
<span className="text-foreground text-xs">
{tool.label}
</span>
{tool.subject ? (
<span className="ml-1.5 truncate text-muted-foreground/70 text-xs">
· {tool.subject}
</span>
) : null}
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">{tool.name}</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground/60 text-xs tabular-nums">
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</TaskItem>
</div>
))}
</TaskContent>
</Task>
)
})}
</MessageContent>
</Message>
)}
</div>
)
{turn.producedFiles && turn.producedFiles.length > 0 ? (
<FileCardStrip
turnId={turn.turnId ?? null}
files={turn.producedFiles}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
) : null}
case 'text':
return <MessageResponse key={key}>{part.text}</MessageResponse>
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="size-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
default:
return null
}
})}
</MessageContent>
</Message>
)}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="size-3.5" />
</div>
)}
</div>
)
}
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
)

View File

@@ -1,243 +0,0 @@
import { Quote, TriangleAlert } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { adapterLabel } from '@/entrypoints/app/agents/AdapterIcon'
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
import type {
HarnessAdapterHealth,
HarnessAgent,
HarnessAgentAdapter,
} from '@/entrypoints/app/agents/agent-harness-types'
import { AgentTile } from '@/entrypoints/app/agents/agent-row/AgentTile'
import {
firstNonBlankLine,
truncate,
} from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
import type { AgentLiveness } from '@/entrypoints/app/agents/LivenessDot'
import { cn } from '@/lib/utils'
interface HomeAgentCardProps {
agent: HarnessAgent
adapter: HarnessAgentAdapter | 'unknown'
/** Per-adapter health snapshot, shared across cards rendering the
* same adapter. `null` when the /adapters response hasn't surfaced
* health yet (we treat that as healthy until proven otherwise). */
adapterHealth: HarnessAdapterHealth | null
/** Highlights the card with an accent ring; tells the user which
* agent the conversation input is bound to. */
active?: boolean
onClick: () => void
}
const PREVIEW_CHARS = 100
/**
* Grid-shaped card for the /home Recent agents section. Composition
* mirrors the rail's `AgentRowCard` but the layout is a vertical
* column sized for a 1/3-width tile rather than a full-width row.
*
* Reuses `<AgentTile>`, `<LivenessDot>`, `livenessDetail`,
* `formatRelativeTime`, `firstNonBlankLine`, `truncate`, and the
* inline `Unavailable` chip pattern so the visual language is
* continuous between rail and grid.
*/
export const HomeAgentCard: FC<HomeAgentCardProps> = ({
agent,
adapter,
adapterHealth,
active,
onClick,
}) => {
const status = agent.status ?? 'unknown'
const lastUsedAt = agent.lastUsedAt ?? null
const isWorking = status === 'working'
const isAsleep = status === 'asleep'
const isError = status === 'error'
const hasActiveTurn = Boolean(agent.activeTurnId)
return (
<button
type="button"
onClick={onClick}
className={cn(
'group flex min-h-32 w-full min-w-0 flex-col rounded-2xl border bg-card p-4 text-left shadow-sm transition-colors',
active && 'ring-1 ring-[var(--accent-orange)]/30',
isWorking
? 'border-[var(--accent-orange)]/40'
: isError
? 'border-destructive/30'
: 'border-border/60 hover:border-[var(--accent-orange)]/30',
)}
>
<div className="flex items-start gap-3">
<AgentTile adapter={adapter} status={status} lastUsedAt={lastUsedAt} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-semibold text-sm">
{displayName(agent)}
</span>
{isWorking && (
<Badge
variant="secondary"
className="ml-auto bg-amber-50 text-amber-900 hover:bg-amber-50"
>
Working
</Badge>
)}
</div>
<SummaryLine
adapter={adapter}
modelId={agent.modelId ?? null}
reasoningEffort={agent.reasoningEffort ?? null}
adapterHealth={adapterHealth}
/>
</div>
</div>
<LastMessage message={agent.lastUserMessage ?? null} />
<div className="mt-3 flex items-center justify-between gap-2 text-muted-foreground text-xs">
<span>{statusFootnote(status, lastUsedAt)}</span>
{hasActiveTurn ? (
<ResumeChip />
) : isAsleep ? (
<Badge variant="outline" className="text-muted-foreground">
Asleep
</Badge>
) : isError ? (
<ErrorChip lastError={agent.lastError ?? null} />
) : null}
</div>
</button>
)
}
const SummaryLine: FC<{
adapter: HarnessAgentAdapter | 'unknown'
modelId: string | null
reasoningEffort: string | null
adapterHealth: HarnessAdapterHealth | null
}> = ({ adapter, modelId, reasoningEffort, adapterHealth }) => {
const parts = [adapterLabel(adapter)]
if (modelId) parts.push(modelId)
if (reasoningEffort) parts.push(reasoningEffort)
const unhealthy = adapterHealth?.healthy === false
return (
<div
className={cn(
'mt-0.5 flex items-center gap-1.5 text-muted-foreground text-xs',
unhealthy && 'text-muted-foreground/70',
)}
>
<span className="truncate">{parts.join(' · ')}</span>
{unhealthy && (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<Badge
variant="outline"
className="h-5 cursor-default gap-1 border-amber-500/40 bg-amber-50 px-1.5 text-amber-900 hover:bg-amber-50"
>
<TriangleAlert className="size-2.5" />
<span className="font-normal">Unavailable</span>
</Badge>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-72 text-sm">
<div className="font-medium">
{adapterLabel(adapter)} CLI not available
</div>
<div className="mt-1 text-muted-foreground text-xs">
{adapterHealth?.reason ??
'Adapter binary missing on $PATH. Install it from the adapter docs to use this agent.'}
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
)
}
const LastMessage: FC<{ message: string | null }> = ({ message }) => {
if (!message) {
return (
<p className="mt-3 flex-1 text-muted-foreground/70 text-xs italic">
No messages yet start a chat
</p>
)
}
return (
<p className="mt-3 line-clamp-2 flex flex-1 items-start gap-1.5 text-foreground/85 text-sm italic leading-snug">
<Quote
className="mt-1 size-3 shrink-0 text-muted-foreground/60"
aria-hidden
/>
<span className="line-clamp-2">
{truncate(firstNonBlankLine(message), PREVIEW_CHARS)}
</span>
</p>
)
}
const ResumeChip: FC = () => (
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent-orange)] px-2.5 py-0.5 font-medium text-[11px] text-white shadow-sm">
<span className="relative flex size-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white/70 opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-white" />
</span>
Resume
</span>
)
const ErrorChip: FC<{ lastError: string | null }> = ({ lastError }) => {
if (!lastError) {
return <Badge variant="destructive">Attention</Badge>
}
return (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<Badge variant="destructive" className="cursor-default">
Attention
</Badge>
</HoverCardTrigger>
<HoverCardContent
side="left"
className="max-w-xs whitespace-pre-wrap font-mono text-xs"
>
{lastError}
</HoverCardContent>
</HoverCard>
)
}
/**
* Footer left side: relative time on every state EXCEPT working,
* which shows `now` (the dot is already pulsing — restating it as
* "Working" would duplicate the pill in the title row).
*/
function statusFootnote(
status: AgentLiveness,
lastUsedAt: number | null,
): string {
if (status === 'working') return 'now'
return formatRelativeTime(lastUsedAt)
}
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const OC_UUID_PATTERN =
/^oc-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function displayName(agent: HarnessAgent): string {
const name = agent.name?.trim()
const id = agent.id
if (!name || name === id) {
if (OC_UUID_PATTERN.test(id)) return id.slice(0, 11)
if (UUID_PATTERN.test(id)) return id.slice(0, 8)
return id
}
return name
}

View File

@@ -1,94 +0,0 @@
import { ListPlus, X } from 'lucide-react'
import type { FC } from 'react'
import {
Queue,
QueueItem,
QueueItemAction,
QueueItemActions,
QueueItemAttachment,
QueueItemContent,
QueueItemFile,
QueueItemImage,
QueueList,
QueueSection,
QueueSectionContent,
QueueSectionLabel,
QueueSectionTrigger,
} from '@/components/ai-elements/queue'
import type {
HarnessQueuedMessage,
HarnessQueuedMessageAttachment,
} from '@/entrypoints/app/agents/agent-harness-types'
import { firstNonBlankLine } from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
interface QueuePanelProps {
queue: HarnessQueuedMessage[]
onRemove: (messageId: string) => void
}
/**
* Renders the agent's pending message queue using the shared AI
* Elements `Queue` primitives. Caller is expected to gate render on
* `queue.length > 0` — when empty, this returns null so the panel
* disappears cleanly between turns.
*/
export const QueuePanel: FC<QueuePanelProps> = ({ queue, onRemove }) => {
if (queue.length === 0) return null
return (
<Queue>
<QueueSection>
<QueueSectionTrigger>
<QueueSectionLabel
count={queue.length}
label={queue.length === 1 ? 'queued message' : 'queued messages'}
icon={<ListPlus className="size-3.5" />}
/>
</QueueSectionTrigger>
<QueueSectionContent>
<QueueList>
{queue.map((entry) => (
<QueueItem key={entry.id}>
<div className="flex items-center gap-2">
<QueueItemContent>
{firstNonBlankLine(entry.message)}
</QueueItemContent>
<QueueItemActions>
<QueueItemAction
aria-label="Remove from queue"
onClick={() => onRemove(entry.id)}
>
<X className="size-3" />
</QueueItemAction>
</QueueItemActions>
</div>
{entry.attachments && entry.attachments.length > 0 ? (
<QueueItemAttachment>
{entry.attachments.map((attachment, idx) =>
renderAttachment(entry.id, attachment, idx),
)}
</QueueItemAttachment>
) : null}
</QueueItem>
))}
</QueueList>
</QueueSectionContent>
</QueueSection>
</Queue>
)
}
function renderAttachment(
messageId: string,
attachment: HarnessQueuedMessageAttachment,
idx: number,
) {
if (attachment.mediaType.startsWith('image/')) {
const src = `data:${attachment.mediaType};base64,${attachment.data}`
return <QueueItemImage key={`${messageId}-${idx}`} src={src} />
}
return (
<QueueItemFile key={`${messageId}-${idx}`}>
{attachment.mediaType}
</QueueItemFile>
)
}

View File

@@ -1,11 +1,8 @@
import type { FC } from 'react'
import { Outlet, useOutletContext } from 'react-router'
import { useHarnessAgents } from '@/entrypoints/app/agents/useAgents'
import type {
AgentEntry,
OpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
import {
type AgentEntry,
type OpenClawStatus,
useOpenClawAgents,
useOpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
@@ -19,32 +16,16 @@ interface AgentCommandContextValue {
export const AgentCommandLayout: FC = () => {
const { status, loading: statusLoading } = useOpenClawStatus(5000)
const openClawEnabled =
status?.status === 'running' && status.controlPlaneStatus === 'connected'
const { agents: openClawAgents, loading: openClawAgentsLoading } =
useOpenClawAgents(openClawEnabled)
const { agents: harnessAgents, loading: harnessAgentsLoading } =
useHarnessAgents()
const visibleOpenClawAgents = openClawEnabled ? openClawAgents : []
// Dual-created OpenClaw agents appear in both `/claw/agents` (gateway
// record) and `/agents` (harness record) under the same id. Prefer the
// harness entry so the chat panel can route through the harness path
// and the rail doesn't show duplicates.
const harnessAgentIds = new Set(harnessAgents.map((entry) => entry.agentId))
const dedupedOpenClawAgents = visibleOpenClawAgents.filter(
(entry) => !harnessAgentIds.has(entry.agentId),
const { agents, loading: agentsLoading } = useOpenClawAgents(
status?.status === 'running' && status.controlPlaneStatus === 'connected',
)
const agents = [...dedupedOpenClawAgents, ...harnessAgents]
return (
<Outlet
context={
{
agents,
agentsLoading:
harnessAgentsLoading ||
statusLoading ||
(openClawEnabled && openClawAgentsLoading),
agentsLoading,
status,
statusLoading,
} satisfies AgentCommandContextValue

View File

@@ -1,124 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* @deprecated Replaced by `FileCardStrip` in
* `agent-conversation.file-card-strip.tsx`. Kept temporarily so
* any in-flight callers don't fail to import; remove in a
* follow-up once nothing external references it.
*
* Compact "Files produced" card rendered under an assistant turn.
*/
import { FileText, Image as ImageIcon, Paperclip } from 'lucide-react'
import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
export interface ProducedFileLike {
id: string
path: string
size: number
}
interface ArtifactCardProps {
files: ReadonlyArray<ProducedFileLike>
className?: string
}
const MAX_INLINE_ROWS = 4
export const ArtifactCard: FC<ArtifactCardProps> = ({ files, className }) => {
const [openFileId, setOpenFileId] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const sortedFiles = useMemo(
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
[files],
)
if (sortedFiles.length === 0) return null
const visible = expanded ? sortedFiles : sortedFiles.slice(0, MAX_INLINE_ROWS)
const hiddenCount = sortedFiles.length - visible.length
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
return (
<div
className={cn(
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
className,
)}
>
<div className="mb-2 flex items-center gap-2 text-muted-foreground text-xs">
<Paperclip className="size-3.5" />
<span className="font-medium text-foreground">
{sortedFiles.length === 1
? '1 file produced'
: `${sortedFiles.length} files produced`}
</span>
</div>
<ul className="flex flex-col gap-1">
{visible.map((file) => (
<li key={file.id}>
<ArtifactRow file={file} onOpen={() => setOpenFileId(file.id)} />
</li>
))}
</ul>
{hiddenCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1.5 h-7 px-2 text-xs"
onClick={() => setExpanded(true)}
>
Show {hiddenCount} more
</Button>
) : null}
<FilePreviewSheet
fileId={openFile?.id ?? null}
filePath={openFile?.path ?? null}
open={Boolean(openFileId)}
onOpenChange={(next) => {
if (!next) setOpenFileId(null)
}}
/>
</div>
)
}
function ArtifactRow({
file,
onOpen,
}: {
file: ProducedFileLike
onOpen: () => void
}) {
const name = basenameOf(file.path)
const kind = inferFileKind(file.path)
const Icon = kind === 'image' ? ImageIcon : FileText
return (
<button
type="button"
onClick={onOpen}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
{formatFileSize(file.size)}
</span>
</button>
)
}

View File

@@ -1,163 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* "Files produced" strip rendered at the bottom of any assistant
* turn that produced files (openclaw only). Replaces Phase 5.3's
* row-list ArtifactCard with small horizontal cards for a lighter
* visual treatment.
*
* Click semantics:
* - Card → opens FilePreviewSheet directly (preview + download).
* - View → emits onOpenRail(turnId); the parent opens the rail
* and scrolls to the matching turn group.
* - +N → same as View (the user is asking to see what was
* overflowed).
*/
import { ChevronRight, FileText, Image as ImageIcon } from 'lucide-react'
import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
export interface CardStripFile {
id: string
path: string
size: number
}
interface FileCardStripProps {
/**
* The turn id that produced these files. Forwarded to
* `onOpenRail` so the rail can scroll/expand the matching group.
* Optional because the live `produced_files` event lands before
* the harness has stamped a server-issued turn id on the
* optimistic turn — in that brief window, View falls back to
* just opening the rail at the top.
*/
turnId?: string | null
files: ReadonlyArray<CardStripFile>
/** Caller wires this to `setOutputsRailOpen(true)` + deep-link. */
onOpenRail: (turnId?: string | null) => void
className?: string
}
const MAX_VISIBLE = 4
export const FileCardStrip: FC<FileCardStripProps> = ({
turnId,
files,
onOpenRail,
className,
}) => {
const [openFileId, setOpenFileId] = useState<string | null>(null)
const sortedFiles = useMemo(
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
[files],
)
if (sortedFiles.length === 0) return null
const visible = sortedFiles.slice(0, MAX_VISIBLE)
const hiddenCount = sortedFiles.length - visible.length
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
return (
<div
className={cn(
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
className,
)}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{sortedFiles.length === 1
? 'File produced'
: `Files produced (${sortedFiles.length})`}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-7 gap-1 px-2 text-xs"
onClick={() => onOpenRail(turnId ?? null)}
>
View
<ChevronRight className="size-3" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
{visible.map((file) => (
<FileCard
key={file.id}
file={file}
onOpen={() => setOpenFileId(file.id)}
/>
))}
{hiddenCount > 0 ? (
<button
type="button"
onClick={() => onOpenRail(turnId ?? null)}
className={cn(
'flex h-[56px] min-w-[56px] shrink-0 items-center justify-center rounded-lg border border-border/60 px-3 text-muted-foreground text-xs',
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground',
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
)}
title={`See ${hiddenCount} more in the Outputs rail`}
>
+{hiddenCount}
</button>
) : null}
</div>
<FilePreviewSheet
fileId={openFile?.id ?? null}
filePath={openFile?.path ?? null}
open={Boolean(openFileId)}
onOpenChange={(next) => {
if (!next) setOpenFileId(null)
}}
/>
</div>
)
}
function FileCard({
file,
onOpen,
}: {
file: CardStripFile
onOpen: () => void
}) {
const name = basenameOf(file.path)
const kind = inferFileKind(file.path)
const Icon = kind === 'image' ? ImageIcon : FileText
return (
<button
type="button"
onClick={onOpen}
title={file.path}
className={cn(
'flex h-[56px] w-[140px] shrink-0 flex-col justify-between rounded-lg border border-border/60 bg-background px-2.5 py-1.5 text-left',
'transition-colors hover:border-border hover:bg-accent/40',
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
)}
>
<div className="flex min-w-0 items-center gap-1.5">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium text-xs">
{name}
</span>
</div>
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatFileSize(file.size)}
</span>
</button>
)
}

View File

@@ -1,283 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Shared preview drawer used by the inline artifact card AND the
* Outputs rail. Branches on the FilePreview discriminated union and
* renders the appropriate body. Always opens via a controlled
* `open`/`onOpenChange` pair so the parent owns the selected file.
*/
import { Download, FileWarning, Loader2 } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { MessageResponse } from '@/components/ai-elements/message'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import {
basenameOf,
buildFileDownloadUrl,
extensionOf,
type FilePreview,
formatFileSize,
useFilePreview,
} from '@/lib/agent-files'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { cn } from '@/lib/utils'
interface FilePreviewSheetProps {
fileId: string | null
filePath: string | null
open: boolean
onOpenChange: (open: boolean) => void
}
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown', 'mdx'])
export const FilePreviewSheet: FC<FilePreviewSheetProps> = ({
fileId,
filePath,
open,
onOpenChange,
}) => {
const { baseUrl } = useAgentServerUrl()
const { preview, loading, error } = useFilePreview(fileId, open)
const fileName = filePath ? basenameOf(filePath) : 'File preview'
const downloadUrl = useMemo(() => {
if (!baseUrl || !fileId) return null
return buildFileDownloadUrl(baseUrl, fileId)
}, [baseUrl, fileId])
// Surface preview-load failures in a toast in addition to the
// inline error block — the inline UI lives at the bottom of the
// sheet and is easy to miss when scrolled into the body.
const lastToastedFileIdRef = useRef<string | null>(null)
useEffect(() => {
if (!open) {
lastToastedFileIdRef.current = null
return
}
if (!error || !fileId) return
if (lastToastedFileIdRef.current === fileId) return
lastToastedFileIdRef.current = fileId
toast.error('Could not load preview', { description: error.message })
}, [open, error, fileId])
const handleDownload = () => {
if (!downloadUrl) {
toast.error("Couldn't reach the agent server", {
description: 'Reconnect to BrowserOS and try again.',
})
return
}
// Manually trigger the download so any future failure (e.g. the
// server returns 404 because the file was removed) can be
// surfaced via toast — the bare <a download> path swallows
// these errors silently.
const link = document.createElement('a')
link.href = downloadUrl
link.download = fileName
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
link.remove()
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
>
<SheetHeader className="border-border/60 border-b px-5 py-4">
<SheetTitle className="truncate pr-8">{fileName}</SheetTitle>
<SheetDescription className="truncate">
{filePath ?? ''}
</SheetDescription>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="px-5 py-4">
{loading ? (
<PreviewSkeleton />
) : error ? (
<PreviewError message={error.message} />
) : preview ? (
<PreviewBody
preview={preview}
filePath={filePath}
downloadUrl={downloadUrl}
/>
) : null}
</div>
</ScrollArea>
{fileId ? (
<div className="border-border/60 border-t bg-background/90 px-5 py-3 backdrop-blur">
<Button
type="button"
size="sm"
className="w-full gap-2"
onClick={handleDownload}
>
<Download className="size-3.5" />
Download
</Button>
</div>
) : null}
</SheetContent>
</Sheet>
)
}
function PreviewSkeleton() {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2 className="size-3.5 animate-spin" />
Loading preview...
</div>
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
)
}
function PreviewError({ message }: { message: string }) {
return (
<div className="flex flex-col items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm">
<div className="flex items-center gap-2 font-medium">
<FileWarning className="size-4" />
Could not load preview
</div>
<p className="text-destructive/80 text-xs">{message}</p>
</div>
)
}
function PreviewBody({
preview,
filePath,
downloadUrl,
}: {
preview: FilePreview
filePath: string | null
downloadUrl: string | null
}) {
if (preview.kind === 'missing') {
return (
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
This file is no longer in the workspace. The agent may have moved or
deleted it after the turn finished.
</div>
)
}
if (preview.kind === 'image') {
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
<div className="overflow-hidden rounded-lg border border-border/60 bg-muted/30">
<img
src={preview.dataUrl}
alt={filePath ?? 'preview'}
className="block max-h-[60vh] w-full object-contain"
/>
</div>
</div>
)
}
if (preview.kind === 'pdf') {
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
PDF previews aren't supported inline yet. Use Download to open this
file in your default PDF viewer.
</div>
</div>
)
}
if (preview.kind === 'binary') {
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
No inline preview for this file type.
{downloadUrl ? ' Use Download to save it locally.' : null}
</div>
</div>
)
}
return <TextPreviewBody preview={preview} filePath={filePath} />
}
function TextPreviewBody({
preview,
filePath,
}: {
preview: Extract<FilePreview, { kind: 'text' }>
filePath: string | null
}) {
const ext = filePath ? extensionOf(filePath).toLowerCase() : ''
const renderAsMarkdown = MARKDOWN_EXTENSIONS.has(ext)
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
{renderAsMarkdown ? (
<div
className={cn(
'prose prose-sm dark:prose-invert max-w-none break-words rounded-lg border border-border/60 bg-muted/30 px-4 py-3',
"[&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='code-block']]:overflow-x-auto",
)}
>
<MessageResponse mode="static" parseIncompleteMarkdown={false}>
{preview.snippet}
</MessageResponse>
</div>
) : (
<pre className="overflow-x-auto rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs leading-relaxed">
<code className="font-mono text-foreground">{preview.snippet}</code>
</pre>
)}
{preview.truncated ? (
<div className="text-muted-foreground text-xs">
Showing the first part of this file. Download to see the full
contents.
</div>
) : null}
</div>
)
}
function PreviewMeta({
preview,
}: {
preview: Exclude<FilePreview, { kind: 'missing' }>
}) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
<span className="font-medium text-foreground">
{formatFileSize(preview.size)}
</span>
<span>·</span>
<span className="font-mono">{preview.mimeType || 'unknown'}</span>
</div>
)
}

View File

@@ -1,338 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Per-agent right-side "Outputs" panel. Lists every file the harness
* has attributed to this agent, grouped by the turn that produced
* them. Click a row to open the shared preview Sheet.
*
* Lifecycle:
* - Open/closed state is controlled by the parent and persisted via
* `useOutputsRailOpen(agentId)` so each agent remembers its
* preference independently.
* - Data refreshes whenever a turn finishes (the conversation hook
* fires `useInvalidateAgentOutputs` from its finally block).
* - Manual "Refresh" button is wired to `useRefreshAgentOutputs`
* for users who navigate in mid-turn.
*/
import {
ChevronDown,
ChevronRight,
FileText,
Image as ImageIcon,
Inbox,
Loader2,
PanelRightClose,
RefreshCw,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import {
basenameOf,
formatFileSize,
inferFileKind,
type ProducedFilesRailGroup,
useAgentOutputs,
useRefreshAgentOutputs,
} from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
interface OutputsRailProps {
agentId: string
onClose: () => void
/**
* When set, the rail scrolls the matching `RailTurnGroup` into
* view and force-opens its `Collapsible`. Used by the inline
* file-card strip's "View" / "+N" deep-link path. Cleared by
* the parent (via `onFocusTurnConsumed`) once the rail has
* acknowledged the deep-link so subsequent renders don't keep
* re-scrolling the same group.
*/
focusTurnId?: string | null
onFocusTurnConsumed?: () => void
}
const RAIL_LOCAL_STORAGE_PREFIX = 'browseros:outputs-rail:'
/**
* Controlled open/close state with per-agent localStorage memory.
* Returns a tuple compatible with React's useState shape so the
* parent can pass it straight into the rail without an extra effect.
*/
export function useOutputsRailOpen(
agentId: string,
): [boolean, (next: boolean) => void] {
const [open, setOpen] = useState(false)
useEffect(() => {
if (typeof window === 'undefined' || !agentId) return
try {
const stored = window.localStorage.getItem(
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
)
setOpen(stored === '1')
} catch {
// localStorage may be unavailable (private mode, locked-down
// contexts) — fall back to closed.
}
}, [agentId])
const update = (next: boolean) => {
setOpen(next)
if (typeof window === 'undefined' || !agentId) return
try {
window.localStorage.setItem(
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
next ? '1' : '0',
)
} catch {
// Best-effort persistence.
}
}
return [open, update]
}
export const OutputsRail: FC<OutputsRailProps> = ({
agentId,
onClose,
focusTurnId,
onFocusTurnConsumed,
}) => {
const { groups, loading, error } = useAgentOutputs(agentId)
const refresh = useRefreshAgentOutputs(agentId)
const [openFile, setOpenFile] = useState<{
id: string
path: string
} | null>(null)
const totalFiles = useMemo(
() => groups.reduce((sum, group) => sum + group.files.length, 0),
[groups],
)
return (
<aside className="flex h-full min-h-0 w-full flex-col border-border/50 border-l bg-background">
<header className="flex shrink-0 items-center gap-2 border-border/50 border-b px-3 py-3">
<span className="font-semibold text-[13px] uppercase tracking-wide">
Outputs
</span>
{totalFiles > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{totalFiles}
</span>
) : null}
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
refresh.mutate(undefined, {
onError: (err) =>
toast.error('Refresh failed', {
description:
err instanceof Error ? err.message : String(err),
}),
})
}
disabled={refresh.isPending}
title="Refresh"
>
{refresh.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={onClose}
title="Hide outputs"
>
<PanelRightClose className="size-3.5" />
</Button>
</div>
</header>
<ScrollArea className="min-h-0 flex-1">
<div className="px-2 py-2">
{loading && groups.length === 0 ? (
<RailSkeleton />
) : error ? (
<RailError message={error.message} />
) : groups.length === 0 ? (
<RailEmpty />
) : (
<ul className="flex flex-col gap-2">
{groups.map((group) => (
<li key={group.turnId}>
<RailTurnGroup
group={group}
focused={
Boolean(focusTurnId) && focusTurnId === group.turnId
}
onFocusConsumed={onFocusTurnConsumed}
onOpenFile={(file) =>
setOpenFile({ id: file.id, path: file.path })
}
/>
</li>
))}
</ul>
)}
</div>
</ScrollArea>
<FilePreviewSheet
fileId={openFile?.id ?? null}
filePath={openFile?.path ?? null}
open={Boolean(openFile)}
onOpenChange={(next) => {
if (!next) setOpenFile(null)
}}
/>
</aside>
)
}
function RailTurnGroup({
group,
focused,
onFocusConsumed,
onOpenFile,
}: {
group: ProducedFilesRailGroup
focused: boolean
onFocusConsumed?: () => void
onOpenFile: (file: { id: string; path: string }) => void
}) {
const [open, setOpen] = useState(true)
const headerLabel = group.turnPrompt.trim() || 'Turn'
const containerRef = useRef<HTMLDivElement>(null)
// Deep-link consumption: when the parent passes `focused=true`,
// expand the collapsible (in case the user had collapsed it
// earlier) and scroll into view. Fire `onFocusConsumed` so the
// parent can drop the URL param and we don't re-scroll on every
// render after that.
useEffect(() => {
if (!focused) return
setOpen(true)
containerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
onFocusConsumed?.()
}, [focused, onFocusConsumed])
return (
<div ref={containerRef}>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-muted-foreground text-xs',
'transition-colors hover:bg-accent/40 hover:text-foreground',
)}
>
{open ? (
<ChevronDown className="size-3 shrink-0" />
) : (
<ChevronRight className="size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate font-medium">
{headerLabel}
</span>
<span className="shrink-0 tabular-nums">{group.files.length}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-1 ml-1 flex flex-col gap-0.5 border-border/40 border-l pl-2">
{group.files.map((file) => (
<li key={file.id}>
<RailFileRow file={file} onOpen={() => onOpenFile(file)} />
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</div>
)
}
function RailFileRow({
file,
onOpen,
}: {
file: ProducedFilesRailGroup['files'][number]
onOpen: () => void
}) {
const name = basenameOf(file.path)
const kind = inferFileKind(file.path)
const Icon = kind === 'image' ? ImageIcon : FileText
return (
<button
type="button"
onClick={onOpen}
className={cn(
'flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition-colors',
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
)}
title={file.path}
>
<Icon className="size-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{name}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{formatFileSize(file.size)}
</span>
</button>
)
}
function RailSkeleton() {
return (
<div className="flex flex-col gap-2 px-1.5 py-1">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-5/6" />
</div>
)
}
function RailEmpty() {
return (
<div className="mx-2 my-3 flex flex-col items-center gap-1.5 rounded-lg border border-border/60 border-dashed bg-muted/20 px-3 py-6 text-center text-muted-foreground text-xs">
<Inbox className="size-4" />
<p className="font-medium">No outputs yet</p>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
Files this agent creates will appear here, grouped by the turn that made
them.
</p>
</div>
)
}
function RailError({ message }: { message: string }) {
return (
<div className="mx-2 my-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
{message}
</div>
)
}

View File

@@ -1,12 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { mapAgentHarnessToolStatus } from './agent-stream-events'
describe('mapAgentHarnessToolStatus', () => {
it('normalizes ACP tool statuses for the chat renderer', () => {
expect(mapAgentHarnessToolStatus('running')).toBe('running')
expect(mapAgentHarnessToolStatus('completed')).toBe('completed')
expect(mapAgentHarnessToolStatus('failed')).toBe('error')
expect(mapAgentHarnessToolStatus('incomplete')).toBe('running')
expect(mapAgentHarnessToolStatus(undefined)).toBe('running')
})
})

View File

@@ -1,19 +0,0 @@
import type { ToolEntry } from '@/lib/agent-conversations/types'
export function mapAgentHarnessToolStatus(
status: string | undefined,
): ToolEntry['status'] {
if (!status) return 'running'
const normalized = status.toLowerCase()
if (['error', 'failed', 'failure', 'denied'].includes(normalized)) {
return 'error'
}
if (
['complete', 'completed', 'done', 'success', 'succeeded'].includes(
normalized,
)
) {
return 'completed'
}
return 'running'
}

View File

@@ -1,183 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
import {
type AgentHistoryPageResponse,
type BrowserOSChatHistoryItem,
buildChatHistoryFromClawMessages,
filterTurnsPersistedInHistory,
flattenHistoryPages,
mapHistoryItemToClawMessage,
} from './claw-chat-types'
function historyItem(
overrides: Partial<BrowserOSChatHistoryItem>,
): BrowserOSChatHistoryItem {
return {
id: 'session-1:0',
role: 'user',
text: 'Hello',
timestamp: 1000,
messageSeq: 0,
sessionKey: 'session-1',
source: 'user-chat',
...overrides,
}
}
function page(items: BrowserOSChatHistoryItem[]): AgentHistoryPageResponse {
return {
agentId: 'main',
sessionKey: 'session-1',
session: null,
items,
page: {
hasMore: false,
limit: 50,
},
}
}
describe('claw-chat-types', () => {
it('maps backend history items into text-first ClawChat messages', () => {
const message = mapHistoryItemToClawMessage(
historyItem({
id: 'session-1:1',
role: 'assistant',
text: 'Hi there',
messageSeq: 1,
}),
)
expect(message).toEqual({
id: 'session-1:1',
role: 'assistant',
sessionKey: 'session-1',
timestamp: 1000,
source: 'user-chat',
messageSeq: 1,
status: 'historical',
parts: [{ type: 'text', text: 'Hi there' }],
})
})
it('flattens paginated history into oldest-to-newest render order', () => {
const messages = flattenHistoryPages([
page([
historyItem({
id: 'session-1:2',
role: 'user',
text: 'newer',
timestamp: 3000,
messageSeq: 2,
}),
]),
page([
historyItem({
id: 'session-1:0',
role: 'user',
text: 'older',
timestamp: 1000,
messageSeq: 0,
}),
historyItem({
id: 'session-1:1',
role: 'assistant',
text: 'middle',
timestamp: 2000,
messageSeq: 1,
}),
]),
])
expect(messages.map((message) => message.id)).toEqual([
'session-1:0',
'session-1:1',
'session-1:2',
])
})
it('builds OpenClaw chat history from text message parts only', () => {
const history = buildChatHistoryFromClawMessages([
{
id: 'user-1',
role: 'user',
sessionKey: 'session-1',
parts: [{ type: 'text', text: ' User request ' }],
},
{
id: 'assistant-1',
role: 'assistant',
sessionKey: 'session-1',
parts: [
{ type: 'reasoning', text: 'private reasoning' },
{ type: 'text', text: 'Assistant answer' },
],
},
])
expect(history).toEqual([
{ role: 'user', content: 'User request' },
{ role: 'assistant', content: 'Assistant answer' },
])
})
it('hides completed live turns once harness history contains the same turn', () => {
const turn: AgentConversationTurn = {
id: 'live-turn',
userText: 'hello',
parts: [{ kind: 'text', text: 'hi there' }],
done: true,
timestamp: 1_000,
}
const visible = filterTurnsPersistedInHistory(
[turn],
[
{
id: 'history-user',
role: 'user',
sessionKey: 'main',
timestamp: 1_050,
status: 'historical',
parts: [{ type: 'text', text: 'hello' }],
},
{
id: 'history-assistant',
role: 'assistant',
sessionKey: 'main',
timestamp: 1_100,
status: 'historical',
parts: [{ type: 'text', text: 'hi there' }],
},
],
)
expect(visible).toEqual([])
})
it('keeps completed live turns until matching assistant history arrives', () => {
const turn: AgentConversationTurn = {
id: 'live-turn',
userText: 'hello',
parts: [{ kind: 'text', text: 'hi there' }],
done: true,
timestamp: 1_000,
}
const visible = filterTurnsPersistedInHistory(
[turn],
[
{
id: 'history-user',
role: 'user',
sessionKey: 'main',
timestamp: 1_050,
status: 'historical',
parts: [{ type: 'text', text: 'hello' }],
},
],
)
expect(visible).toEqual([turn])
})
})

View File

@@ -1,368 +0,0 @@
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
export type ClawChatRole = 'user' | 'assistant'
export type ClawChatSource = 'user-chat' | 'cron' | 'hook' | 'channel' | 'other'
export interface BrowserOSOpenClawSession {
key: string
updatedAt: number
sessionId: string
agentId: string
kind: string
source: ClawChatSource
status?: string
totalTokens?: number
model?: string
modelProvider?: string
}
export interface BrowserOSChatHistoryToolCall {
toolCallId?: string
toolName: string
label: string
subject?: string
status: 'pending' | 'running' | 'completed' | 'failed'
input?: unknown
output?: unknown
error?: string
durationMs?: number
}
export interface BrowserOSChatHistoryReasoning {
text: string
durationMs?: number
}
export interface BrowserOSChatHistoryAttachment {
kind: 'image' | 'file'
mediaType: string
// Images carry a `data:` URL so we can render directly without any
// additional fetch; files (text/PDF) currently round-trip via inline
// text in the message body and do not populate this field in v1.
dataUrl?: string
name?: string
}
export interface BrowserOSChatHistoryItem {
id: string
role: ClawChatRole
text: string
timestamp?: number
messageSeq: number
sessionKey: string
source: ClawChatSource
costUsd?: number
tokensIn?: number
tokensOut?: number
toolCalls?: BrowserOSChatHistoryToolCall[]
reasoning?: BrowserOSChatHistoryReasoning
attachments?: BrowserOSChatHistoryAttachment[]
}
export interface AgentHistoryPageResponse {
agentId: string
sessionKey: string | null
session: BrowserOSOpenClawSession | null
items: BrowserOSChatHistoryItem[]
page: {
cursor?: string
hasMore: boolean
limit: number
}
}
export type ClawChatMessageStatus =
| 'historical'
| 'sending'
| 'streaming'
| 'error'
export type ClawChatMessagePart =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string; duration?: number }
| {
type: 'tool-call'
name: string
label: string
subject?: string
status: 'pending' | 'running' | 'completed' | 'failed'
input?: unknown
output?: unknown
error?: string
durationMs?: number
}
| {
type: 'attachment'
kind: 'image' | 'file'
mediaType: string
dataUrl?: string
name?: string
}
| { type: 'meta'; label: string; value: string }
export interface ClawChatMessage {
id: string
role: ClawChatRole
sessionKey: string
timestamp?: number
source?: ClawChatSource
messageSeq?: number
status?: ClawChatMessageStatus
parts: ClawChatMessagePart[]
costUsd?: number
tokensIn?: number
tokensOut?: number
}
export function mapHistoryItemToClawMessage(
item: BrowserOSChatHistoryItem,
): ClawChatMessage {
const parts: ClawChatMessagePart[] = []
// Attachments first — they belong above the text in user messages and
// never appear on assistant messages today (assistant images come back
// through tool results, which render via the Task collapsible).
if (item.attachments && item.attachments.length > 0) {
for (const attachment of item.attachments) {
parts.push({
type: 'attachment',
kind: attachment.kind,
mediaType: attachment.mediaType,
dataUrl: attachment.dataUrl,
name: attachment.name,
})
}
}
// Reasoning, then tool calls, then text — the chronological order the
// agent produced them (think → act → answer).
if (item.reasoning && item.reasoning.text.trim().length > 0) {
// 0ms means thinking and the final answer were emitted in the same JSONL
// line (no tool calls between them) — there's no real elapsed wall-clock,
// so fall through to the "Thinking" trigger instead of "Thought for 0
// seconds" / streaming shimmer. Real multi-line turns floor at 1s.
const durationMs = item.reasoning.durationMs ?? 0
const duration =
durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : undefined
parts.push({
type: 'reasoning',
text: item.reasoning.text,
duration,
})
}
if (item.toolCalls && item.toolCalls.length > 0) {
for (const tc of item.toolCalls) {
parts.push({
type: 'tool-call',
name: tc.toolName,
label: tc.label,
subject: tc.subject,
status: tc.status,
input: tc.input,
output: tc.output,
error: tc.error,
durationMs: tc.durationMs,
})
}
}
// Only emit a text part when there's actual content. User messages with
// only attachments and no caption shouldn't render an empty bubble.
if (item.text.trim().length > 0) {
parts.push({ type: 'text', text: item.text })
}
return {
id: item.id,
role: item.role,
sessionKey: item.sessionKey,
timestamp: item.timestamp,
source: item.source,
messageSeq: item.messageSeq,
status: 'historical',
parts,
costUsd: item.costUsd,
tokensIn: item.tokensIn,
tokensOut: item.tokensOut,
}
}
export function flattenHistoryPages(
pages: AgentHistoryPageResponse[],
): ClawChatMessage[] {
return pages
.flatMap((page) => page.items)
.sort((a, b) => {
if (a.timestamp != null && b.timestamp != null) {
return a.timestamp - b.timestamp
}
return a.messageSeq - b.messageSeq
})
.map(mapHistoryItemToClawMessage)
}
export function buildChatHistoryFromClawMessages(
messages: ClawChatMessage[],
): OpenClawChatHistoryMessage[] {
return messages
.map((message) => {
const content = message.parts
.filter((part): part is { type: 'text'; text: string } => {
return part.type === 'text' && part.text.trim().length > 0
})
.map((part) => part.text.trim())
.join('\n\n')
return content ? { role: message.role, content } : null
})
.filter((message): message is OpenClawChatHistoryMessage =>
Boolean(message),
)
}
const TURN_HISTORY_MATCH_WINDOW_MS = 5_000
export function filterTurnsPersistedInHistory(
turns: AgentConversationTurn[],
historyMessages: ClawChatMessage[],
): AgentConversationTurn[] {
return turns.filter(
(turn) => !isTurnPersistedInHistory(turn, historyMessages),
)
}
/**
* Persisted turns that still carry `producedFiles` — once history
* reloads, the assistant text is rendered by `ClawChatMessage` and
* the optimistic turn is filtered out by
* `filterTurnsPersistedInHistory`. The historical message has no
* `producedFiles` field (history items don't carry that), so the
* inline file-card strip would vanish on history reload.
*
* Returning these here lets the caller render a strip-only entry
* after the corresponding history bubble — full message stays as
* the persisted history pair, but the produced-files affordance
* survives.
*/
export function selectStripOnlyTurns(
turns: AgentConversationTurn[],
historyMessages: ClawChatMessage[],
): AgentConversationTurn[] {
return turns.filter(
(turn) =>
Boolean(turn.producedFiles && turn.producedFiles.length > 0) &&
isTurnPersistedInHistory(turn, historyMessages),
)
}
function isTurnPersistedInHistory(
turn: AgentConversationTurn,
historyMessages: ClawChatMessage[],
): boolean {
if (!turn.done) return false
const assistantText = getTurnAssistantText(turn)
if (!assistantText) return false
const minTimestamp = turn.timestamp - TURN_HISTORY_MATCH_WINDOW_MS
const userText = turn.userText.trim()
const userPersisted =
!userText ||
historyMessages.some(
(message) =>
message.role === 'user' &&
isHistoryMessageAfter(message, minTimestamp) &&
getClawMessageText(message) === userText,
)
const assistantPersisted = historyMessages.some(
(message) =>
message.role === 'assistant' &&
isHistoryMessageAfter(message, minTimestamp) &&
getClawMessageText(message) === assistantText,
)
return userPersisted && assistantPersisted
}
function isHistoryMessageAfter(
message: ClawChatMessage,
minTimestamp: number,
): boolean {
return message.timestamp == null || message.timestamp >= minTimestamp
}
function getTurnAssistantText(turn: AgentConversationTurn): string {
return turn.parts
.filter((part) => part.kind === 'text')
.map((part) => part.text)
.join('')
.trim()
}
function getClawMessageText(message: ClawChatMessage): string {
return message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
.trim()
}
function firstNonBlankLine(value: string): string {
for (const raw of value.split('\n')) {
const trimmed = raw.trim()
if (trimmed) return trimmed
}
return ''
}
/**
* Map each assistant history message to the produced-files group
* that came from its turn. Match key is `group.turnPrompt` (first
* non-blank line of the user prompt that initiated the turn) vs.
* the first non-blank line of the user message that immediately
* preceded this assistant message — the same shape the server
* emits when storing turnPrompt.
*
* Walks history forward (oldest-first per `flattenHistoryPages`)
* and consumes groups in chronological order. A group can only
* match once — if two turns share the same prompt the earlier
* one wins, and the later assistant message stays unassociated
* (those land back in `tailStripGroups` at the conversation tail).
*/
export function mapHistoryToProducedFilesGroups(
historyMessages: ClawChatMessage[],
groups: ReadonlyArray<ProducedFilesRailGroup>,
): {
byAssistantMessageId: Map<string, ProducedFilesRailGroup>
unmatched: ProducedFilesRailGroup[]
} {
const byAssistantMessageId = new Map<string, ProducedFilesRailGroup>()
if (groups.length === 0) {
return { byAssistantMessageId, unmatched: [] }
}
// Oldest-first so the iteration order matches history.
const remaining = [...groups].sort((a, b) => a.createdAt - b.createdAt)
let pendingPrompt: string | null = null
for (const message of historyMessages) {
if (message.role === 'user') {
pendingPrompt = firstNonBlankLine(getClawMessageText(message))
continue
}
if (message.role !== 'assistant' || !pendingPrompt) continue
const matchIndex = remaining.findIndex(
(group) => group.turnPrompt === pendingPrompt,
)
if (matchIndex >= 0) {
const [match] = remaining.splice(matchIndex, 1)
byAssistantMessageId.set(message.id, match)
}
pendingPrompt = null
}
return { byAssistantMessageId, unmatched: remaining }
}

View File

@@ -1,71 +0,0 @@
import { buildToolLabel } from '../../../lib/tool-labels'
import type { HarnessAgentHistoryPage } from '../agents/agent-harness-types'
import type {
AgentHistoryPageResponse,
BrowserOSChatHistoryItem,
BrowserOSChatHistoryToolCall,
} from './claw-chat-types'
export function mapHarnessHistoryPage(
page: HarnessAgentHistoryPage,
): AgentHistoryPageResponse {
const items: BrowserOSChatHistoryItem[] = page.items.map((item, index) => {
const toolCalls = item.toolCalls?.map(
(tool): BrowserOSChatHistoryToolCall => {
const input = asRecord(tool.input)
const { label, subject } = buildToolLabel(tool.toolName, input)
return {
toolName: tool.toolName,
label,
status: tool.status,
...(tool.toolCallId ? { toolCallId: tool.toolCallId } : {}),
...(subject ? { subject } : {}),
...(tool.input !== undefined ? { input: tool.input } : {}),
...(tool.output !== undefined ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {}),
...(tool.durationMs != null ? { durationMs: tool.durationMs } : {}),
}
},
)
return {
id: item.id,
role: item.role,
text: item.text,
timestamp: item.createdAt,
messageSeq: index + 1,
sessionKey: 'main',
source: 'user-chat',
...(item.reasoning ? { reasoning: item.reasoning } : {}),
...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}),
}
})
const updatedAt =
page.items.length > 0
? Math.max(...page.items.map((item) => item.createdAt))
: Date.now()
return {
agentId: page.agentId,
sessionKey: 'main',
session: {
key: 'main',
updatedAt,
sessionId: 'main',
agentId: page.agentId,
kind: 'agent-harness',
source: 'user-chat',
},
items,
page: {
hasMore: false,
limit: items.length,
},
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}

View File

@@ -1,69 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
import { orderHomeAgents } from './home-agent-card.helpers'
function agent(overrides: Partial<HarnessAgent>): HarnessAgent {
return {
id: overrides.id ?? 'agent-x',
name: overrides.name ?? overrides.id ?? 'agent-x',
adapter: overrides.adapter ?? 'codex',
permissionMode: 'approve-all',
sessionKey: `agent:${overrides.id ?? 'agent-x'}:main`,
createdAt: 1000,
updatedAt: 1000,
...overrides,
}
}
describe('orderHomeAgents', () => {
it('places active-turn agents before everyone else', () => {
const sorted = orderHomeAgents([
agent({ id: 'a', lastUsedAt: 5000 }),
agent({ id: 'b', lastUsedAt: 9000, activeTurnId: 'turn-1' }),
agent({ id: 'c', lastUsedAt: 7000 }),
])
expect(sorted.map((a) => a.id)).toEqual(['b', 'c', 'a'])
})
it('orders non-active agents by lastUsedAt desc', () => {
const sorted = orderHomeAgents([
agent({ id: 'old', lastUsedAt: 1000 }),
agent({ id: 'new', lastUsedAt: 9000 }),
agent({ id: 'mid', lastUsedAt: 5000 }),
])
expect(sorted.map((a) => a.id)).toEqual(['new', 'mid', 'old'])
})
it('puts the gateway `main` seed agent above other never-used agents', () => {
const sorted = orderHomeAgents([
agent({ id: 'oc-aaaaaa', lastUsedAt: null }),
agent({ id: 'main', lastUsedAt: null }),
agent({ id: 'oc-bbbbbb', lastUsedAt: null }),
])
expect(sorted.map((a) => a.id)).toEqual(['main', 'oc-aaaaaa', 'oc-bbbbbb'])
})
it('sends never-used agents to the bottom even when `main` is among them', () => {
const sorted = orderHomeAgents([
agent({ id: 'main', lastUsedAt: null }),
agent({ id: 'used', lastUsedAt: 5000 }),
])
expect(sorted.map((a) => a.id)).toEqual(['used', 'main'])
})
it('does NOT sort by pinned — pinned agents are treated like any other', () => {
const sorted = orderHomeAgents([
agent({ id: 'unpinned-recent', lastUsedAt: 9000, pinned: false }),
agent({ id: 'pinned-old', lastUsedAt: 1000, pinned: true }),
])
expect(sorted.map((a) => a.id)).toEqual(['unpinned-recent', 'pinned-old'])
})
it('falls back to id-stable ordering when lastUsedAt ties', () => {
const sorted = orderHomeAgents([
agent({ id: 'b', lastUsedAt: 5000 }),
agent({ id: 'a', lastUsedAt: 5000 }),
])
expect(sorted.map((a) => a.id)).toEqual(['a', 'b'])
})
})

View File

@@ -1,42 +0,0 @@
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
/**
* Order for the /home Recent agents grid.
*
* 1. Active turn first — agents mid-turn float to the top so the
* Resume affordance is the first thing the user sees on /home.
* 2. The protected gateway-side `main` agent stays pinned-to-top in
* the never-used group on a fresh install (mirrors the rail).
* 3. Recency (`lastUsedAt` desc).
* 4. `id` tiebreaker for stability so the grid doesn't reshuffle on
* every 5-second poll.
*
* Pin is NOT a sort key. The home grid is action-oriented and trusts
* recency + active-turn to surface the right agent; pinning is an
* organisation tool that lives on the rail at /agents.
*/
export function orderHomeAgents(agents: HarnessAgent[]): HarnessAgent[] {
return [...agents].sort((a, b) => {
const aActive = a.activeTurnId != null
const bActive = b.activeTurnId != null
if (aActive !== bActive) return aActive ? -1 : 1
// Recency wins outright. Never-used agents (`lastUsedAt == null`)
// both fall to the same `-Infinity` bucket and the seed/id rules
// below decide their order — but a used agent always beats any
// never-used agent regardless of id.
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
if (aValue !== bValue) return bValue - aValue
// Inside the never-used (or exact-tie) group: pin the gateway
// `main` seed to the top of the group on a fresh install, then
// fall back to id-stable order so the grid doesn't reshuffle on
// every poll.
const aSeed = a.id === 'main' && a.lastUsedAt == null
const bSeed = b.id === 'main' && b.lastUsedAt == null
if (aSeed !== bSeed) return aSeed ? -1 : 1
return a.id.localeCompare(b.id)
})
}

View File

@@ -1,109 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import type { StagedAttachment } from '@/lib/attachments'
import {
consumePendingInitialMessage,
peekPendingInitialMessage,
setPendingInitialMessage,
} from './pending-initial-message'
function makeAttachment(id: string): StagedAttachment {
return {
id,
kind: 'image',
mediaType: 'image/png',
name: `${id}.png`,
dataUrl: `data:image/png;base64,${id}`,
payload: {
kind: 'image',
mediaType: 'image/png',
name: `${id}.png`,
dataUrl: `data:image/png;base64,${id}`,
},
}
}
afterEach(() => {
// Drain any leftover pending entry so tests don't leak into each
// other (the module-scope state survives across `it` blocks).
consumePendingInitialMessage('drain')
// If still set, clear by consuming with the matching id.
const leftover = peekPendingInitialMessage()
if (leftover) consumePendingInitialMessage(leftover.agentId)
})
describe('pending-initial-message', () => {
it('consume returns the payload set for the same agentId', () => {
setPendingInitialMessage({
agentId: 'agent-a',
text: 'hello',
attachments: [makeAttachment('one')],
createdAt: Date.now(),
})
const result = consumePendingInitialMessage('agent-a')
expect(result?.text).toBe('hello')
expect(result?.attachments).toHaveLength(1)
expect(result?.attachments[0]?.id).toBe('one')
})
it('consume is destructive — second call returns null', () => {
setPendingInitialMessage({
agentId: 'agent-a',
text: 'hello',
attachments: [],
createdAt: Date.now(),
})
expect(consumePendingInitialMessage('agent-a')).not.toBeNull()
expect(consumePendingInitialMessage('agent-a')).toBeNull()
})
it('consume returns null and preserves entry when agentId differs', () => {
setPendingInitialMessage({
agentId: 'agent-a',
text: 'hello',
attachments: [],
createdAt: Date.now(),
})
expect(consumePendingInitialMessage('agent-b')).toBeNull()
expect(peekPendingInitialMessage()?.agentId).toBe('agent-a')
expect(consumePendingInitialMessage('agent-a')).not.toBeNull()
})
it('returns null for entries older than the TTL', () => {
setPendingInitialMessage({
agentId: 'agent-a',
text: 'old',
attachments: [],
createdAt: Date.now() - 11_000, // older than 10 s TTL
})
expect(consumePendingInitialMessage('agent-a')).toBeNull()
})
it('replaces a previous pending entry when set is called again', () => {
setPendingInitialMessage({
agentId: 'agent-a',
text: 'first',
attachments: [],
createdAt: Date.now(),
})
setPendingInitialMessage({
agentId: 'agent-b',
text: 'second',
attachments: [makeAttachment('two')],
createdAt: Date.now(),
})
expect(consumePendingInitialMessage('agent-a')).toBeNull()
const result = consumePendingInitialMessage('agent-b')
expect(result?.text).toBe('second')
expect(result?.attachments[0]?.id).toBe('two')
})
it('no-ops when set is called with empty agentId', () => {
setPendingInitialMessage({
agentId: '',
text: 'oops',
attachments: [],
createdAt: Date.now(),
})
expect(peekPendingInitialMessage()).toBeNull()
})
})

View File

@@ -1,81 +0,0 @@
import type { StagedAttachment } from '@/lib/attachments'
/**
* Same-tab in-memory handoff between the `/home` composer and the
* chat screen at `/home/agents/:agentId`. URL search params (`?q=`)
* carry the text fine, but cannot carry binary attachments — a multi-
* megabyte image dataUrl would explode URL length limits and round-
* trip badly. This module is the rich-data side channel for the same
* navigation: the composer writes here, the chat screen reads here on
* mount.
*
* Intentionally module-scope. Same render tree, same tab — no need
* for sessionStorage (which would force JSON-serialising the dataUrls
* and re-parsing on the read side). Cross-tab handoff is out of
* scope: the user typing at home in tab A and switching to tab B's
* chat would surface an empty registry there, which is the correct
* behaviour.
*/
export interface PendingInitialMessage {
agentId: string
text: string
attachments: StagedAttachment[]
createdAt: number
}
/**
* 10s TTL on the entry. A stale entry from a back-button journey
* shouldn't fire on a future visit; if real-world latency makes 10s
* too tight under slow harness boot, bump but never make it
* indefinite.
*/
const PENDING_TTL_MS = 10_000
let pending: PendingInitialMessage | null = null
let pendingTimer: ReturnType<typeof setTimeout> | null = null
function clearPending(): void {
pending = null
if (pendingTimer !== null) {
clearTimeout(pendingTimer)
pendingTimer = null
}
}
export function setPendingInitialMessage(payload: PendingInitialMessage): void {
// Defensive: the home composer should never call this without an
// agent selected. If it somehow does, no-op rather than holding a
// payload we can't route.
if (!payload.agentId) return
clearPending()
pending = payload
pendingTimer = setTimeout(clearPending, PENDING_TTL_MS)
}
/**
* Destructive read. Returns the entry only if `agentId` matches and
* the entry is fresh; clears the entry on success so Strict-Mode
* double-invokes can't double-send.
*/
export function consumePendingInitialMessage(
agentId: string,
): PendingInitialMessage | null {
if (!pending) return null
if (pending.agentId !== agentId) return null
if (Date.now() - pending.createdAt >= PENDING_TTL_MS) {
clearPending()
return null
}
const entry = pending
clearPending()
return entry
}
/**
* Non-mutating read for tests. Production code should never need this
* — use `consume` and own the lifecycle.
*/
export function peekPendingInitialMessage(): PendingInitialMessage | null {
return pending
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react'
import {
type AgentEntry,
getModelDisplayName,
type OpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
import { getLatestConversation } from '@/lib/agent-conversations/storage'
import type { AgentCardData } from '@/lib/agent-conversations/types'
function getAgentStatusTone(
status: OpenClawStatus['status'] | undefined,
): AgentCardData['status'] {
if (status === 'error') return 'error'
if (status === 'starting') return 'working'
return 'idle'
}
async function getAgentCardData(
agent: AgentEntry,
status: OpenClawStatus['status'] | undefined,
): Promise<AgentCardData> {
const conversation = await getLatestConversation(agent.agentId)
const lastTurn = conversation?.turns[conversation.turns.length - 1]
const lastTextPart = lastTurn?.parts.findLast((part) => part.kind === 'text')
return {
agentId: agent.agentId,
name: agent.name,
model: getModelDisplayName(agent.model),
status: getAgentStatusTone(status),
lastMessage:
lastTextPart?.kind === 'text'
? lastTextPart.text.slice(0, 120)
: undefined,
lastMessageTimestamp: lastTurn?.timestamp,
}
}
export function useAgentCardData(
agents: AgentEntry[],
status: OpenClawStatus['status'] | undefined,
) {
const [cardData, setCardData] = useState<AgentCardData[]>([])
useEffect(() => {
let active = true
const loadCardData = async () => {
const nextCardData = await Promise.all(
agents.map((agent) => getAgentCardData(agent, status)),
)
if (active) {
setCardData(nextCardData)
}
}
if (agents.length > 0) {
void loadCardData()
} else {
setCardData([])
}
return () => {
active = false
}
}, [agents, status])
return cardData
}

View File

@@ -1,94 +1,52 @@
import { useEffect, useRef, useState } from 'react'
import {
type AgentHarnessStreamEvent,
attachToHarnessTurn,
cancelHarnessTurn,
chatWithHarnessAgent,
fetchActiveHarnessTurn,
} from '@/entrypoints/app/agents/useAgents'
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from '@/entrypoints/app/agents/useOpenClaw'
import {
getLatestConversation,
saveConversation,
} from '@/lib/agent-conversations/storage'
import type {
AgentConversation,
AgentConversationTurn,
AssistantPart,
ConversationTurnFile,
ToolEntry,
UserAttachmentPreview,
} from '@/lib/agent-conversations/types'
import { useInvalidateAgentOutputs } from '@/lib/agent-files'
import type { ServerAttachmentPayload } from '@/lib/attachments'
import { consumeSSEStream } from '@/lib/sse'
import { buildToolLabel } from '@/lib/tool-labels'
import { mapAgentHarnessToolStatus } from './agent-stream-events'
export interface SendInput {
text: string
attachments?: ServerAttachmentPayload[]
// Optional preview metadata used to render the optimistic user turn.
// Built by the composer at staging time; the server only sees the
// payload array.
attachmentPreviews?: UserAttachmentPreview[]
}
interface UseAgentConversationOptions {
// The hook always speaks to the harness chat path now; the OpenClaw
// legacy /claw/agents/:id/chat surface was removed in Step 12. The
// option remains for forward-compatibility.
runtime?: 'agent-harness'
sessionKey?: string | null
history?: OpenClawChatHistoryMessage[]
onComplete?: () => void
onSessionKeyChange?: (sessionKey: string) => void
/**
* Server-side active turn id, surfaced via the listing query. When
* this changes from null/<id> to a different non-null id while we
* aren't already streaming (e.g. the server just popped a queued
* message and started a new turn), the hook reattaches via
* /chat/active so the chat panel picks up the live stream without
* waiting for a remount.
*/
activeTurnId?: string | null
}
export function useAgentConversation(
agentId: string,
options: UseAgentConversationOptions = {},
) {
export function useAgentConversation(agentId: string, agentName: string) {
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
const [streaming, setStreaming] = useState(false)
const invalidateAgentOutputs = useInvalidateAgentOutputs()
// Stable ref so the resume effect doesn't re-subscribe on every
// render (the hook's returned callable is freshly closured each
// time, but the underlying queryClient is stable).
const invalidateAgentOutputsRef = useRef(invalidateAgentOutputs)
invalidateAgentOutputsRef.current = invalidateAgentOutputs
const sessionKeyRef = useRef(options.sessionKey ?? '')
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
const [loading, setLoading] = useState(true)
const sessionKeyRef = useRef('')
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const streamAbortRef = useRef<AbortController | null>(null)
const onCompleteRef = useRef(options.onComplete)
const onSessionKeyChangeRef = useRef(options.onSessionKeyChange)
// Per-turn resume bookkeeping. `turnId` is captured from the response
// header; `lastSeq` advances with every SSE event so a reconnect can
// resume via Last-Event-ID.
const turnIdRef = useRef<string | null>(null)
const lastSeqRef = useRef<number | null>(null)
useEffect(() => {
sessionKeyRef.current = options.sessionKey ?? ''
}, [options.sessionKey])
useEffect(() => {
historyRef.current = options.history ?? []
}, [options.history])
useEffect(() => {
onCompleteRef.current = options.onComplete
}, [options.onComplete])
useEffect(() => {
onSessionKeyChangeRef.current = options.onSessionKeyChange
}, [options.onSessionKeyChange])
let active = true
getLatestConversation(agentId)
.then((conv) => {
if (!active) return
if (conv) {
setTurns(conv.turns)
sessionKeyRef.current = conv.sessionKey
} else {
sessionKeyRef.current = crypto.randomUUID()
}
setLoading(false)
})
.catch(() => {
if (active) {
sessionKeyRef.current = crypto.randomUUID()
setLoading(false)
}
})
return () => {
active = false
}
}, [agentId])
useEffect(() => {
return () => {
@@ -96,11 +54,17 @@ export function useAgentConversation(
}
}, [])
// Indirection for the resume effect below: lets it call the latest
// event handler without re-subscribing on every render.
const processEventRef = useRef<(event: AgentHarnessStreamEvent) => void>(
() => {},
)
const persistTurns = (updatedTurns: AgentConversationTurn[]) => {
const conv: AgentConversation = {
agentId,
agentName,
sessionKey: sessionKeyRef.current,
turns: updatedTurns,
createdAt: updatedTurns[0]?.timestamp ?? Date.now(),
updatedAt: Date.now(),
}
saveConversation(conv).catch(() => {})
}
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
@@ -112,310 +76,123 @@ export function useAgentConversation(
})
}
const appendTextDelta = (delta: string) => {
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
}
const appendThinkingDelta = (delta: string) => {
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
}
const appendErrorText = (message: string) => {
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${message}` },
])
}
const markCurrentTurnDone = () => {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
}
const setProducedFilesOnCurrentTurn = (files: ConversationTurnFile[]) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
// Replace, don't merge: the server's diff is authoritative for
// the just-completed turn — duplicate events shouldn't grow the
// list, and a re-attribution should overwrite an earlier one.
return [...prev.slice(0, -1), { ...last, producedFiles: files }]
})
}
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
if (event.type !== 'tool_call') return
const rawName = event.title || event.rawType || 'tool call'
const { label, subject } = buildToolLabel(
rawName,
event.text ? { description: event.text } : undefined,
)
const tool: ToolEntry = {
id: event.id ?? crypto.randomUUID(),
name: rawName,
label,
subject,
status: mapAgentHarnessToolStatus(event.status),
}
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((existing) => existing.id === tool.id)
) {
const tools = part.tools.map((existing) =>
existing.id === tool.id ? { ...existing, ...tool } : existing,
)
return [
...parts.slice(0, i),
{ ...part, tools },
...parts.slice(i + 1),
]
}
}
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
}
const processAgentHarnessStreamEvent = (event: AgentHarnessStreamEvent) => {
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text_delta':
if (event.stream === 'thought') {
appendThinkingDelta(event.text)
} else {
appendTextDelta(event.text)
}
break
case 'tool_call':
upsertAgentHarnessTool(event)
break
case 'produced_files':
setProducedFilesOnCurrentTurn(event.files)
break
case 'done':
markCurrentTurnDone()
break
case 'error':
appendErrorText(event.message)
break
case 'status':
break
}
}
processEventRef.current = processAgentHarnessStreamEvent
const activeTurnIdDep = options.activeTurnId ?? null
// On mount, on agent change, and whenever the listing reports a
// *new* active turn id, check whether the server has an in-flight
// turn for this agent and reattach to it. This catches three
// cases at once: the chat resilience flow (tab close/reopen),
// navigation between agents, AND queue drain (the server starts a
// new turn from a queued message → activeTurnId flips → attach).
useEffect(() => {
let cancelled = false
const abortController = new AbortController()
// Reference the dep inside the body so biome's exhaustive-deps
// rule sees it consumed; the value is just an "any non-null
// active turn id" trigger — the actual id we attach to comes
// from the fresh fetchActiveHarnessTurn call below.
void activeTurnIdDep
const attemptResume = async () => {
// Track whether *we* started a stream in this run. When the
// early-return paths fire (no active turn, or a `send()` /
// earlier resume already owns `streamAbortRef`), the finally
// block must NOT touch streaming/turnIdRef/lastSeqRef —
// otherwise we clobber the in-flight stream's state and the
// Stop button drops out mid-turn while events keep arriving.
let weStartedStream = false
try {
const active = await fetchActiveHarnessTurn(agentId)
if (cancelled || !active || active.status !== 'running') return
if (streamAbortRef.current) return // someone else already owns the stream
// Stage a placeholder turn so the streamed events have a row
// to render into. The server now persists the kicking-off
// prompt on the active turn, so we render it as the user
// bubble immediately — no empty-bubble flicker when a queued
// message starts running.
setTurns((prev) => [
...prev,
{
id: crypto.randomUUID(),
turnId: active.turnId,
userText: active.prompt ?? '',
parts: [],
done: false,
timestamp: active.startedAt,
},
])
textAccRef.current = ''
thinkAccRef.current = ''
turnIdRef.current = active.turnId
lastSeqRef.current = null
streamAbortRef.current = abortController
setStreaming(true)
weStartedStream = true
const response = await attachToHarnessTurn(agentId, {
turnId: active.turnId,
signal: abortController.signal,
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
if (!response.ok) return
await consumeSSEStream<AgentHarnessStreamEvent>(
response,
(event, meta) => {
if (typeof meta.seq === 'number') lastSeqRef.current = meta.seq
processEventRef.current(event)
},
abortController.signal,
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running' as const,
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const toolStatus: 'completed' | 'error' =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId ? { ...t, status: toolStatus, durationMs } : t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
} catch {
// Resume is best-effort; transient errors fall back to the
// user starting a new turn manually.
} finally {
// Always release `streamAbortRef` if we owned it — even when
// the effect was cancelled mid-stream (a listing poll
// captured the next queue-drain turn id, for example). If we
// don't, the next effect run hits `if (streamAbortRef.current)
// return` against our now-aborted controller and never
// reattaches, leaving `streaming === true` with no live stream.
if (weStartedStream && streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
// The other state (streaming flag, turn id, lastSeq) is the
// *current run's* lifecycle: only reset it on a clean exit.
// When `cancelled` is true the next run will set these
// itself, so resetting here would only cause a brief flicker.
if (!cancelled && weStartedStream) {
const finishedTurnId = turnIdRef.current
turnIdRef.current = null
lastSeqRef.current = null
setStreaming(false)
void invalidateAgentOutputsRef.current(
agentId,
finishedTurnId ?? undefined,
)
}
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
const updated = [...prev.slice(0, -1), { ...last, done: true }]
persistTurns(updated)
return updated
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
void attemptResume()
return () => {
cancelled = true
abortController.abort()
}
}, [agentId, activeTurnIdDep])
/**
* Send the chat request and follow the 409-active-turn redirect
* once. Pulled out of `send` to keep its cognitive complexity in
* check — the retry adds a branch that biome counts heavily.
*/
const openSendStream = async (
targetAgentId: string,
text: string,
attachments: ServerAttachmentPayload[],
signal: AbortSignal,
): Promise<Response> => {
const initial = await chatWithHarnessAgent(
targetAgentId,
text,
signal,
attachments,
)
if (initial.status !== 409) return initial
// 409 means the server already has an active turn for this agent
// (a previous tab kicked one off and we're a fresh mount that
// missed the resume window). Attach to it instead of double-sending.
const body = (await initial.json()) as { turnId?: string }
if (!body.turnId) return initial
return attachToHarnessTurn(targetAgentId, {
turnId: body.turnId,
signal,
})
}
/**
* Pull session-key / turn-id off response headers and propagate to
* refs + the optimistic turn. Stamping `turnId` here lets the
* inline artifact card fall back to /files/turn/<id> on a resumed
* mount that missed the live `produced_files` event.
*/
const applyResponseHeadersToTurn = (response: Response) => {
const responseSessionKey =
response.headers.get('X-Session-Key') ??
response.headers.get('X-Session-Id')
if (responseSessionKey) {
sessionKeyRef.current = responseSessionKey
onSessionKeyChangeRef.current?.(responseSessionKey)
}
const responseTurnId = response.headers.get('X-Turn-Id')
if (!responseTurnId) return
turnIdRef.current = responseTurnId
lastSeqRef.current = null
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, turnId: responseTurnId }]
})
}
const send = async (input: string | SendInput) => {
const normalized: SendInput =
typeof input === 'string' ? { text: input } : input
const trimmed = normalized.text.trim()
const attachments = normalized.attachments ?? []
if (streaming) return
if (!trimmed && attachments.length === 0) return
const send = async (text: string) => {
if (!text.trim() || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: AgentConversationTurn = {
id: crypto.randomUUID(),
userText: trimmed,
userAttachments:
normalized.attachmentPreviews &&
normalized.attachmentPreviews.length > 0
? normalized.attachmentPreviews
: undefined,
userText: text.trim(),
parts: [],
done: false,
timestamp: Date.now(),
@@ -428,13 +205,13 @@ export function useAgentConversation(
streamAbortRef.current = abortController
try {
const response = await openSendStream(
const response = await chatWithAgent(
agentId,
trimmed,
attachments,
text.trim(),
sessionKeyRef.current,
history,
abortController.signal,
)
applyResponseHeadersToTurn(response)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
@@ -443,12 +220,9 @@ export function useAgentConversation(
])
return
}
await consumeSSEStream<AgentHarnessStreamEvent>(
await consumeSSEStream(
response,
(event, meta) => {
if (typeof meta.seq === 'number') lastSeqRef.current = meta.seq
processAgentHarnessStreamEvent(event)
},
processStreamEvent,
abortController.signal,
)
} catch (err) {
@@ -462,50 +236,24 @@ export function useAgentConversation(
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
// Capture before nulling — the invalidation needs the turn id so
// useAgentTurnFiles consumers also flush, not just the agent-wide
// rail query.
const finishedTurnId = turnIdRef.current
turnIdRef.current = null
lastSeqRef.current = null
onCompleteRef.current?.()
setStreaming(false)
void invalidateAgentOutputs(agentId, finishedTurnId ?? undefined)
}
}
/**
* Stop button. The fetch abort only detaches *this* SSE subscriber
* now — the underlying turn would otherwise keep running on the
* server. So we explicitly cancel via the new endpoint, then unwind
* the local stream.
*/
const stop = async () => {
const turnId = turnIdRef.current ?? undefined
streamAbortRef.current?.abort()
streamAbortRef.current = null
try {
await cancelHarnessTurn(agentId, {
turnId,
reason: 'user pressed stop',
})
} catch {
// Best-effort — UI already aborted.
}
}
const resetConversation = () => {
void stop()
streamAbortRef.current?.abort()
streamAbortRef.current = null
setTurns([])
setStreaming(false)
sessionKeyRef.current = crypto.randomUUID()
}
return {
turns,
streaming,
loading,
sessionKey: sessionKeyRef.current,
send,
stop,
resetConversation,
}
}

View File

@@ -1,55 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { mapHarnessHistoryPage } from './harness-history-mapper'
describe('mapHarnessHistoryPage', () => {
it('maps rich harness history into chat history items', () => {
const page = mapHarnessHistoryPage({
agentId: 'agent-1',
sessionId: 'main',
items: [
{
id: 'agent:agent-1:main:1',
agentId: 'agent-1',
sessionId: 'main',
role: 'assistant',
text: 'Done.',
createdAt: 1000,
reasoning: { text: 'checking state' },
toolCalls: [
{
toolCallId: 'tool-1',
toolName: 'read_file',
status: 'completed',
input: { path: 'src/index.ts' },
output: 'file contents',
},
],
},
],
})
expect(page.items).toEqual([
{
id: 'agent:agent-1:main:1',
role: 'assistant',
text: 'Done.',
timestamp: 1000,
messageSeq: 1,
sessionKey: 'main',
source: 'user-chat',
reasoning: { text: 'checking state' },
toolCalls: [
{
toolCallId: 'tool-1',
toolName: 'read_file',
label: 'Read file',
subject: 'index.ts',
status: 'completed',
input: { path: 'src/index.ts' },
output: 'file contents',
},
],
},
])
})
})

View File

@@ -1,29 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { fetchHarnessAgentHistory } from '@/entrypoints/app/agents/useAgents'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type { AgentHistoryPageResponse } from './claw-chat-types'
import { mapHarnessHistoryPage } from './harness-history-mapper'
const HISTORY_QUERY_KEY = 'harness-agent-history'
export function useHarnessChatHistory(agentId: string, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentHistoryPageResponse, Error>({
queryKey: [HISTORY_QUERY_KEY, baseUrl, agentId, 'main'],
queryFn: async () => {
return mapHarnessHistoryPage(await fetchHarnessAgentHistory(agentId))
},
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
})
return {
...query,
error: query.error ?? urlError,
isLoading: query.isLoading || urlLoading,
}
}

View File

@@ -1,47 +0,0 @@
import { Bot, Cpu, Sparkles, Wand2 } from 'lucide-react'
import type { FC } from 'react'
import type { HarnessAgentAdapter } from './agent-harness-types'
/**
* Single icon component for any adapter the agent rail can render.
* Falls back to a generic bot when the adapter is unknown so future
* adapters land without a code change at the call site.
*/
interface AdapterIconProps {
adapter: HarnessAgentAdapter | 'unknown'
className?: string
}
export const AdapterIcon: FC<AdapterIconProps> = ({ adapter, className }) => {
switch (adapter) {
case 'claude':
// Claude Code — text-based agent, sparkles to evoke the "AI assistant" feel.
return <Sparkles className={className} aria-label="Claude Code" />
case 'codex':
// Codex — code-leaning, CPU mark.
return <Cpu className={className} aria-label="Codex" />
case 'openclaw':
// OpenClaw — bot/automation framing.
return <Bot className={className} aria-label="OpenClaw" />
case 'hermes':
// Hermes — messenger god framing, wand evokes the agentic conjuring.
return <Wand2 className={className} aria-label="Hermes" />
default:
return <Bot className={className} aria-label="Agent" />
}
}
export function adapterLabel(adapter: HarnessAgentAdapter | 'unknown'): string {
switch (adapter) {
case 'claude':
return 'Claude Code'
case 'codex':
return 'Codex'
case 'openclaw':
return 'OpenClaw'
case 'hermes':
return 'Hermes'
default:
return 'Agent'
}
}

View File

@@ -0,0 +1,399 @@
import {
ArrowLeft,
Bot,
CheckCircle2,
Loader2,
Send,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import {
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from './useOpenClaw'
interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
type AssistantPart =
| { kind: 'thinking'; text: string; done: boolean }
| { kind: 'tool-batch'; tools: ToolEntry[] }
| { kind: 'text'; text: string }
interface ChatTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
}
interface AgentChatProps {
agentId: string
agentName: string
onBack: () => void
}
export const AgentChat: FC<AgentChatProps> = ({
agentId,
agentName,
onBack,
}) => {
const [turns, setTurns] = useState<ChatTurn[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sessionKeyRef = useRef(crypto.randomUUID())
const streamAbortRef = useRef<AbortController | null>(null)
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const scrollToBottom = () => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
useEffect(() => {
scrollToBottom()
}, [turns])
useEffect(() => {
return () => {
streamAbortRef.current?.abort()
}
}, [])
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool: ToolEntry = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running',
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const status =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId
? {
...t,
status: status as ToolEntry['status'],
durationMs,
}
: t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: ChatTurn = {
id: crypto.randomUUID(),
userText: text,
parts: [],
done: false,
}
setTurns((prev) => [...prev, turn])
setInput('')
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text,
sessionKeyRef.current,
history,
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
setStreaming(false)
}
}
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<h2 className="font-semibold text-lg">{agentName}</h2>
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.map((turn) => (
<div key={turn.id} className="space-y-3">
{/* User message */}
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{/* Assistant response — all parts grouped */}
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">
{tool.name}
</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return (
<MessageResponse key={key}>
{part.text}
</MessageResponse>
)
default:
return null
}
})}
</MessageContent>
</Message>
)}
{/* Streaming indicator when waiting for first part */}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
))}
</div>
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="Send a message..."
className="min-h-[44px] resize-none"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || streaming}
size="icon"
>
{streaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,165 +0,0 @@
import { Loader2 } from 'lucide-react'
import { type FC, useMemo } from 'react'
import { AgentRowCard } from './AgentRowCard'
import { AgentsEmptyState } from './AgentsEmptyState'
import type {
HarnessAdapterDescriptor,
HarnessAgent,
HarnessAgentAdapter,
} from './agent-harness-types'
import type {
AgentAdapterHealth,
AgentRowData,
} from './agent-row/agent-row.types'
import { compareAgentsByPinThenRecency } from './agents-list-order'
import type { AgentListItem } from './agents-page-types'
import type { AgentLiveness } from './LivenessDot'
interface AgentListProps {
agents: AgentListItem[]
/** Optional per-agent activity metadata, keyed by `agentId`. */
activity?: Record<
string,
{ status: AgentLiveness; lastUsedAt: number | null }
>
/** Lookup table from harness id → enriched agent record. */
harnessAgentLookup?: Map<string, HarnessAgent>
/** Adapter catalog (carries per-adapter health). */
adapters: HarnessAdapterDescriptor[]
loading: boolean
deletingAgentKey: string | null
onCreateAgent: () => void
onDeleteAgent: (agent: AgentListItem) => void
onPinToggle: (agent: AgentListItem, next: boolean) => void
}
export const AgentList: FC<AgentListProps> = ({
agents,
activity,
harnessAgentLookup,
adapters,
loading,
deletingAgentKey,
onCreateAgent,
onDeleteAgent,
onPinToggle,
}) => {
const adapterHealth = useMemo(() => {
const map = new Map<HarnessAgentAdapter, AgentAdapterHealth>()
for (const adapter of adapters) {
if (adapter.health) {
map.set(adapter.id, {
healthy: adapter.health.healthy,
reason: adapter.health.reason,
})
}
}
return map
}, [adapters])
const ordered = useMemo(() => {
const withMeta = agents.map((agent) => {
const harness = harnessAgentLookup?.get(agent.agentId)
return {
agent,
id: agent.agentId,
pinned: harness?.pinned ?? false,
lastUsedAt: activity?.[agent.agentId]?.lastUsedAt ?? null,
}
})
return withMeta
.sort(compareAgentsByPinThenRecency)
.map((entry) => entry.agent)
}, [activity, agents, harnessAgentLookup])
if (loading && agents.length === 0) {
return (
<div className="flex h-36 items-center justify-center rounded-xl border border-border border-dashed bg-card/50">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (agents.length === 0) {
return <AgentsEmptyState onCreateAgent={onCreateAgent} />
}
return (
<div className="grid gap-3">
{ordered.map((agent) => {
const harness = harnessAgentLookup?.get(agent.agentId)
const adapter: HarnessAgentAdapter | 'unknown' =
harness?.adapter ?? inferAdapterFromLabel(agent.runtimeLabel)
const data = buildRowData({
agent,
adapter,
harness,
activity: activity?.[agent.agentId],
adapterHealth:
adapterHealth.get(adapter as HarnessAgentAdapter) ?? null,
})
return (
<AgentRowCard
key={agent.key}
data={data}
deleting={deletingAgentKey === agent.key}
onDelete={onDeleteAgent}
onPinToggle={onPinToggle}
/>
)
})}
</div>
)
}
function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
const lower = label?.toLowerCase()
if (lower === 'claude code') return 'claude'
if (lower === 'codex') return 'codex'
if (lower === 'openclaw') return 'openclaw'
if (lower === 'hermes') return 'hermes'
return 'unknown'
}
const ZERO_BUCKETS = (): number[] => Array.from({ length: 14 }, () => 0)
function buildRowData(input: {
agent: AgentListItem
adapter: HarnessAgentAdapter | 'unknown'
harness: HarnessAgent | undefined
activity: { status: AgentLiveness; lastUsedAt: number | null } | undefined
adapterHealth: AgentAdapterHealth | null
}): AgentRowData {
const { agent, adapter, harness, activity, adapterHealth } = input
return {
agent,
adapter,
modelLabel: deriveModelLabel(agent, harness),
reasoningEffort: harness?.reasoningEffort ?? null,
status: activity?.status ?? 'unknown',
lastUsedAt: activity?.lastUsedAt ?? harness?.lastUsedAt ?? null,
pinned: harness?.pinned ?? false,
cwd: harness?.cwd ?? null,
lastUserMessage: harness?.lastUserMessage ?? null,
tokens: harness?.tokens ?? null,
turnsByDay: harness?.turnsByDay ?? ZERO_BUCKETS(),
failedByDay: harness?.failedByDay ?? ZERO_BUCKETS(),
lastError: harness?.lastError ?? null,
lastErrorAt: harness?.lastErrorAt ?? null,
activeTurnId: harness?.activeTurnId ?? null,
adapterHealth,
}
}
function deriveModelLabel(
agent: AgentListItem,
harness: HarnessAgent | undefined,
): string | null {
// Prefer the agent rail's modelLabel when meaningful; harness's
// modelId is a stable identifier but the rail's `modelLabel`
// already maps to a friendly display string.
if (agent.modelLabel && agent.modelLabel !== 'default') {
return agent.modelLabel
}
return harness?.modelId ?? null
}

View File

@@ -1,99 +0,0 @@
import type { FC } from 'react'
import { cn } from '@/lib/utils'
import { AgentActions } from './agent-row/AgentActions'
import { AgentErrorPanel } from './agent-row/AgentErrorPanel'
import { AgentLastMessage } from './agent-row/AgentLastMessage'
import { AgentMetaRow } from './agent-row/AgentMetaRow'
import { AgentSummaryChips } from './agent-row/AgentSummaryChips'
import { AgentTile } from './agent-row/AgentTile'
import { AgentTitleRow } from './agent-row/AgentTitleRow'
import type {
AgentRowCallbacks,
AgentRowData,
} from './agent-row/agent-row.types'
interface AgentRowCardProps extends AgentRowCallbacks {
data: AgentRowData
/** Whether THIS agent is mid-delete; renders a spinner in the menu. */
deleting?: boolean
}
/**
* Composition shell for the agent rail. Owns no state; sub-components
* each handle their own micro-state (error-panel collapse, etc.) and
* emit callbacks (delete, pin/unpin) for the page to act on.
*
* The whole card carries state — not just the tile — so the row's
* border subtly tells the user what's going on at a glance:
* working → accent-orange border with a soft glow
* error → destructive border
* idle → muted border, lifts on hover
*/
export const AgentRowCard: FC<AgentRowCardProps> = ({
data,
deleting,
onDelete,
onPinToggle,
}) => {
return (
<div
className={cn(
// Layout-stable hover. No translate, no shadow change — both
// visibly perturb neighbouring rows. Only the border tint
// shifts on hover, and the rail's vertical rhythm stays
// exactly the same in every state.
'group rounded-xl border bg-card p-4 shadow-sm transition-colors',
data.status === 'working'
? 'border-[var(--accent-orange)]/40'
: data.status === 'error'
? 'border-destructive/40'
: 'border-border hover:border-[var(--accent-orange)]/30',
)}
>
<div className="flex items-start gap-4">
<AgentTile
adapter={data.adapter}
status={data.status}
lastUsedAt={data.lastUsedAt}
/>
<div className="min-w-0 flex-1">
<AgentTitleRow
agent={data.agent}
status={data.status}
pinned={data.pinned}
turnsByDay={data.turnsByDay}
failedByDay={data.failedByDay}
onPinToggle={(next) => onPinToggle(data.agent, next)}
/>
<AgentSummaryChips
adapter={data.adapter}
modelLabel={data.modelLabel}
reasoningEffort={data.reasoningEffort}
adapterHealth={data.adapterHealth}
/>
<AgentLastMessage message={data.lastUserMessage} />
<AgentMetaRow lastUsedAt={data.lastUsedAt} tokens={data.tokens} />
{data.status === 'error' && data.lastError && (
<AgentErrorPanel
agentId={data.agent.agentId}
message={data.lastError}
errorAt={data.lastErrorAt}
/>
)}
</div>
<AgentActions
agent={data.agent}
activeTurnId={data.activeTurnId}
deleting={deleting}
onDelete={onDelete}
/>
</div>
</div>
)
}

View File

@@ -5,16 +5,14 @@ import {
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Terminal } from '@xterm/xterm'
import { ArrowLeft, Check, Copy } from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import { ArrowLeft } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import '@xterm/xterm/css/xterm.css'
import { Button } from '@/components/ui/button'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface AgentTerminalProps {
onBack: () => void
initialCommand?: string
onSessionExit?: () => void
}
type TerminalServerMessage =
@@ -38,22 +36,26 @@ function resolveCssColor(variableName: string): string {
return color
}
function withAlpha(color: string, alpha: number): string {
const channels = color.match(/[\d.]+/g)
if (!channels || channels.length < 3) return color
const [red, green, blue] = channels
return `rgb(${red} ${green} ${blue} / ${alpha})`
}
function createTerminalTheme() {
const isDark = document.documentElement.classList.contains('dark')
const background = resolveCssColor('--background')
const foreground = resolveCssColor('--foreground')
const muted = resolveCssColor('--muted-foreground')
const accent = resolveCssColor('--accent-orange')
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
// Solid terminal-standard selection colors. Deriving from a CSS var
// with alpha composed against the background produced near-white
// rectangles on light mode, making selection invisible.
selectionBackground: isDark ? '#3a4463' : '#b4d4f4',
selectionInactiveBackground: isDark ? '#2b3348' : '#d9e5f3',
selectionBackground: withAlpha(accent, isDark ? 0.3 : 0.2),
selectionForeground: foreground,
black: isDark ? '#16131a' : '#1f1b22',
red: isDark ? '#ef8c7c' : '#c25544',
@@ -116,38 +118,8 @@ function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
return null
}
export const AgentTerminal: FC<AgentTerminalProps> = ({
onBack,
initialCommand,
onSessionExit,
}) => {
export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
// Refs keep the mount-once effect from tearing down the PTY when the
// parent re-renders with new inline callbacks.
const initialCommandRef = useRef(initialCommand)
const onSessionExitRef = useRef(onSessionExit)
initialCommandRef.current = initialCommand
onSessionExitRef.current = onSessionExit
const [copied, setCopied] = useState(false)
// Copy the current xterm selection to the browser clipboard. No-op
// if nothing is selected — users who want the whole buffer can
// Cmd+A first. Uses the browser clipboard, not the container's, so
// it works even when the running TUI has mouse tracking enabled
// (Opt+drag forces a selection regardless, see terminal config).
const handleCopy = async (): Promise<void> => {
const text = terminalRef.current?.getSelection()
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard permission denied or unavailable — swallow, user will retry
}
}
useEffect(() => {
if (!containerRef.current) return
@@ -160,34 +132,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
lineHeight: 1.25,
scrollback: 8000,
theme: createTerminalTheme(),
// Opt+click+drag forces a native text selection even when the
// running TUI has mouse-tracking enabled (xterm would otherwise
// forward every click to the app and selection wouldn't work).
macOptionClickForcesSelection: true,
})
terminalRef.current = terminal
// Cmd+A → select all, Cmd+C → copy selection via the browser
// clipboard. Return false so xterm doesn't also forward the keys
// to the running program.
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== 'keydown') return true
const isMac = navigator.platform.toUpperCase().includes('MAC')
const mod = isMac ? event.metaKey : event.ctrlKey
if (!mod) return true
const key = event.key.toLowerCase()
if (key === 'a') {
terminal.selectAll()
return false
}
if (key === 'c') {
const sel = terminal.getSelection()
if (sel) {
void navigator.clipboard.writeText(sel)
return false
}
}
return true
})
const fitAddon = new FitAddon()
@@ -195,12 +139,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
terminal.loadAddon(new WebLinksAddon())
terminal.open(containerRef.current)
// React 18 StrictMode double-invokes effects in dev. Everything
// async inside this effect is scoped to an AbortController; the
// cleanup aborts it and any pending awaits bail out, so we never
// leak a second live WebSocket or duplicate xterm listeners.
const ac = new AbortController()
const cleanups: Array<() => void> = []
let ws: WebSocket | null = null
let sawExit = false
@@ -221,28 +159,17 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
sendMessage({ type: 'resize', cols, rows })
}
const connect = async (): Promise<void> => {
const connect = async () => {
const baseUrl = await getAgentServerUrl()
if (ac.signal.aborted) return
const wsUrl = new URL('/terminal/ws', baseUrl)
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(wsUrl)
// If the effect was cleaned up between the await above and now,
// close the socket we just opened and bail.
if (ac.signal.aborted) {
ws.close()
ws = null
return
}
cleanups.push(() => ws?.close())
ws.onopen = () => {
fitAddon.fit()
terminal.focus()
sendResize()
const cmd = initialCommandRef.current
if (cmd) sendMessage({ type: 'input', data: `${cmd}\n` })
}
ws.onmessage = (event) => {
@@ -258,7 +185,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
terminal.write(
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
)
onSessionExitRef.current?.()
}
}
@@ -274,41 +200,49 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
const inputDisposable = terminal.onData((data) => {
sendMessage({ type: 'input', data })
})
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
sendResize(cols, rows)
})
cleanups.push(() => inputDisposable.dispose())
cleanups.push(() => resizeDisposable.dispose())
return () => {
inputDisposable.dispose()
resizeDisposable.dispose()
}
}
void connect()
let disposeSocketBindings: (() => void) | undefined
void connect().then((disposeBindings) => {
disposeSocketBindings = disposeBindings
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
sendResize()
})
resizeObserver.observe(containerRef.current)
cleanups.push(() => resizeObserver.disconnect())
const themeObserver = new MutationObserver(() => applyTheme())
const themeObserver = new MutationObserver(() => {
applyTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
cleanups.push(() => themeObserver.disconnect())
return () => {
ac.abort()
for (const dispose of cleanups) dispose()
resizeObserver.disconnect()
themeObserver.disconnect()
disposeSocketBindings?.()
ws?.close()
terminal.dispose()
terminalRef.current = null
}
}, [])
return (
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-3 sm:px-6">
<div className="flex items-center gap-3 border-border border-b px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
@@ -322,14 +256,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
</div>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? (
<Check className="mr-1 size-3.5" />
) : (
<Copy className="mr-1 size-3.5" />
)}
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<div className="min-h-0 flex-1 p-4 sm:p-6">
@@ -343,7 +269,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
</div>
</div>
<div className="min-h-0 flex-1 cursor-text px-4 py-4 sm:px-5 sm:py-5">
<div className="min-h-0 flex-1 px-4 py-4 sm:px-5 sm:py-5">
<div ref={containerRef} className="h-full w-full" />
</div>
</div>

View File

@@ -1,32 +0,0 @@
import { Bot, Plus } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
interface AgentsEmptyStateProps {
onCreateAgent: () => void
}
export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
onCreateAgent,
}) => {
return (
<div className="rounded-xl border border-border border-dashed bg-card/50 p-12 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Bot className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<h3 className="mb-1 font-semibold">No agents yet</h3>
<p className="mx-auto mb-4 max-w-sm text-muted-foreground text-sm">
Spin up an OpenClaw, Claude Code, or Codex agent to chat with, schedule,
or run in the background.
</p>
<Button
onClick={onCreateAgent}
variant="outline"
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
>
<Plus className="mr-1.5 h-4 w-4" />
Create your first agent
</Button>
</div>
)
}

View File

@@ -1,41 +0,0 @@
import { Bot, Plus } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
interface AgentsHeaderProps {
onCreateAgent: () => void
}
/**
* Mirrors the visual shape of `SoulHeader` and `ScheduledTasksHeader`
* so the page reads as part of the same family. Loose lifecycle
* controls that used to sit next to the title moved into
* `GatewayStatusBar` — they're OpenClaw-specific and don't apply to
* Claude/Codex agents.
*/
export const AgentsHeader: FC<AgentsHeaderProps> = ({ onCreateAgent }) => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Bot className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<h2 className="mb-1 font-semibold text-xl">Agents</h2>
<p className="text-muted-foreground text-sm">
OpenClaw, Claude Code, and Codex agents chat, schedule, and run
them in the background.
</p>
</div>
<Button
onClick={onCreateAgent}
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
variant="outline"
>
<Plus className="mr-1.5 h-4 w-4" />
New Agent
</Button>
</div>
</div>
)
}

View File

@@ -1,206 +0,0 @@
import { Loader2, RotateCcw, Terminal } from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { OpenClawStatus } from './useOpenClaw'
interface GatewayStatusBarProps {
status: OpenClawStatus | null
/** Disabled while a gateway lifecycle mutation is mid-flight. */
actionInProgress: boolean
onOpenTerminal: () => void
onRestart: () => void
}
/**
* Compact one-line status bar for the OpenClaw gateway. Renders the
* lifecycle pills (Running / Control plane connected) plus a Terminal
* escape hatch and a Restart Gateway action. Lives between the page
* header and the agent list when at least one OpenClaw agent is in
* the merged list; collapses to nothing for Claude/Codex-only setups.
*
* Status is sourced from `GET /agents`'s `gateway` field — the agents
* page no longer polls `/claw/status` directly. One endpoint, one
* 5s interval, no duplicate state.
*/
export const GatewayStatusBar: FC<GatewayStatusBarProps> = ({
status,
actionInProgress,
onOpenTerminal,
onRestart,
}) => {
if (!status) return null
const runningPill = pillForRuntimeStatus(status.status)
const controlPlanePill = pillForControlPlane(status.controlPlaneStatus)
return (
<div className="rounded-xl border border-border bg-card px-4 py-3 shadow-sm">
<div className="flex items-center gap-3 text-sm">
<span className="font-medium text-muted-foreground">
OpenClaw gateway
</span>
<Badge
variant={runningPill.variant}
className={cn('gap-1.5', runningPill.className)}
>
<span
className={cn(
'inline-block h-1.5 w-1.5 rounded-full',
runningPill.dot,
)}
/>
{runningPill.label}
</Badge>
<Badge
variant={controlPlanePill.variant}
className={cn('gap-1.5', controlPlanePill.className)}
>
<span
className={cn(
'inline-block h-1.5 w-1.5 rounded-full',
controlPlanePill.dot,
)}
/>
{controlPlanePill.label}
</Badge>
<Separator orientation="vertical" className="h-4" />
<WithTooltip label="Open a shell into the OpenClaw gateway container for raw CLI access (config edits, session inspection).">
<Button variant="ghost" size="sm" onClick={onOpenTerminal}>
<Terminal className="mr-1.5 h-3.5 w-3.5" />
Terminal
</Button>
</WithTooltip>
<WithTooltip label="Restart the OpenClaw gateway. Useful when the gateway is stuck or after editing provider config.">
<Button
variant="ghost"
size="sm"
onClick={onRestart}
disabled={actionInProgress}
className="ml-auto"
>
{actionInProgress ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
)}
Restart Gateway
</Button>
</WithTooltip>
</div>
</div>
)
}
const WithTooltip: FC<{ label: string; children: ReactNode }> = ({
label,
children,
}) => (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-xs">
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
type PillKind = {
variant: 'default' | 'secondary' | 'outline' | 'destructive'
label: string
dot: string
className?: string
}
function pillForRuntimeStatus(status: OpenClawStatus['status']): PillKind {
switch (status) {
case 'running':
return {
variant: 'secondary',
label: 'Running',
dot: 'bg-emerald-500',
className: 'bg-emerald-50 text-emerald-900 hover:bg-emerald-50',
}
case 'starting':
return {
variant: 'secondary',
label: 'Starting',
dot: 'bg-amber-500 animate-pulse',
className: 'bg-amber-50 text-amber-900 hover:bg-amber-50',
}
case 'stopped':
return {
variant: 'outline',
label: 'Stopped',
dot: 'bg-muted-foreground/40',
}
case 'error':
return {
variant: 'destructive',
label: 'Error',
dot: 'bg-destructive-foreground',
}
default:
return {
variant: 'outline',
label: 'Unknown',
dot: 'bg-muted-foreground/40',
}
}
}
function pillForControlPlane(
status: OpenClawStatus['controlPlaneStatus'],
): PillKind {
switch (status) {
case 'connected':
return {
variant: 'secondary',
label: 'Control plane connected',
dot: 'bg-emerald-500',
className: 'bg-emerald-50 text-emerald-900 hover:bg-emerald-50',
}
case 'connecting':
return {
variant: 'secondary',
label: 'Connecting',
dot: 'bg-amber-500 animate-pulse',
className: 'bg-amber-50 text-amber-900 hover:bg-amber-50',
}
case 'reconnecting':
return {
variant: 'secondary',
label: 'Reconnecting',
dot: 'bg-amber-500 animate-pulse',
className: 'bg-amber-50 text-amber-900 hover:bg-amber-50',
}
case 'recovering':
return {
variant: 'secondary',
label: 'Recovering',
dot: 'bg-amber-500 animate-pulse',
className: 'bg-amber-50 text-amber-900 hover:bg-amber-50',
}
case 'failed':
return {
variant: 'destructive',
label: 'Needs attention',
dot: 'bg-destructive-foreground',
}
default:
return {
variant: 'outline',
label: 'Disconnected',
dot: 'bg-muted-foreground/40',
}
}
}

View File

@@ -1,83 +0,0 @@
import type { FC } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export type AgentLiveness = 'working' | 'idle' | 'asleep' | 'error' | 'unknown'
interface LivenessDotProps {
status: AgentLiveness
/**
* Optional human-friendly secondary line, e.g. "Idle for 4 min" or
* "Asleep — no activity for 22 min". When absent the tooltip just
* reads the status label.
*/
detail?: string
className?: string
}
const VARIANT: Record<
AgentLiveness,
{ dot: string; ring: string; label: string }
> = {
working: {
// Animated amber pulse + soft halo so the eye catches an active
// agent in a long list without the dot screaming for attention.
dot: 'bg-amber-500 animate-pulse',
ring: 'ring-2 ring-amber-200',
label: 'Working on a turn',
},
idle: {
dot: 'bg-emerald-500',
ring: 'ring-2 ring-emerald-100',
label: 'Idle',
},
asleep: {
dot: 'bg-muted-foreground/40',
ring: 'ring-2 ring-muted',
label: 'Asleep',
},
error: {
dot: 'bg-destructive',
ring: 'ring-2 ring-destructive/30',
label: 'Attention',
},
unknown: {
dot: 'bg-muted-foreground/30',
ring: 'ring-2 ring-muted',
label: 'Status unknown',
},
}
export const LivenessDot: FC<LivenessDotProps> = ({
status,
detail,
className,
}) => {
const variant = VARIANT[status]
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label={detail ?? variant.label}
className={cn(
'inline-block h-3 w-3 rounded-full',
variant.dot,
variant.ring,
className,
)}
/>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
{detail ?? variant.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -1,282 +0,0 @@
import { AlertCircle, Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type {
HarnessAdapterDescriptor,
HarnessAgentAdapter,
} from './agent-harness-types'
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
import { ProviderSelector } from './OpenClawControls'
import {
type OpenClawCliProvider,
type OpenClawCliProviderAuthStatus,
OpenClawCliProviderStatusPanel,
} from './openclaw-cli-providers'
interface NewAgentDialogProps {
adapters: HarnessAdapterDescriptor[]
canManageOpenClaw: boolean
createError: string | null
createRuntime: CreateAgentRuntime
creating: boolean
defaultProviderId: string
harnessAdapterId: HarnessAgentAdapter
harnessModelId: string
harnessReasoningEffort: string
hermesProviders: ProviderOption[]
hermesSelectedProviderId: string
name: string
open: boolean
providers: ProviderOption[]
selectedCliProvider: OpenClawCliProvider | undefined
selectedProviderId: string
cliAuthError: Error | null
cliAuthLoading: boolean
cliAuthStatus: OpenClawCliProviderAuthStatus | undefined
onConnectCliProvider: () => void
onCreate: () => void
onOpenChange: (open: boolean) => void
onRuntimeChange: (runtime: CreateAgentRuntime) => void
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
onHarnessModelChange: (modelId: string) => void
onHarnessReasoningChange: (reasoningEffort: string) => void
onHermesProviderChange: (providerId: string) => void
onNameChange: (name: string) => void
onProviderChange: (providerId: string) => void
}
export const NewAgentDialog: FC<NewAgentDialogProps> = ({
adapters,
canManageOpenClaw,
createError,
createRuntime,
creating,
defaultProviderId,
harnessAdapterId,
harnessModelId,
harnessReasoningEffort,
hermesProviders,
hermesSelectedProviderId,
name,
open,
providers,
selectedCliProvider,
selectedProviderId,
cliAuthError,
cliAuthLoading,
cliAuthStatus,
onConnectCliProvider,
onCreate,
onOpenChange,
onRuntimeChange,
onHarnessAdapterChange,
onHarnessModelChange,
onHarnessReasoningChange,
onHermesProviderChange,
onNameChange,
onProviderChange,
}) => {
const selectedHarnessAdapter =
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
const isHarnessRuntime = createRuntime !== 'openclaw'
const isHermesRuntime = createRuntime === 'hermes'
const isClassicHarnessRuntime = isHarnessRuntime && !isHermesRuntime
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
const cliBlocked =
createRuntime === 'openclaw' &&
!!selectedCliProvider &&
!cliAuthStatus?.loggedIn
const hermesBlocked =
isHermesRuntime &&
(hermesProviders.length === 0 || !hermesSelectedProviderId)
const canCreate =
Boolean(name.trim()) &&
!creating &&
!openClawBlocked &&
!cliBlocked &&
!hermesBlocked &&
(createRuntime === 'openclaw'
? providers.length > 0
: Boolean(selectedHarnessAdapter))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Agent</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
{createError ? (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Create failed</AlertTitle>
<AlertDescription>{createError}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-2">
<Label htmlFor="agent-name">Name</Label>
<Input
id="agent-name"
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder={
createRuntime === 'openclaw' ? 'research-agent' : 'Review bot'
}
onKeyDown={(event) => {
if (event.key === 'Enter' && canCreate) onCreate()
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="agent-runtime">Adapter</Label>
<Select
value={createRuntime}
onValueChange={(value) => {
if (
value === 'openclaw' ||
value === 'claude' ||
value === 'codex' ||
value === 'hermes'
) {
onRuntimeChange(value)
if (value !== 'openclaw') onHarnessAdapterChange(value)
}
}}
>
<SelectTrigger id="agent-runtime">
<SelectValue />
</SelectTrigger>
<SelectContent>
{adapters.map((adapter) => (
<SelectItem key={adapter.id} value={adapter.id}>
{adapter.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createRuntime === 'openclaw' ? (
<>
{openClawBlocked ? (
<Alert>
<AlertCircle className="size-4" />
<AlertTitle>OpenClaw is not ready</AlertTitle>
<AlertDescription>
Start or set up the OpenClaw gateway before creating an
OpenClaw agent.
</AlertDescription>
</Alert>
) : null}
<ProviderSelector
providers={providers}
defaultProviderId={defaultProviderId}
selectedId={selectedProviderId}
onSelect={onProviderChange}
hideApiKeyHint={!!selectedCliProvider}
/>
{selectedCliProvider ? (
<OpenClawCliProviderStatusPanel
provider={selectedCliProvider}
status={cliAuthStatus}
loading={cliAuthLoading}
fetchError={cliAuthError}
onConnect={onConnectCliProvider}
/>
) : null}
</>
) : null}
{isHermesRuntime ? (
<ProviderSelector
providers={hermesProviders}
defaultProviderId={defaultProviderId}
selectedId={hermesSelectedProviderId}
onSelect={onHermesProviderChange}
/>
) : null}
{isClassicHarnessRuntime ? (
<>
<div className="grid gap-2">
<Label htmlFor="harness-model">Model</Label>
<Select
value={harnessModelId}
onValueChange={onHarnessModelChange}
>
<SelectTrigger id="harness-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(selectedHarnessAdapter?.models ?? []).map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="harness-effort">Reasoning</Label>
<Select
value={harnessReasoningEffort}
onValueChange={onHarnessReasoningChange}
>
<SelectTrigger id="harness-effort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(selectedHarnessAdapter?.reasoningEfforts ?? []).map(
(effort) => (
<SelectItem key={effort.id} value={effort.id}>
{effort.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
</>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={creating}
>
Cancel
</Button>
<Button disabled={!canCreate} onClick={onCreate}>
{creating ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,387 +0,0 @@
import {
AlertCircle,
Cpu,
Loader2,
Plus,
RefreshCw,
ShieldAlert,
Square,
TerminalSquare,
WifiOff,
Wrench,
} from 'lucide-react'
import type { FC } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ProviderOption } from './agents-page-types'
import {
CONTROL_PLANE_COPY,
FALLBACK_CONTROL_PLANE_COPY,
} from './agents-page-types'
import type { getControlPlaneCopy } from './agents-page-utils'
import type { OpenClawStatus } from './useOpenClaw'
const StatusBadge: FC<{ status: OpenClawStatus['status'] }> = ({ status }) => {
const variants: Record<
OpenClawStatus['status'],
{
variant: 'default' | 'secondary' | 'outline' | 'destructive'
label: string
}
> = {
running: { variant: 'default', label: 'Running' },
starting: { variant: 'secondary', label: 'Starting...' },
stopped: { variant: 'outline', label: 'Stopped' },
error: { variant: 'destructive', label: 'Error' },
uninitialized: { variant: 'outline', label: 'Not Set Up' },
}
const current = variants[status] ?? {
variant: 'outline' as const,
label: 'Unknown',
}
return <Badge variant={current.variant}>{current.label}</Badge>
}
const ControlPlaneBadge: FC<{
status: OpenClawStatus['controlPlaneStatus']
}> = ({ status }) => {
const current = CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
return <Badge variant={current.badgeVariant}>{current.badgeLabel}</Badge>
}
interface ProviderSelectorProps {
providers: ProviderOption[]
defaultProviderId: string
selectedId: string
onSelect: (id: string) => void
hideApiKeyHint?: boolean
}
export const ProviderSelector: FC<ProviderSelectorProps> = ({
providers,
defaultProviderId,
selectedId,
onSelect,
hideApiKeyHint,
}) => {
if (providers.length === 0) {
return (
<div className="space-y-2">
<p className="font-medium text-sm">LLM Provider</p>
<p className="text-muted-foreground text-sm">
No compatible LLM providers configured.{' '}
<a href="#/settings/ai" className="underline">
Add one in AI settings
</a>{' '}
first.
</p>
</div>
)
}
return (
<div className="space-y-2">
<Label htmlFor="provider-select">LLM Provider</Label>
<Select value={selectedId} onValueChange={onSelect}>
<SelectTrigger id="provider-select">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name} - {provider.modelId}
{provider.id === defaultProviderId ? ' (default)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{!hideApiKeyHint && (
<p className="text-muted-foreground text-xs">
Uses your existing API key from BrowserOS settings. The key is passed
to the container and never leaves your machine.
</p>
)}
</div>
)
}
interface AgentsPageHeaderProps {
actionInProgress: boolean
controlPlaneBusy: boolean
reconnecting: boolean
status: OpenClawStatus | null
onCreateAgent: () => void
onOpenTerminal: () => void
onReconnect: () => void
onRefresh: () => void
onRestart: () => void
onStop: () => void
}
export const AgentsPageHeader: FC<AgentsPageHeaderProps> = ({
actionInProgress,
controlPlaneBusy,
reconnecting,
status,
onCreateAgent,
onOpenTerminal,
onReconnect,
onRefresh,
onRestart,
onStop,
}) => (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-semibold text-2xl tracking-normal">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw, Claude Code, and Codex agents
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{status ? (
<>
<StatusBadge status={status.status} />
{status.status !== 'uninitialized' && (
<ControlPlaneBadge status={status.controlPlaneStatus} />
)}
</>
) : null}
{status?.status === 'running' &&
status.controlPlaneStatus !== 'connected' ? (
<Button
variant="outline"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
) : null}
{status?.status === 'running' ? (
<>
<Button
variant="ghost"
size="icon"
onClick={onRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button variant="outline" onClick={onOpenTerminal}>
<TerminalSquare className="mr-2 size-4" />
Terminal
</Button>
</>
) : null}
<Button variant="ghost" size="icon" onClick={onRefresh} title="Refresh">
<RefreshCw className="size-4" />
</Button>
<Button onClick={onCreateAgent}>
<Plus className="mr-2 size-4" />
New Agent
</Button>
</div>
</div>
)
export function LifecycleAlert({ message }: { message: string }) {
return (
<Alert>
<Loader2 className="size-4 animate-spin" />
<AlertTitle>{message}</AlertTitle>
</Alert>
)
}
export function InlineErrorAlert({
message,
onDismiss,
}: {
message: string
onDismiss: () => void
}) {
return (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Agent action failed</AlertTitle>
<AlertDescription>
<p>{message}</p>
<div className="mt-2">
<Button variant="outline" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
)
}
interface ControlPlaneAlertProps {
actionInProgress: boolean
controlPlaneBusy: boolean
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
reconnecting: boolean
recoveryDetail: string | null
status: OpenClawStatus
onReconnect: () => void
onRestart: () => void
}
export const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
actionInProgress,
controlPlaneBusy,
controlPlaneCopy,
reconnecting,
recoveryDetail,
status,
onReconnect,
onRestart,
}) => (
<Alert
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
>
{status.controlPlaneStatus === 'failed' ? (
<ShieldAlert className="size-4" />
) : status.controlPlaneStatus === 'recovering' ? (
<Wrench className="size-4" />
) : (
<WifiOff className="size-4" />
)}
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
<AlertDescription>
<p>{controlPlaneCopy.description}</p>
{recoveryDetail ? <p>{recoveryDetail}</p> : null}
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</AlertDescription>
</Alert>
)
interface GatewayStateCardsProps {
actionInProgress: boolean
status: OpenClawStatus | null
onOpenSetup: () => void
onRestart: () => void
onStart: () => void
}
export const GatewayStateCards: FC<GatewayStateCardsProps> = ({
actionInProgress,
status,
onOpenSetup,
onRestart,
onStart,
}) => (
<>
{status?.status === 'uninitialized' ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
: 'BrowserOS VM runtime is unavailable on this system.'}
</p>
</div>
{status.podmanAvailable ? (
<Button onClick={onOpenSetup}>Set Up Now</Button>
) : null}
</CardContent>
</Card>
) : null}
{status?.status === 'stopped' ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
</CardContent>
</Card>
) : null}
{status?.status === 'error' ? (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">
{status.error ?? status.lastGatewayError}
</p>
</div>
<div className="flex gap-2">
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
<Button
variant="outline"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</CardContent>
</Card>
) : null}
</>
)

View File

@@ -1,76 +0,0 @@
import { Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { ProviderOption } from './agents-page-types'
import { ProviderSelector } from './OpenClawControls'
import type { OpenClawCliProvider } from './openclaw-cli-providers'
interface SetupOpenClawDialogProps {
defaultProviderId: string
open: boolean
providers: ProviderOption[]
selectedProviderId: string
selectedCliProvider: OpenClawCliProvider | undefined
settingUp: boolean
onOpenChange: (open: boolean) => void
onProviderChange: (providerId: string) => void
onSetup: () => void
}
export const SetupOpenClawDialog: FC<SetupOpenClawDialogProps> = ({
defaultProviderId,
open,
providers,
selectedProviderId,
selectedCliProvider,
settingUp,
onOpenChange,
onProviderChange,
onSetup,
}) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Up OpenClaw</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<ProviderSelector
providers={providers}
defaultProviderId={defaultProviderId}
selectedId={selectedProviderId}
onSelect={onProviderChange}
hideApiKeyHint={!!selectedCliProvider}
/>
{selectedCliProvider ? (
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 text-muted-foreground text-xs">
{selectedCliProvider.description}. Clicking{' '}
<span className="font-medium">Set Up &amp; Start</span> starts the
gateway and opens a terminal to sign in.
</p>
) : null}
<Button
onClick={onSetup}
disabled={settingUp || providers.length === 0}
className="w-full"
>
{settingUp ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Setting up...
</>
) : (
'Set Up & Start'
)}
</Button>
</div>
</DialogContent>
</Dialog>
)

View File

@@ -1,4 +0,0 @@
export function buildAgentApiUrl(baseUrl: string, path: string): string {
const normalizedPath = path === '/' ? '' : path
return `${baseUrl}/agents${normalizedPath}`
}

View File

@@ -1,107 +0,0 @@
import type { AgentListItem } from './agents-page-types'
import type { AgentLiveness } from './LivenessDot'
/**
* Display rules for the redesigned agent rows. Pure helpers — no React,
* no API calls — so they're trivial to unit-test and the row card stays
* focused on layout.
*/
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const OC_UUID_PATTERN =
/^oc-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* The agent rail used to render whatever the gateway returned for `name`.
* Post-migration that's frequently the agent's UUID — readable to nobody.
* Prefer the explicit `name` when it differs meaningfully from the id;
* otherwise fall back to a short prefix users can recognize on second
* glance.
*/
export function displayName(agent: AgentListItem): string {
const name = agent.name?.trim()
const id = agent.agentId
if (!name || name === id) {
if (OC_UUID_PATTERN.test(id)) return id.slice(0, 11) // "oc-XXXXXXXX"
if (UUID_PATTERN.test(id)) return id.slice(0, 8)
return id
}
return name
}
export function canDelete(agent: AgentListItem): boolean {
// The gateway's protected `main` agent must not be deletable. The
// server enforces this too, but disabling the menu item avoids users
// hitting an opaque 400.
if (agent.agentId === 'main') return false
return agent.canDelete
}
/**
* Rename will be wired to a future `PATCH /agents/:id` endpoint. The
* legacy `/claw/agents` create flow named the agent on the gateway via
* the `name` field but the field isn't editable post-create today.
*/
export function canRename(_agent: AgentListItem): boolean {
return false
}
/**
* The detail line carries the agent's workspace path. The `detail`
* field on AgentListItem already holds it for OpenClaw entries
* (`/home/node/.openclaw/workspace-...`); for harness agents it's the
* synthetic `<adapter>:main` marker that's not informative — hide it.
*/
export function workspaceLabel(agent: AgentListItem): string | null {
if (!agent.detail) return null
if (/^(claude|codex|openclaw):main$/.test(agent.detail)) return null
return agent.detail
}
const ONE_MINUTE = 60_000
const ONE_HOUR = 60 * ONE_MINUTE
const ONE_DAY = 24 * ONE_HOUR
/**
* Lightweight relative-time formatter. We don't want to drag in
* `dayjs/relativeTime` just for a few labels.
*/
export function formatRelativeTime(epochMs: number | null): string {
if (epochMs === null || !Number.isFinite(epochMs)) return 'never'
const diff = Math.max(0, Date.now() - epochMs)
if (diff < ONE_MINUTE) return 'just now'
if (diff < ONE_HOUR) {
const m = Math.floor(diff / ONE_MINUTE)
return `${m} min ago`
}
if (diff < ONE_DAY) {
const h = Math.floor(diff / ONE_HOUR)
return h === 1 ? '1 hr ago' : `${h} hr ago`
}
const d = Math.floor(diff / ONE_DAY)
return d === 1 ? '1 day ago' : `${d} days ago`
}
/**
* Tooltip-friendly description of a row's current liveness state.
* Returns `undefined` when the state has nothing extra to add (e.g.
* `unknown` with no timestamp).
*/
export function livenessDetail(
status: AgentLiveness,
lastUsedAt: number | null | undefined,
): string | undefined {
if (lastUsedAt == null) return undefined
const diffMin = Math.floor((Date.now() - lastUsedAt) / 60_000)
if (status === 'idle') return `Idle for ${Math.max(0, diffMin)} min`
if (status === 'asleep') {
if (diffMin < 60) return `Asleep — quiet for ${diffMin} min`
const hr = Math.floor(diffMin / 60)
return `Asleep — quiet for ${hr} hr`
}
if (status === 'working') return 'Working on a turn'
if (status === 'error') return 'Attention — last turn failed'
return undefined
}

View File

@@ -1,185 +0,0 @@
import type { AgentEntry } from './useOpenClaw'
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
/**
* One file the harness attributed to the assistant turn that just
* finished. Mirrors the server-side `ProducedFileEventEntry` shape so
* the inline artifact card can render alongside the streamed text the
* user just watched complete. Only present for openclaw adapter
* turns; claude / codex don't produce these events in v1.
*/
export interface HarnessProducedFile {
id: string
/** Workspace-relative POSIX path. */
path: string
size: number
mtimeMs: number
}
export type AgentHarnessStreamEvent =
| {
type: 'text_delta'
text: string
stream: 'output' | 'thought'
rawType?: string
}
| {
type: 'tool_call'
text: string
title: string
id?: string
status?: string
rawType?: string
}
| {
type: 'status'
text: string
rawType?: string
}
| {
type: 'produced_files'
files: HarnessProducedFile[]
}
| {
type: 'done'
text?: string
stopReason?: string
}
| {
type: 'error'
message: string
code?: string
}
export type HarnessAgentLiveness = 'working' | 'idle' | 'asleep' | 'error'
export interface HarnessAgent {
id: string
name: string
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
permissionMode: 'approve-all'
sessionKey: string
createdAt: number
updatedAt: number
/**
* Server-derived liveness state. When the listing endpoint hasn't
* been enriched yet (older deployments) this is undefined and the UI
* falls back to `unknown`.
*/
status?: HarnessAgentLiveness
/**
* Wall-clock ms of the last persisted turn. `null` for never-used
* agents. Drives the recency sort and the "Last used X min ago" copy.
*/
lastUsedAt?: number | null
/** Pinned agents float to the top of the list. Defaults to `false`. */
pinned?: boolean
/** First non-blank line of the most recent user message; null if none. */
lastUserMessage?: string | null
/** Working directory the agent runs in; null when no session record yet. */
cwd?: string | null
/** Cumulative + 7-day rolling token usage; null when no record. */
tokens?: {
last7d: { input: number; output: number; requestCount: number }
cumulative: { input: number; output: number }
} | null
turnsByDay?: number[]
failedByDay?: number[]
lastError?: string | null
lastErrorAt?: number | null
/** When non-null, an in-flight turn this row can be resumed from. */
activeTurnId?: string | null
/** Persistent FIFO queue of messages waiting for this agent. */
queue?: HarnessQueuedMessage[]
}
export interface HarnessQueuedMessageAttachment {
mediaType: string
data: string
}
export interface HarnessQueuedMessage {
id: string
createdAt: number
message: string
attachments?: ReadonlyArray<HarnessQueuedMessageAttachment>
}
export interface HarnessAdapterHealth {
healthy: boolean
reason?: string
checkedAt: number
}
export interface HarnessAdapterDescriptor {
id: HarnessAgentAdapter
name: string
defaultModelId: string
defaultReasoningEffort: string
modelControl: 'runtime-supported' | 'best-effort'
models: Array<{ id: string; label: string; recommended?: boolean }>
reasoningEfforts: Array<{ id: string; label: string; recommended?: boolean }>
health?: HarnessAdapterHealth
}
export interface CreateHarnessAgentInput {
name: string
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
/**
* Adapter provider id from the user's BrowserOS AI Settings entry.
* Provider-backed adapters use this with `apiKey`/`baseUrl` to write
* or provision their runtime-specific provider config.
*/
providerType?: string
/** API key paired with `providerType` when the selected adapter needs one. */
apiKey?: string
/** Base URL for OpenAI-compatible/custom provider entries. */
baseUrl?: string
}
export interface HarnessHistoryReasoning {
text: string
durationMs?: number
}
export interface HarnessHistoryToolCall {
toolCallId?: string
toolName: string
status: 'pending' | 'running' | 'completed' | 'failed'
input?: unknown
output?: unknown
error?: string
durationMs?: number
}
export interface HarnessHistoryEntry {
id: string
agentId: string
sessionId: 'main'
role: 'user' | 'assistant'
text: string
createdAt: number
reasoning?: HarnessHistoryReasoning
toolCalls?: HarnessHistoryToolCall[]
}
export interface HarnessAgentHistoryPage {
agentId: string
sessionId: 'main'
items: HarnessHistoryEntry[]
}
export function mapHarnessAgentToEntry(agent: HarnessAgent): AgentEntry {
return {
agentId: agent.id,
name: agent.name,
workspace: `${agent.adapter}:main`,
model: agent.modelId,
source: 'agent-harness',
}
}

View File

@@ -1,160 +0,0 @@
import {
Copy,
Loader2,
MessageSquare,
MoreHorizontal,
Pencil,
RotateCcw,
Trash2,
} from 'lucide-react'
import type { FC } from 'react'
import { useNavigate } from 'react-router'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
canDelete as canDeleteAgent,
canRename as canRenameAgent,
displayName,
} from '../agent-display.helpers'
import type { AgentListItem } from '../agents-page-types'
interface AgentActionsProps {
agent: AgentListItem
activeTurnId: string | null
deleting?: boolean
onDelete: (agent: AgentListItem) => void
}
/**
* Single primary CTA per row: `Resume` (filled, accent-orange, with a
* pulsing dot) when an active turn exists; otherwise `Chat` (outline).
* Both navigate to the same place — the chat hook auto-attaches via
* `/chat/active` when there's a live turn — but the row signals which
* action the user is actually taking.
*/
export const AgentActions: FC<AgentActionsProps> = ({
agent,
activeTurnId,
deleting,
onDelete,
}) => {
const navigate = useNavigate()
const allowDelete = canDeleteAgent(agent)
const allowRename = canRenameAgent(agent)
const handleChat = () => navigate(`/agents/${agent.agentId}`)
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(agent.agentId)
toast.success('Agent id copied')
} catch {
toast.error('Could not copy agent id')
}
}
return (
<div className="flex shrink-0 items-center gap-1.5">
{activeTurnId ? (
<Button
variant="default"
size="sm"
onClick={handleChat}
className="gap-2 bg-[var(--accent-orange)] text-white shadow-sm hover:bg-[var(--accent-orange)]/90"
>
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white/70 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-white" />
</span>
Resume
</Button>
) : (
<Button variant="outline" size="sm" onClick={handleChat}>
<MessageSquare className="mr-1.5 size-3" />
Chat
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={`More actions for ${displayName(agent)}`}
className="size-8 text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={() => void handleCopyId()}>
<Copy className="mr-2 size-3.5" />
Copy id
</DropdownMenuItem>
<ComingSoonItem
icon={Pencil}
label="Rename"
disabled={!allowRename}
/>
<ComingSoonItem icon={RotateCcw} label="Reset history" disabled />
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => onDelete(agent)}
disabled={!allowDelete || deleting}
className="text-destructive focus:text-destructive"
>
{deleting ? (
<Loader2 className="mr-2 size-3.5 animate-spin" />
) : (
<Trash2 className="mr-2 size-3.5" />
)}
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
interface ComingSoonItemProps {
icon: typeof Pencil
label: string
disabled: boolean
}
const ComingSoonItem: FC<ComingSoonItemProps> = ({
icon: Icon,
label,
disabled,
}) => {
const item = (
<DropdownMenuItem disabled className="text-muted-foreground">
<Icon className="mr-2 size-3.5" />
{label}
</DropdownMenuItem>
)
if (!disabled) return item
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="block w-full">{item}</span>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{label} coming soon
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -1,96 +0,0 @@
import { AlertTriangle, ChevronDown } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { cn } from '@/lib/utils'
import { truncate } from './agent-row.helpers'
interface AgentErrorPanelProps {
agentId: string
message: string
errorAt: number | null
}
const STORAGE_PREFIX = 'agent-row:lastErrorSeenAt:'
const PREVIEW_CHARS = 200
export const AgentErrorPanel: FC<AgentErrorPanelProps> = ({
agentId,
message,
errorAt,
}) => {
const storageKey = `${STORAGE_PREFIX}${agentId}`
// Open if we've never seen this `errorAt` for this agent. Once the
// user collapses the panel (or refreshes after seeing it), we mark
// it seen so it doesn't re-pop on every poll.
const [open, setOpen] = useState<boolean>(() => {
if (typeof window === 'undefined' || !errorAt) return true
const seen = Number(window.localStorage.getItem(storageKey) ?? 0)
return !Number.isFinite(seen) || errorAt > seen
})
useEffect(() => {
if (!open && errorAt && typeof window !== 'undefined') {
window.localStorage.setItem(storageKey, String(errorAt))
}
}, [open, errorAt, storageKey])
const preview = truncate(message, PREVIEW_CHARS)
const truncated = preview.length < message.length
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3">
<div className="flex items-center justify-between rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-2 font-medium text-destructive text-xs">
<AlertTriangle className="size-3.5" />
Last error
</div>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-muted-foreground"
>
<span className="text-xs">{open ? 'hide' : 'show'}</span>
<ChevronDown
className={cn(
'ml-1 size-3 transition-transform',
open && 'rotate-180',
)}
/>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="mt-1 rounded-md border-destructive/30 border-x border-b bg-destructive/5 px-3 pb-2 text-xs">
{truncated ? (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<span className="cursor-default font-mono text-foreground/80">
{preview}
</span>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
className="max-w-md whitespace-pre-wrap font-mono text-xs"
>
{message}
</HoverCardContent>
</HoverCard>
) : (
<span className="font-mono text-foreground/80">{message}</span>
)}
</div>
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -1,35 +0,0 @@
import { Quote } from 'lucide-react'
import type { FC } from 'react'
import { firstNonBlankLine, truncate } from './agent-row.helpers'
interface AgentLastMessageProps {
message: string | null
}
const PREVIEW_CHARS = 110
/**
* Inline preview of the most recent user message. Renders as a quoted,
* italic line so the row reads like a conversation snippet rather than
* a label-and-value pair. No hover-card — opening the agent's chat is
* the canonical way to read the full message.
*/
export const AgentLastMessage: FC<AgentLastMessageProps> = ({ message }) => {
if (!message) {
return (
<p className="mt-1 text-muted-foreground/70 text-xs italic">
No messages yet start a chat
</p>
)
}
const preview = truncate(firstNonBlankLine(message), PREVIEW_CHARS)
return (
<p className="mt-1.5 flex items-start gap-1.5 text-foreground/85 text-sm italic leading-snug">
<Quote
className="mt-1 size-3 shrink-0 text-muted-foreground/60"
aria-hidden
/>
<span className="truncate">{preview}</span>
</p>
)
}

View File

@@ -1,37 +0,0 @@
import type { FC } from 'react'
import { formatRelativeTime } from '../agent-display.helpers'
import { AgentTokenSummary } from './AgentTokenSummary'
import type { AgentTokenUsage } from './agent-row.types'
interface AgentMetaRowProps {
lastUsedAt: number | null
tokens: AgentTokenUsage | null
}
/**
* Bottom-of-row meta line. Intentionally sparse — last activity time
* and lifetime tokens. CWD is no longer surfaced here because the path
* the server happens to be running from isn't actionable; if a future
* surface needs the cwd (chat panel, debug view) it reads from the
* listing payload directly.
*/
export const AgentMetaRow: FC<AgentMetaRowProps> = ({ lastUsedAt, tokens }) => {
const lastUsedLabel = formatRelativeTime(lastUsedAt)
const tokensTotal =
(tokens?.cumulative.input ?? 0) + (tokens?.cumulative.output ?? 0)
const showTokens = tokensTotal > 0
return (
<div className="mt-2 flex flex-wrap items-center gap-x-2 text-muted-foreground text-xs">
<span>{lastUsedLabel}</span>
{showTokens && (
<>
<span aria-hidden className="text-muted-foreground/50">
·
</span>
<AgentTokenSummary tokens={tokens} />
</>
)}
</div>
)
}

View File

@@ -1,92 +0,0 @@
import type { FC } from 'react'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { cn } from '@/lib/utils'
import { formatLocalDate, ROW_BAR_COUNT } from './agent-row.helpers'
interface AgentSparklineProps {
/** 14 entries, oldest → newest. Today's bucket is the last index. */
turnsByDay: number[]
/** Same length, same order. Failed turns counted separately. */
failedByDay: number[]
className?: string
}
const MIN_BAR_HEIGHT_PX = 2
const MAX_BAR_HEIGHT_PX = 18
export const AgentSparkline: FC<AgentSparklineProps> = ({
turnsByDay,
failedByDay,
className,
}) => {
if (turnsByDay.length === 0 || turnsByDay.every((n) => n === 0)) return null
const max = Math.max(1, ...turnsByDay)
return (
<HoverCard openDelay={250}>
<HoverCardTrigger asChild>
<div
role="img"
aria-label={`Last ${ROW_BAR_COUNT} days of activity`}
className={cn('flex h-5 items-end gap-px', className)}
>
{turnsByDay.map((count, idx) => {
const ratio = count / max
const height = Math.max(
MIN_BAR_HEIGHT_PX,
Math.round(ratio * MAX_BAR_HEIGHT_PX),
)
const isToday = idx === ROW_BAR_COUNT - 1
const failed = failedByDay[idx] ?? 0
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length sparkline buckets keyed by day position
key={`bar-${idx}`}
className={cn(
'w-1.5 rounded-sm',
count === 0
? 'bg-muted-foreground/15'
: failed > 0
? 'bg-destructive/50'
: 'bg-[var(--accent-orange)]/50',
isToday && 'ring-1 ring-foreground/30',
)}
style={{ height }}
/>
)
})}
</div>
</HoverCardTrigger>
<HoverCardContent side="left" className="w-56 text-xs">
<div className="mb-2 font-medium text-sm">Last 14 days</div>
<ul className="space-y-0.5">
{turnsByDay.map((count, idx) => {
const failed = failedByDay[idx] ?? 0
const dayLabel = formatLocalDate(idx)
return (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length list keyed by day position
key={`day-${idx}`}
className="flex items-center justify-between text-muted-foreground"
>
<span>{dayLabel}</span>
<span>
{count}
{failed > 0 && (
<span className="ml-1 text-destructive">
({failed} failed)
</span>
)}
</span>
</li>
)
})}
</ul>
</HoverCardContent>
</HoverCard>
)
}

View File

@@ -1,71 +0,0 @@
import { TriangleAlert } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { cn } from '@/lib/utils'
import { adapterLabel } from '../AdapterIcon'
import type { HarnessAgentAdapter } from '../agent-harness-types'
import type { AgentAdapterHealth } from './agent-row.types'
interface AgentSummaryChipsProps {
adapter: HarnessAgentAdapter | 'unknown'
modelLabel: string | null
reasoningEffort: string | null
/** When unhealthy, the adapter label dims and a warning chip appears. */
adapterHealth: AgentAdapterHealth | null
}
/**
* Adapter / model / reasoning summary line. Always rendered (so OpenClaw
* rows that fall back to defaults still expose what they're set up to do)
* and surfaces adapter-health *only when unhealthy* — keeping the calm
* default state silent and reserving visual noise for things the user
* needs to act on.
*/
export const AgentSummaryChips: FC<AgentSummaryChipsProps> = ({
adapter,
modelLabel,
reasoningEffort,
adapterHealth,
}) => {
const parts = [adapterLabel(adapter)]
if (modelLabel) parts.push(modelLabel)
if (reasoningEffort) parts.push(reasoningEffort)
const unhealthy = adapterHealth?.healthy === false
return (
<div
className={cn(
'flex items-center gap-1.5 text-muted-foreground text-xs',
unhealthy && 'text-muted-foreground/70',
)}
>
<span className="truncate">{parts.join(' · ')}</span>
{unhealthy && adapterHealth && (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<Badge
variant="outline"
className="h-5 cursor-default gap-1 border-amber-500/40 bg-amber-50 px-1.5 text-amber-900 hover:bg-amber-50"
>
<TriangleAlert className="size-2.5" />
<span className="font-normal">Unavailable</span>
</Badge>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-72 text-sm">
<div className="font-medium">
{adapterLabel(adapter)} CLI not available
</div>
<div className="mt-1 text-muted-foreground text-xs">
{adapterHealth.reason ??
'Adapter binary missing on $PATH. Install it from the adapter docs to use this agent.'}
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
)
}

View File

@@ -1,37 +0,0 @@
import type { FC } from 'react'
import { cn } from '@/lib/utils'
import { AdapterIcon } from '../AdapterIcon'
import { livenessDetail } from '../agent-display.helpers'
import type { HarnessAgentAdapter } from '../agent-harness-types'
import { type AgentLiveness, LivenessDot } from '../LivenessDot'
export interface AgentTileProps {
adapter: HarnessAgentAdapter | 'unknown'
status: AgentLiveness
lastUsedAt: number | null
}
/**
* Adapter glyph + a single liveness dot. Adapter health is no longer
* surfaced here — it lives as an inline pill inside `AgentSummaryChips`
* so the user isn't asked to disambiguate two dots on the same tile.
*/
export const AgentTile: FC<AgentTileProps> = ({
adapter,
status,
lastUsedAt,
}) => (
<div className="relative shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<AdapterIcon adapter={adapter} className="h-6 w-6" />
</div>
<LivenessDot
status={status}
detail={livenessDetail(status, lastUsedAt)}
className={cn(
'absolute -right-0.5 -bottom-0.5',
status === 'working' && 'animate-pulse',
)}
/>
</div>
)

View File

@@ -1,55 +0,0 @@
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { displayName } from '../agent-display.helpers'
import type { AgentListItem } from '../agents-page-types'
import type { AgentLiveness } from '../LivenessDot'
import { AgentSparkline } from './AgentSparkline'
import { PinToggle } from './PinToggle'
interface AgentTitleRowProps {
agent: AgentListItem
status: AgentLiveness
pinned: boolean
turnsByDay: number[]
failedByDay: number[]
onPinToggle: (next: boolean) => void
}
/**
* Title strip: name + status badge + (right-aligned) sparkline. The
* pin toggle sits trailing the title so the title always flushes left
* regardless of pin state — moving the star left of the title indents
* the row's first line off-axis from the model/preview/meta lines
* below it. When unpinned and not hovered, the toggle is removed from
* layout entirely so it reserves no space at all.
*/
export const AgentTitleRow: FC<AgentTitleRowProps> = ({
agent,
status,
pinned,
turnsByDay,
failedByDay,
onPinToggle,
}) => (
<div className="mb-1 flex items-center gap-2">
<span className="truncate font-semibold">{displayName(agent)}</span>
{status === 'working' && (
<Badge
variant="secondary"
className="bg-amber-50 text-amber-900 hover:bg-amber-50"
>
Working
</Badge>
)}
{status === 'asleep' && (
<Badge variant="outline" className="text-muted-foreground">
Asleep
</Badge>
)}
{status === 'error' && <Badge variant="destructive">Attention</Badge>}
<PinToggle pinned={pinned} onToggle={onPinToggle} />
<div className="ml-auto">
<AgentSparkline turnsByDay={turnsByDay} failedByDay={failedByDay} />
</div>
</div>
)

View File

@@ -1,63 +0,0 @@
import type { FC } from 'react'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { Progress } from '@/components/ui/progress'
import { formatTokens } from './agent-row.helpers'
import type { AgentTokenUsage } from './agent-row.types'
interface AgentTokenSummaryProps {
tokens: AgentTokenUsage | null
}
/**
* Inline token total + a HoverCard breakdown. Surfaces lifetime tokens
* (the only window we can compute reliably from the session record).
* Per-window stats land in a follow-up once the activity ledger ships.
*/
export const AgentTokenSummary: FC<AgentTokenSummaryProps> = ({ tokens }) => {
if (!tokens) return null
const { input, output } = tokens.cumulative
const total = input + output
if (total === 0) return null
const inputPct = (input / total) * 100
return (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span className="cursor-default text-muted-foreground tabular-nums transition-colors hover:text-foreground">
{formatTokens(total)} tokens
</span>
</HoverCardTrigger>
<HoverCardContent side="top" align="end" className="w-72 text-sm">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium">Lifetime tokens</span>
<span className="text-muted-foreground text-xs tabular-nums">
{formatTokens(total)} total
</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Input</span>
<span className="tabular-nums">{formatTokens(input)}</span>
</div>
<Progress value={inputPct} className="h-1.5" />
<div className="mt-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">Output</span>
<span className="tabular-nums">{formatTokens(output)}</span>
</div>
<Progress value={100 - inputPct} className="h-1.5" />
</div>
<p className="mt-3 border-t pt-2 text-muted-foreground text-xs leading-snug">
Cumulative across every turn this agent has run. Per-window stats
arrive in a future release.
</p>
</HoverCardContent>
</HoverCard>
)
}

View File

@@ -1,60 +0,0 @@
import { Star } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
interface PinToggleProps {
pinned: boolean
onToggle: (next: boolean) => void
}
/**
* Trailing star toggle. The button is *always rendered* — only its
* opacity changes between pinned/unpinned/hover states — so the title
* row's height is constant. Hiding the slot via `display: none` would
* collapse the row's vertical metrics on hover and shift every card
* below in the rail.
*
* Placement is trailing the title (after the status badge) so the
* title itself flushes left regardless of pin state — leading the
* row with the star would indent the title relative to the model /
* preview / meta lines beneath it.
*/
export const PinToggle: FC<PinToggleProps> = ({ pinned, onToggle }) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'size-6 text-muted-foreground transition-opacity hover:text-foreground',
pinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
aria-pressed={pinned}
aria-label={pinned ? 'Unpin agent' : 'Pin agent'}
onClick={(event) => {
event.stopPropagation()
onToggle(!pinned)
}}
>
<Star
className={cn(
'size-3.5',
pinned && 'fill-amber-400 text-amber-500',
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{pinned ? 'Unpin' : 'Pin to top'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
firstNonBlankLine,
formatLocalDate,
formatTokens,
ROW_BAR_COUNT,
truncate,
} from './agent-row.helpers'
describe('formatTokens', () => {
it('renders zero / NaN as "0"', () => {
expect(formatTokens(0)).toBe('0')
expect(formatTokens(Number.NaN)).toBe('0')
})
it('renders sub-1K as integer', () => {
expect(formatTokens(142)).toBe('142')
})
it('renders K with one decimal under 10', () => {
expect(formatTokens(8_400)).toBe('8.4K')
})
it('drops the decimal at >=10K', () => {
expect(formatTokens(120_000)).toBe('120K')
})
it('renders M with one decimal under 10', () => {
expect(formatTokens(1_200_000)).toBe('1.2M')
})
})
describe('firstNonBlankLine', () => {
it('returns the first non-blank line', () => {
expect(firstNonBlankLine('\n\nhello\nworld')).toBe('hello')
})
it('skips USER_QUERY envelope tags', () => {
expect(firstNonBlankLine('<USER_QUERY>\nfix tests\n</USER_QUERY>')).toBe(
'fix tests',
)
})
it('falls back to the trimmed input when nothing matches', () => {
expect(firstNonBlankLine(' single ')).toBe('single')
})
})
describe('truncate', () => {
it('returns input unchanged when within limit', () => {
expect(truncate('hello', 10)).toBe('hello')
})
it('appends an ellipsis when over limit', () => {
expect(truncate('hello world', 6)).toBe('hello…')
})
})
describe('formatLocalDate', () => {
const today = new Date('2026-04-30T12:00:00Z')
it('labels today and yesterday explicitly', () => {
expect(formatLocalDate(ROW_BAR_COUNT - 1, today)).toBe('today')
expect(formatLocalDate(ROW_BAR_COUNT - 2, today)).toBe('yesterday')
})
it('returns a "Mon D" format for older days', () => {
const label = formatLocalDate(0, today)
// "Apr 17" or "Apr 17," depending on locale; just assert it
// contains a month abbreviation and a day number.
expect(label).toMatch(/[A-Za-z]+ \d+/)
})
})

View File

@@ -1,64 +0,0 @@
/**
* Pure formatters consumed by row sub-components. Kept distinct from
* `agent-display.helpers.ts` (page-level helpers) so the row internals
* have an obvious single home.
*/
const TOKEN_THRESHOLDS: Array<[number, string]> = [
[1_000_000, 'M'],
[1_000, 'K'],
]
/** `1.2M`, `820K`, `8.4K`, `142`, `0`. */
export function formatTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0'
for (const [threshold, suffix] of TOKEN_THRESHOLDS) {
if (n >= threshold) {
const value = n / threshold
const decimal = value < 10 ? value.toFixed(1) : value.toFixed(0)
return `${decimal}${suffix}`
}
}
return String(Math.round(n))
}
const USER_QUERY_OPEN = /^<USER_QUERY>$/i
const USER_QUERY_CLOSE = /^<\/USER_QUERY>$/i
/**
* First non-blank line, with the BrowserOS user-system-prompt
* `<USER_QUERY>` envelope tags stripped so previews don't show
* structural noise.
*/
export function firstNonBlankLine(text: string): string {
const lines = text.split('\n').map((line) => line.trim())
for (const line of lines) {
if (!line) continue
if (USER_QUERY_OPEN.test(line) || USER_QUERY_CLOSE.test(line)) continue
return line
}
return text.trim()
}
export function truncate(text: string, max: number): string {
if (text.length <= max) return text
return `${text.slice(0, max - 1).trimEnd()}`
}
const SPARKLINE_DAYS = 14
/**
* "today" / "yesterday" / "Apr 17" — given an index 0..13 from
* oldest → newest. `today` defaults to `new Date()` so callers don't
* have to thread a clock through.
*/
export function formatLocalDate(idx: number, today: Date = new Date()): string {
if (idx === SPARKLINE_DAYS - 1) return 'today'
if (idx === SPARKLINE_DAYS - 2) return 'yesterday'
const offset = SPARKLINE_DAYS - 1 - idx
const date = new Date(today)
date.setDate(date.getDate() - offset)
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
export const ROW_BAR_COUNT = SPARKLINE_DAYS

View File

@@ -1,51 +0,0 @@
import type { HarnessAgentAdapter } from '../agent-harness-types'
import type { AgentListItem } from '../agents-page-types'
import type { AgentLiveness } from '../LivenessDot'
/**
* Window-bounded token usage. Server returns `null` when no session
* record exists yet for the agent.
*/
export interface AgentTokenUsage {
last7d: { input: number; output: number; requestCount: number }
cumulative: { input: number; output: number }
}
export interface AgentAdapterHealth {
healthy: boolean
reason?: string
}
/**
* Everything an `AgentRowCard` needs to render. Mirrors the shape
* `useHarnessAgents` exposes; the page assembles one entry per row in
* `AgentList` and passes it down. Sub-components only see slices of
* this object — no prop drilling beyond two levels.
*/
export interface AgentRowData {
agent: AgentListItem
adapter: HarnessAgentAdapter | 'unknown'
modelLabel: string | null
reasoningEffort: string | null
status: AgentLiveness
lastUsedAt: number | null
pinned: boolean
cwd: string | null
lastUserMessage: string | null
tokens: AgentTokenUsage | null
/** 14 entries, oldest → newest. Today is the last index. */
turnsByDay: number[]
/** Same length and ordering as `turnsByDay`. */
failedByDay: number[]
lastError: string | null
lastErrorAt: number | null
/** When non-null, an in-flight turn this row can be resumed from. */
activeTurnId: string | null
/** Adapter-level health, shared across rows for the same adapter. */
adapterHealth: AgentAdapterHealth | null
}
export interface AgentRowCallbacks {
onDelete: (agent: AgentListItem) => void
onPinToggle: (agent: AgentListItem, next: boolean) => void
}

View File

@@ -1,104 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { HarnessAgent } from './agent-harness-types'
import {
compareAgentsByPinThenRecency,
orderAgentsByPinThenRecency,
} from './agents-list-order'
function makeAgent(input: {
id: string
pinned?: boolean
lastUsedAt?: number | null
}): HarnessAgent {
return {
id: input.id,
name: input.id,
adapter: 'codex',
permissionMode: 'approve-all',
sessionKey: 'session',
createdAt: 0,
updatedAt: 0,
pinned: input.pinned,
lastUsedAt: input.lastUsedAt,
}
}
describe('orderAgentsByPinThenRecency', () => {
it('floats pinned agents to the top regardless of recency', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'a', pinned: false, lastUsedAt: 1_000 }),
makeAgent({ id: 'b', pinned: true, lastUsedAt: 100 }),
makeAgent({ id: 'c', pinned: false, lastUsedAt: 500 }),
])
expect(result.map((entry) => entry.id)).toEqual(['b', 'a', 'c'])
})
it('sorts by lastUsedAt desc within each pin group', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'older-pin', pinned: true, lastUsedAt: 100 }),
makeAgent({ id: 'newer-pin', pinned: true, lastUsedAt: 200 }),
makeAgent({ id: 'older', pinned: false, lastUsedAt: 50 }),
makeAgent({ id: 'newer', pinned: false, lastUsedAt: 80 }),
])
expect(result.map((entry) => entry.id)).toEqual([
'newer-pin',
'older-pin',
'newer',
'older',
])
})
it('seed-pins the gateway main agent above other never-used agents', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: null }),
makeAgent({ id: 'main', pinned: false, lastUsedAt: null }),
makeAgent({ id: 'zzz', pinned: false, lastUsedAt: null }),
])
expect(result.map((entry) => entry.id)).toEqual(['main', 'aaa', 'zzz'])
})
it('drops the main seed-pin once the agent has been used', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: 999 }),
makeAgent({ id: 'main', pinned: false, lastUsedAt: 1 }),
])
expect(result.map((entry) => entry.id)).toEqual(['aaa', 'main'])
})
it('puts never-used agents below recently-used ones', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'fresh', pinned: false, lastUsedAt: null }),
makeAgent({ id: 'used', pinned: false, lastUsedAt: 100 }),
])
expect(result.map((entry) => entry.id)).toEqual(['used', 'fresh'])
})
it('id-stable tiebreaks two agents with identical lastUsedAt', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'b', pinned: false, lastUsedAt: 100 }),
makeAgent({ id: 'a', pinned: false, lastUsedAt: 100 }),
])
expect(result.map((entry) => entry.id)).toEqual(['a', 'b'])
})
})
describe('compareAgentsByPinThenRecency', () => {
it('produces the same order as the harness-shape helper', () => {
const items = [
{ id: 'older', pinned: false, lastUsedAt: 50 },
{ id: 'newer', pinned: false, lastUsedAt: 80 },
{ id: 'pinned', pinned: true, lastUsedAt: 1 },
]
const sorted = [...items].sort(compareAgentsByPinThenRecency)
expect(sorted.map((item) => item.id)).toEqual(['pinned', 'newer', 'older'])
})
it('seeds the main agent above other never-used rows', () => {
const items = [
{ id: 'zzz', pinned: false, lastUsedAt: null },
{ id: 'main', pinned: false, lastUsedAt: null },
]
const sorted = [...items].sort(compareAgentsByPinThenRecency)
expect(sorted.map((item) => item.id)).toEqual(['main', 'zzz'])
})
})

View File

@@ -1,59 +0,0 @@
import type { HarnessAgent } from './agent-harness-types'
/**
* Stable ordering for index-shaped agent surfaces (the `/agents` rail
* and the chat-screen rail at `/agents/:agentId`). Pinned rows float
* to the top, then recency desc, with never-used agents falling to
* the bottom in id-stable order. The gateway's `main` agent gets
* seed-pinned to the top of the never-used group so a fresh install
* has an obvious starting point even before the user has used it.
*
* NOT the same rule as the home grid (`orderHomeAgents`): home is
* action-shaped — active-turn floats to the top — so users can
* resume what's running. The chat rail keeps recency stable so it
* doesn't reshuffle as turns transition every 5s.
*/
export function orderAgentsByPinThenRecency(
agents: HarnessAgent[],
): HarnessAgent[] {
return [...agents].sort((a, b) => {
const aPinned = a.pinned ?? false
const bPinned = b.pinned ?? false
if (aPinned !== bPinned) return aPinned ? -1 : 1
const aSeed = a.id === 'main' && (a.lastUsedAt ?? null) === null
const bSeed = b.id === 'main' && (b.lastUsedAt ?? null) === null
if (aSeed && !bSeed) return -1
if (!aSeed && bSeed) return 1
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
if (aValue !== bValue) return bValue - aValue
return a.id.localeCompare(b.id)
})
}
/**
* Same comparator, but operates over arbitrary records that carry
* `pinned`, `lastUsedAt`, and an `id`-equivalent key. Used by the
* `/agents` `AgentList` which pivots `AgentListItem` + harness
* lookup into a sortable shape; both surfaces stay on identical
* sort semantics through this adapter.
*/
export function compareAgentsByPinThenRecency<
T extends { pinned: boolean; lastUsedAt: number | null; id: string },
>(a: T, b: T): number {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
const aSeed = a.id === 'main' && a.lastUsedAt === null
const bSeed = b.id === 'main' && b.lastUsedAt === null
if (aSeed && !bSeed) return -1
if (!aSeed && bSeed) return 1
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
if (aValue !== bValue) return bValue - aValue
return a.id.localeCompare(b.id)
}

View File

@@ -1,195 +0,0 @@
import type { NavigateFunction } from 'react-router'
import {
AGENT_CREATED_EVENT,
AGENT_DELETED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
import type {
AgentListItem,
CreateAgentRuntime,
ProviderOption,
} from './agents-page-types'
import { findOpenClawCliProviderById } from './openclaw-cli-providers'
import type {
AgentEntry,
OpenClawAgentMutationInput,
OpenClawSetupInput,
} from './useOpenClaw'
export interface AgentPageActionInput {
createProviderId: string
createRuntime: CreateAgentRuntime
createHermesProviderId: string
harnessModelId: string
harnessReasoningEffort: string
navigate: NavigateFunction
newName: string
selectableOpenClawProviders: ProviderOption[]
selectableHermesProviders: ProviderOption[]
setupProviderId: string
createHarnessAgent: (input: {
name: string
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
providerType?: string
apiKey?: string
baseUrl?: string
}) => Promise<HarnessAgent>
createOpenClawAgent: (
input: OpenClawAgentMutationInput,
) => Promise<{ agent: AgentEntry }>
deleteHarnessAgent: (agentId: string) => Promise<unknown>
deleteOpenClawAgent: (agentId: string) => Promise<unknown>
setCliAuthModalOpen: (open: boolean) => void
setCreateError: (error: string | null) => void
setCreateOpen: (open: boolean) => void
setDeletingAgentKey: (key: string | null) => void
setNewName: (name: string) => void
setPageError: (error: string | null) => void
setSetupOpen: (open: boolean) => void
setupOpenClaw: (input: OpenClawSetupInput) => Promise<unknown>
}
export function createAgentPageActions(input: AgentPageActionInput) {
const runWithPageErrorHandling = async (fn: () => Promise<unknown>) => {
input.setPageError(null)
try {
await fn()
} catch (err) {
input.setPageError(err instanceof Error ? err.message : String(err))
}
}
const handleSetup = async () => {
const option = input.selectableOpenClawProviders.find(
(item) => item.id === input.setupProviderId,
)
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
const llmOption = !isCli && option ? option : undefined
await runWithPageErrorHandling(async () => {
await input.setupOpenClaw({
providerType: option?.type,
providerName: isCli ? undefined : option?.name,
baseUrl: llmOption?.baseUrl,
apiKey: llmOption?.apiKey,
modelId: option?.modelId,
})
input.setSetupOpen(false)
if (isCli) input.setCliAuthModalOpen(true)
})
}
const handleOpenClawCreate = async () => {
if (!input.newName.trim()) return
const option = input.selectableOpenClawProviders.find(
(item) => item.id === input.createProviderId,
)
const normalizedName = input.newName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
const llmOption = !isCli && option ? option : undefined
input.setCreateError(null)
try {
const result = await input.createOpenClawAgent({
name: normalizedName,
providerType: option?.type,
providerName: isCli ? undefined : option?.name,
baseUrl: llmOption?.baseUrl,
apiKey: llmOption?.apiKey,
modelId: option?.modelId,
})
input.setCreateOpen(false)
input.setNewName('')
track(AGENT_CREATED_EVENT, {
runtime: 'openclaw',
provider_type: option?.type,
})
input.navigate(`/agents/${result.agent.agentId}`)
} catch (err) {
input.setCreateError(err instanceof Error ? err.message : String(err))
}
}
const handleHarnessCreate = async () => {
if (!input.newName.trim()) return
const isHermes = input.createRuntime === 'hermes'
// Hermes pulls every provider field from the user's selected entry
// in the global LLM-providers list (managed under AI Settings). The
// backend rejects creation if any required field is missing.
const hermesProvider = isHermes
? input.selectableHermesProviders.find(
(option) => option.id === input.createHermesProviderId,
)
: undefined
const effectiveModelId = isHermes
? hermesProvider?.modelId
: input.harnessModelId || undefined
input.setCreateError(null)
try {
const agent = await input.createHarnessAgent({
name: input.newName.trim(),
adapter: input.createRuntime as HarnessAgentAdapter,
modelId: effectiveModelId,
reasoningEffort: input.harnessReasoningEffort || undefined,
providerType: hermesProvider?.type,
apiKey: hermesProvider?.apiKey,
baseUrl: hermesProvider?.baseUrl,
})
input.setCreateOpen(false)
input.setNewName('')
track(AGENT_CREATED_EVENT, {
runtime: input.createRuntime,
model_id: effectiveModelId,
reasoning_effort: input.harnessReasoningEffort || undefined,
provider_type: hermesProvider?.type,
})
input.navigate(`/agents/${agent.id}`)
} catch (err) {
input.setCreateError(err instanceof Error ? err.message : String(err))
}
}
const handleCreate = () => {
const createByRuntime: Record<CreateAgentRuntime, () => Promise<void>> = {
openclaw: handleOpenClawCreate,
claude: handleHarnessCreate,
codex: handleHarnessCreate,
hermes: handleHarnessCreate,
}
void createByRuntime[input.createRuntime]()
}
const handleDelete = async (agent: AgentListItem) => {
input.setDeletingAgentKey(agent.key)
await runWithPageErrorHandling(async () => {
const deleteBySource: Record<
AgentListItem['source'],
(agentId: string) => Promise<unknown>
> = {
openclaw: (agentId) => input.deleteOpenClawAgent(agentId),
'agent-harness': (agentId) => input.deleteHarnessAgent(agentId),
}
await deleteBySource[agent.source](agent.agentId)
track(AGENT_DELETED_EVENT, {
runtime: agent.source,
agent_id: agent.agentId,
})
})
input.setDeletingAgentKey(null)
}
return {
handleCreate,
handleDelete,
handleSetup,
runWithPageErrorHandling,
}
}

View File

@@ -1,231 +0,0 @@
import { type Dispatch, type SetStateAction, useEffect, useMemo } from 'react'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type {
HarnessAdapterDescriptor,
HarnessAgentAdapter,
} from './agent-harness-types'
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
import { toProviderOptions } from './agents-page-utils'
import { getHermesSupportedProviders } from './hermes-supported-providers'
import {
buildOpenClawCliProviderOptions,
findOpenClawCliProviderById,
useOpenClawCliProviderAuthStatus,
} from './openclaw-cli-providers'
export function useDefaultAgentName(
createOpen: boolean,
setNewName: Dispatch<SetStateAction<string>>,
): void {
useEffect(() => {
if (!createOpen) return
setNewName((current) => current || 'agent')
}, [createOpen, setNewName])
}
export function useHarnessAgentDefaults(input: {
adapters: HarnessAdapterDescriptor[]
createOpen: boolean
harnessAdapterId: HarnessAgentAdapter
setHarnessAdapterId: Dispatch<SetStateAction<HarnessAgentAdapter>>
setHarnessModelId: Dispatch<SetStateAction<string>>
setHarnessReasoningEffort: Dispatch<SetStateAction<string>>
}): void {
const {
adapters,
createOpen,
harnessAdapterId,
setHarnessAdapterId,
setHarnessModelId,
setHarnessReasoningEffort,
} = input
useEffect(() => {
if (!createOpen) return
const adapter =
adapters.find((entry) => entry.id === harnessAdapterId) ?? adapters[0]
if (!adapter) return
setHarnessAdapterId(adapter.id)
setHarnessModelId((current) => current || adapter.defaultModelId)
setHarnessReasoningEffort(
(current) => current || adapter.defaultReasoningEffort,
)
}, [
adapters,
createOpen,
harnessAdapterId,
setHarnessAdapterId,
setHarnessModelId,
setHarnessReasoningEffort,
])
}
export function useOpenClawProviderSelection(input: {
providers: LlmProviderConfig[]
defaultProviderId: string
createOpen: boolean
createRuntime: CreateAgentRuntime
createProviderId: string
setCreateProviderId: Dispatch<SetStateAction<string>>
setupOpen: boolean
setupProviderId: string
setSetupProviderId: Dispatch<SetStateAction<string>>
cliAuthModalOpen: boolean
setCliAuthModalOpen: Dispatch<SetStateAction<boolean>>
}) {
const {
providers,
defaultProviderId,
createOpen,
createRuntime,
createProviderId,
setCreateProviderId,
setupOpen,
setupProviderId,
setSetupProviderId,
cliAuthModalOpen,
setCliAuthModalOpen,
} = input
const cliProviderOptions = useMemo(
() => buildOpenClawCliProviderOptions(),
[],
)
const selectableOpenClawProviders = useMemo(
() => toProviderOptions(providers, cliProviderOptions),
[providers, cliProviderOptions],
)
useEffect(() => {
if (selectableOpenClawProviders.length === 0) return
const fallbackId =
selectableOpenClawProviders.find(
(provider) => provider.id === defaultProviderId,
)?.id ?? selectableOpenClawProviders[0].id
if (createOpen && !createProviderId) {
setCreateProviderId(fallbackId)
}
}, [
createOpen,
createProviderId,
defaultProviderId,
selectableOpenClawProviders,
setCreateProviderId,
])
useEffect(() => {
if (selectableOpenClawProviders.length === 0) return
const fallbackId =
selectableOpenClawProviders.find(
(provider) => provider.id === defaultProviderId,
)?.id ?? selectableOpenClawProviders[0].id
if (setupOpen && !setupProviderId) {
setSetupProviderId(fallbackId)
}
}, [
defaultProviderId,
selectableOpenClawProviders,
setSetupProviderId,
setupOpen,
setupProviderId,
])
const selectedCreateOption = selectableOpenClawProviders.find(
(provider) => provider.id === createProviderId,
)
const selectedCliProvider = selectedCreateOption
? findOpenClawCliProviderById(selectedCreateOption.type)
: undefined
const selectedSetupOption = selectableOpenClawProviders.find(
(provider) => provider.id === setupProviderId,
)
const selectedSetupCliProvider = selectedSetupOption
? findOpenClawCliProviderById(selectedSetupOption.type)
: undefined
const activeCliProvider =
(setupOpen && selectedSetupCliProvider) ||
(createOpen && createRuntime === 'openclaw' && selectedCliProvider) ||
undefined
const {
data: cliAuthStatus,
isLoading: cliAuthLoading,
error: cliAuthError,
} = useOpenClawCliProviderAuthStatus(
activeCliProvider?.id ?? '',
!!activeCliProvider,
)
useEffect(() => {
if (cliAuthModalOpen && cliAuthStatus?.loggedIn) {
setCliAuthModalOpen(false)
}
}, [cliAuthModalOpen, cliAuthStatus?.loggedIn, setCliAuthModalOpen])
return {
selectableOpenClawProviders,
selectedCliProvider,
selectedSetupCliProvider,
authTerminalProvider: selectedSetupCliProvider ?? selectedCliProvider,
cliAuthStatus,
cliAuthLoading,
cliAuthError,
}
}
/**
* Mirror of useOpenClawProviderSelection but for Hermes. Hermes only
* needs the create-dialog flow (no setup dialog, no CLI providers), so
* this hook is much smaller — it just filters the global provider list
* to ones Hermes can drive and seeds the selected id when the dialog
* opens.
*/
export function useHermesProviderSelection(input: {
providers: LlmProviderConfig[]
defaultProviderId: string
createOpen: boolean
createRuntime: CreateAgentRuntime
createHermesProviderId: string
setCreateHermesProviderId: Dispatch<SetStateAction<string>>
}) {
const {
providers,
defaultProviderId,
createOpen,
createRuntime,
createHermesProviderId,
setCreateHermesProviderId,
} = input
const selectableHermesProviders = useMemo<ProviderOption[]>(
() =>
getHermesSupportedProviders(providers).map((provider) => ({
id: provider.id,
type: provider.type,
name: provider.name,
modelId: provider.modelId,
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
})),
[providers],
)
useEffect(() => {
if (selectableHermesProviders.length === 0) return
if (!createOpen || createRuntime !== 'hermes') return
if (createHermesProviderId) return
const fallbackId =
selectableHermesProviders.find((p) => p.id === defaultProviderId)?.id ??
selectableHermesProviders[0].id
setCreateHermesProviderId(fallbackId)
}, [
createHermesProviderId,
createOpen,
createRuntime,
defaultProviderId,
selectableHermesProviders,
setCreateHermesProviderId,
])
return { selectableHermesProviders }
}

View File

@@ -1,119 +0,0 @@
import type { HarnessAgentAdapter } from './agent-harness-types'
import type { GatewayLifecycleAction, OpenClawStatus } from './useOpenClaw'
export type CreateAgentRuntime = 'openclaw' | HarnessAgentAdapter
export interface ProviderOption {
id: string
type: string
name: string
modelId: string
baseUrl?: string
apiKey?: string
}
export interface AgentListItem {
key: string
agentId: string
name: string
source: 'openclaw' | 'agent-harness'
runtimeLabel: string
modelLabel: string
detail: string
canChat: boolean
canDelete: boolean
}
export interface GatewayUiState {
canManageAgents: boolean
controlPlaneDegraded: boolean
controlPlaneBusy: boolean
}
export const DEFAULT_HARNESS_ADAPTER: HarnessAgentAdapter = 'claude'
export const DEFAULT_CREATE_RUNTIME: CreateAgentRuntime = 'openclaw'
export const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
setup: 'Setting up OpenClaw...',
start: 'Starting gateway...',
stop: 'Stopping gateway...',
restart: 'Restarting gateway...',
reconnect: 'Restoring gateway connection...',
}
export const CONTROL_PLANE_COPY: Record<
OpenClawStatus['controlPlaneStatus'],
{
badgeVariant: 'default' | 'secondary' | 'outline' | 'destructive'
badgeLabel: string
title: string
description: string
}
> = {
connected: {
badgeVariant: 'default',
badgeLabel: 'Control Plane Ready',
title: 'Gateway Connected',
description: 'OpenClaw can create, manage, and chat with agents normally.',
},
connecting: {
badgeVariant: 'secondary',
badgeLabel: 'Connecting',
title: 'Connecting to Gateway',
description:
'BrowserOS is establishing the OpenClaw control channel for agent operations.',
},
reconnecting: {
badgeVariant: 'secondary',
badgeLabel: 'Reconnecting',
title: 'Reconnecting Control Plane',
description:
'The gateway process is up, but BrowserOS is restoring the control channel.',
},
recovering: {
badgeVariant: 'secondary',
badgeLabel: 'Recovering',
title: 'Recovering Gateway Connection',
description:
'BrowserOS detected a control-plane fault and is trying a safe recovery path.',
},
disconnected: {
badgeVariant: 'outline',
badgeLabel: 'Disconnected',
title: 'Gateway Disconnected',
description: 'The gateway process is not available to BrowserOS right now.',
},
failed: {
badgeVariant: 'destructive',
badgeLabel: 'Needs Attention',
title: 'Gateway Recovery Failed',
description:
'BrowserOS could not restore the OpenClaw control channel automatically.',
},
}
export const FALLBACK_CONTROL_PLANE_COPY = {
badgeVariant: 'outline' as const,
badgeLabel: 'Unknown',
title: 'Gateway State Unknown',
description:
'BrowserOS received a gateway status it does not recognize yet. Refreshing or reconnecting should restore a known state.',
}
export const RECOVERY_REASON_COPY: Record<
NonNullable<OpenClawStatus['lastRecoveryReason']>,
string
> = {
transient_disconnect:
'The control channel dropped briefly and BrowserOS is retrying it.',
signature_expired:
'The gateway rejected the signed device handshake because its clock drifted.',
pairing_required:
'The gateway asked BrowserOS to approve its local device identity again.',
token_mismatch:
'BrowserOS had to reload the gateway token before reconnecting.',
container_not_ready:
'The OpenClaw gateway process is not ready yet, so control-plane recovery cannot start.',
unknown:
'BrowserOS hit an unexpected gateway error and could not classify it cleanly.',
}

View File

@@ -1,167 +0,0 @@
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
import {
type AgentListItem,
CONTROL_PLANE_COPY,
FALLBACK_CONTROL_PLANE_COPY,
type GatewayUiState,
LIFECYCLE_BANNER_COPY,
type ProviderOption,
RECOVERY_REASON_COPY,
} from './agents-page-types'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import {
type AgentEntry,
type GatewayLifecycleAction,
getModelDisplayName,
type OpenClawStatus,
} from './useOpenClaw'
export function getControlPlaneCopy(
status: OpenClawStatus['controlPlaneStatus'],
) {
return CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
}
export function getRecoveryDetail(status: OpenClawStatus): string | null {
if (!status.lastRecoveryReason && !status.lastGatewayError) return null
const detail = status.lastRecoveryReason
? RECOVERY_REASON_COPY[status.lastRecoveryReason]
: null
if (status.lastGatewayError && detail) {
return `${detail} Latest gateway error: ${status.lastGatewayError}`
}
return status.lastGatewayError ?? detail
}
export function formatHarnessAdapter(adapter: HarnessAgentAdapter): string {
return adapter === 'claude' ? 'Claude Code' : 'Codex'
}
export function toProviderOptions(
providers: LlmProviderConfig[],
cliProviders: ProviderOption[],
): ProviderOption[] {
return [...getOpenClawSupportedProviders(providers), ...cliProviders]
}
export function toOpenClawListItem(
agent: AgentEntry,
canManageAgents: boolean,
): AgentListItem {
return {
key: `openclaw:${agent.agentId}`,
agentId: agent.agentId,
name: agent.name,
source: 'openclaw',
runtimeLabel: 'OpenClaw',
modelLabel: getModelDisplayName(agent.model) ?? 'default',
detail: agent.workspace,
canChat: canManageAgents,
canDelete: canManageAgents && agent.agentId !== 'main',
}
}
export function toHarnessListItem(agent: HarnessAgent): AgentListItem {
return {
key: `agent-harness:${agent.id}`,
agentId: agent.id,
name: agent.name,
source: 'agent-harness',
runtimeLabel: formatHarnessAdapter(agent.adapter),
modelLabel: agent.modelId ?? 'default',
detail: `${agent.adapter}:main`,
canChat: true,
canDelete: true,
}
}
export function getGatewayUiState(
status: OpenClawStatus | null,
): GatewayUiState {
if (!status) {
return {
canManageAgents: false,
controlPlaneDegraded: false,
controlPlaneBusy: false,
}
}
const controlPlaneBusy =
status.controlPlaneStatus === 'connecting' ||
status.controlPlaneStatus === 'reconnecting' ||
status.controlPlaneStatus === 'recovering'
return {
canManageAgents:
status.status === 'running' && status.controlPlaneStatus === 'connected',
controlPlaneBusy,
controlPlaneDegraded:
status.status === 'running' && status.controlPlaneStatus !== 'connected',
}
}
export function getLifecycleBanner(
action: GatewayLifecycleAction | null,
): string | null {
return action ? LIFECYCLE_BANNER_COPY[action] : null
}
export function canManageOpenClawAgents(
state: GatewayUiState,
lifecyclePending: boolean,
): boolean {
return state.canManageAgents && !lifecyclePending
}
export function shouldShowControlPlaneDegraded(
state: GatewayUiState,
lifecyclePending: boolean,
): boolean {
return state.controlPlaneDegraded && !lifecyclePending
}
export function getControlPlaneCopyForStatus(status: OpenClawStatus | null) {
return status
? getControlPlaneCopy(status.controlPlaneStatus)
: FALLBACK_CONTROL_PLANE_COPY
}
export function getVisibleOpenClawAgents(
enabled: boolean,
agents: AgentEntry[],
): AgentEntry[] {
return enabled ? agents : []
}
export function getAgentsLoading(input: {
adaptersLoading: boolean
harnessAgentsLoading: boolean
openClawAgentsLoading: boolean
}): boolean {
return (
input.adaptersLoading ||
input.harnessAgentsLoading ||
input.openClawAgentsLoading
)
}
export function getInlineError(input: {
lifecyclePending: boolean
pageError: string | null
openClawAgentsError: Error | null
adaptersError: Error | null
harnessAgentsError: Error | null
}): string | null {
if (input.lifecyclePending) return null
return (
input.pageError ??
input.openClawAgentsError?.message ??
input.adaptersError?.message ??
input.harnessAgentsError?.message ??
null
)
}

View File

@@ -1,30 +0,0 @@
import {
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
type HermesSupportedBrowserosProviderType,
} from '@browseros/shared/constants/hermes'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
export function isHermesSupportedProviderType(
providerType: ProviderType,
): providerType is HermesSupportedBrowserosProviderType {
return (
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly ProviderType[]
).includes(providerType)
}
/**
* Filters the user's global LLM providers down to ones Hermes can use.
* A provider qualifies when its type is in the Hermes-supported set
* AND it has an API key wired up. CLI-style providers (chatgpt-pro,
* github-copilot, qwen-code) and other unsupported types (browseros,
* ollama, lmstudio, bedrock, azure, google, moonshot) are filtered
* out — Hermes can't drive them today.
*/
export function getHermesSupportedProviders(
providers: LlmProviderConfig[],
): LlmProviderConfig[] {
return providers.filter(
(provider) =>
!!provider.apiKey && isHermesSupportedProviderType(provider.type),
)
}

View File

@@ -1,185 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { CheckCircle2, Loader2, Terminal, TriangleAlert } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
export interface OpenClawCliProvider {
id: string
displayName: string
description: string
models: readonly string[]
authLoginCommand: string
}
export interface OpenClawCliProviderAuthStatus {
installed: boolean
loggedIn: boolean
accountLabel?: string
subscriptionLabel?: string
error?: string
}
export interface OpenClawCliProviderOption {
id: string
type: string
name: string
modelId: string
}
const CLAUDE_CLI_PROVIDER: OpenClawCliProvider = {
id: 'claude-cli',
displayName: 'Anthropic Claude CLI',
description: 'Uses your Claude.ai subscription via the Claude Code CLI',
models: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'],
authLoginCommand: 'claude /login',
}
export const OPENCLAW_CLI_PROVIDERS: readonly OpenClawCliProvider[] = [
CLAUDE_CLI_PROVIDER,
]
export function findOpenClawCliProviderById(
id: string,
): OpenClawCliProvider | undefined {
return OPENCLAW_CLI_PROVIDERS.find((provider) => provider.id === id)
}
export function buildOpenClawCliProviderOptions(): OpenClawCliProviderOption[] {
return OPENCLAW_CLI_PROVIDERS.flatMap((provider) =>
provider.models.map((modelId) => ({
id: `${provider.id}/${modelId}`,
type: provider.id,
name: provider.displayName,
modelId,
})),
)
}
async function fetchCliProviderAuthStatus(
baseUrl: string,
providerId: string,
): Promise<OpenClawCliProviderAuthStatus> {
const res = await fetch(`${baseUrl}/claw/providers/${providerId}/auth-status`)
if (!res.ok) {
let message = `Auth status request failed (${res.status})`
try {
const body = (await res.json()) as { error?: string }
if (body.error) message = body.error
} catch {}
throw new Error(message)
}
return res.json() as Promise<OpenClawCliProviderAuthStatus>
}
export function useOpenClawCliProviderAuthStatus(
providerId: string,
enabled: boolean,
) {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
return useQuery<OpenClawCliProviderAuthStatus, Error>({
queryKey: ['openclaw-cli-auth', baseUrl, providerId],
queryFn: () => fetchCliProviderAuthStatus(baseUrl as string, providerId),
enabled: !!baseUrl && !urlLoading && enabled,
refetchInterval: enabled ? 2000 : false,
})
}
interface OpenClawCliProviderStatusPanelProps {
provider: OpenClawCliProvider
status: OpenClawCliProviderAuthStatus | undefined
loading: boolean
fetchError: Error | null
onConnect: () => void
}
export const OpenClawCliProviderStatusPanel: FC<
OpenClawCliProviderStatusPanelProps
> = ({ provider, status, loading, fetchError, onConnect }) => {
// Initial fetch (no data yet).
if (loading && !status) {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">
Checking {provider.displayName} status
</span>
</div>
)
}
if (fetchError) {
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
<TriangleAlert className="mt-0.5 size-4 text-destructive" />
<div>
<div className="font-medium text-destructive">
Could not read {provider.displayName} status
</div>
<div className="text-muted-foreground text-xs">
{fetchError.message}
</div>
</div>
</div>
)
}
if (!status) return null
// Install failed or binary missing.
if (!status.installed) {
return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm">
<TriangleAlert className="mt-0.5 size-4 text-amber-600" />
<div>
<div className="font-medium">
{provider.displayName} not installed
</div>
<div className="text-muted-foreground text-xs">
The gateway will try to install it on the next restart. If this
persists, check your network and the gateway logs.
</div>
</div>
</div>
)
}
// Happy path.
if (status.loggedIn) {
const identityBits = [
status.accountLabel,
status.subscriptionLabel ? `(${status.subscriptionLabel})` : null,
].filter(Boolean)
const identity = identityBits.length > 0 ? identityBits.join(' ') : 'Ready'
return (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/5 px-3 py-2 text-sm">
<CheckCircle2 className="size-4 text-emerald-600" />
<div className="min-w-0 flex-1">
<div className="font-medium">Connected to {provider.displayName}</div>
<div className="truncate text-muted-foreground text-xs">
{identity}
</div>
</div>
</div>
)
}
// Installed but not logged in.
return (
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 px-3 py-3 text-sm">
<div>
<div className="font-medium">{provider.displayName} not set up</div>
<div className="text-muted-foreground text-xs">
{provider.description}
</div>
{status.error && (
<div className="mt-1 text-destructive text-xs">{status.error}</div>
)}
</div>
<Button size="sm" variant="outline" onClick={onConnect} className="w-fit">
<Terminal className="mr-1 size-4" />
Connect {provider.displayName}
</Button>
</div>
)
}

View File

@@ -1,38 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { buildAgentApiUrl } from './agent-api-url'
import { mapHarnessAgentToEntry } from './agent-harness-types'
describe('mapHarnessAgentToEntry', () => {
it('maps created harness agents into chat-compatible entries', () => {
expect(
mapHarnessAgentToEntry({
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}),
).toEqual({
agentId: 'agent-1',
name: 'Review bot',
workspace: 'codex:main',
model: 'gpt-5.5',
source: 'agent-harness',
})
})
})
describe('buildAgentApiUrl', () => {
it('does not add a trailing slash for the harness root route', () => {
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/')).toBe(
'http://127.0.0.1:9105/agents',
)
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/adapters')).toBe(
'http://127.0.0.1:9105/agents/adapters',
)
})
})

View File

@@ -1,470 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { buildAgentApiUrl } from './agent-api-url'
import {
type AgentHarnessStreamEvent,
type CreateHarnessAgentInput,
type HarnessAdapterDescriptor,
type HarnessAgent,
type HarnessAgentHistoryPage,
type HarnessQueuedMessage,
mapHarnessAgentToEntry,
} from './agent-harness-types'
import type { OpenClawStatus } from './useOpenClaw'
/**
* Combined response shape of `GET /agents`. The page polls this once
* and consumes both fields, replacing the dedicated `/claw/status`
* poll the previous design carried.
*/
interface HarnessAgentsResponse {
agents: HarnessAgent[]
gateway: OpenClawStatus | null
}
export type { AgentHarnessStreamEvent }
export const AGENT_QUERY_KEYS = {
adapters: 'agent-harness-adapters',
agents: 'agent-harness-agents',
/** Outputs-rail data for one agent — `[agentOutputs, baseUrl, agentId]`. */
agentOutputs: 'agent-harness-agent-outputs',
/** Per-turn artifact-card files — `[agentTurnFiles, baseUrl, agentId, turnId]`. */
agentTurnFiles: 'agent-harness-agent-turn-files',
/** Single-file preview payload — `[filePreview, baseUrl, fileId]`. */
filePreview: 'agent-harness-file-preview',
} as const
export async function agentsFetch<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(buildAgentApiUrl(baseUrl, path), init)
if (!res.ok) {
let message = `Request failed with status ${res.status}`
try {
const body = (await res.json()) as { error?: string }
if (body.error) message = body.error
} catch {}
throw new Error(message)
}
return res.json() as Promise<T>
}
export function useAgentAdapters(enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<HarnessAdapterDescriptor[], Error>({
queryKey: [AGENT_QUERY_KEYS.adapters, baseUrl],
queryFn: async () => {
const data = await agentsFetch<{ adapters: HarnessAdapterDescriptor[] }>(
baseUrl as string,
'/adapters',
)
return data.adapters ?? []
},
enabled: Boolean(baseUrl) && !urlLoading && enabled,
})
return {
adapters: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useHarnessAgents(enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<HarnessAgentsResponse, Error>({
queryKey: [AGENT_QUERY_KEYS.agents, baseUrl],
queryFn: async () => {
const data = await agentsFetch<HarnessAgentsResponse>(
baseUrl as string,
'/',
)
return {
agents: data.agents ?? [],
gateway: data.gateway ?? null,
}
},
enabled: Boolean(baseUrl) && !urlLoading && enabled,
// Poll every 5s so the per-agent liveness state (working / idle /
// asleep / error) and last-used timestamps stay fresh without a
// websocket. `refetchIntervalInBackground: false` lets a hidden
// tab go quiet — react-query's default, made explicit.
refetchInterval: 5_000,
refetchIntervalInBackground: false,
})
return {
agents: (query.data?.agents ?? []).map(mapHarnessAgentToEntry),
harnessAgents: query.data?.agents ?? [],
gateway: query.data?.gateway ?? null,
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useCreateHarnessAgent() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateHarnessAgentInput) => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
const data = await agentsFetch<{ agent: HarnessAgent }>(baseUrl, '/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return data.agent
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [AGENT_QUERY_KEYS.agents],
})
},
})
}
/**
* Apply a partial update to a harness agent. Used by the pin-toggle
* star and (eventually) the inline rename UI. Optimistically writes
* the patch into the listing query cache so the row updates instantly,
* then rolls back if the server rejects the change.
*/
export function useUpdateHarnessAgent() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: {
agentId: string
patch: { name?: string; pinned?: boolean }
}) => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
const data = await agentsFetch<{ agent: HarnessAgent }>(
baseUrl,
`/${encodeURIComponent(input.agentId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input.patch),
},
)
return data.agent
},
onMutate: async ({ agentId, patch }) => {
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
if (!previous) return { previous: undefined }
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
...previous,
agents: previous.agents.map((agent) =>
agent.id === agentId ? { ...agent, ...patch } : agent,
),
})
return { previous }
},
onError: (_err, _vars, context) => {
if (!context?.previous) return
queryClient.setQueryData(
[AGENT_QUERY_KEYS.agents, baseUrl],
context.previous,
)
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: [AGENT_QUERY_KEYS.agents],
})
},
})
}
export function useDeleteHarnessAgent() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (agentId: string) => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
return agentsFetch<{ success: boolean }>(
baseUrl,
`/${encodeURIComponent(agentId)}`,
{ method: 'DELETE' },
)
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [AGENT_QUERY_KEYS.agents],
})
},
})
}
export async function chatWithHarnessAgent(
agentId: string,
message: string,
signal?: AbortSignal,
attachments?: ReadonlyArray<unknown>,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
...(attachments && attachments.length > 0 ? { attachments } : {}),
}),
signal,
})
}
/**
* Subscribe to an existing turn (the server's `ActiveTurnRegistry`
* decoupled the turn lifecycle from POST /chat). `lastSeq` lets the
* client resume after a disconnect — the server replays buffered
* frames with seq > lastSeq, then tails new ones.
*/
export async function attachToHarnessTurn(
agentId: string,
options: { turnId?: string; lastSeq?: number; signal?: AbortSignal } = {},
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
const url = new URL(
`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat/stream`,
)
if (options.turnId) url.searchParams.set('turnId', options.turnId)
const headers: Record<string, string> = {}
if (typeof options.lastSeq === 'number') {
headers['Last-Event-ID'] = String(options.lastSeq)
}
return fetch(url.toString(), { signal: options.signal, headers })
}
export interface HarnessActiveTurnInfo {
turnId: string
agentId: string
sessionId: 'main'
status: 'running' | 'done' | 'error' | 'cancelled'
lastSeq: number
startedAt: number
endedAt?: number
/** User message that kicked off the turn; null when not captured. */
prompt: string | null
}
/**
* Discover an in-flight turn for an agent. Used on chat mount so the
* UI reattaches instead of starting a new turn after a tab/refresh.
*/
export async function fetchActiveHarnessTurn(
agentId: string,
): Promise<HarnessActiveTurnInfo | null> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(
`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat/active`,
)
if (!response.ok) return null
const body = (await response.json()) as {
active: HarnessActiveTurnInfo | null
}
return body.active
}
/**
* Stop button. Hits the explicit cancel endpoint instead of just
* aborting the fetch (which now only detaches *this* subscriber from
* the buffer; the underlying turn would otherwise keep running).
*/
export async function cancelHarnessTurn(
agentId: string,
options: { turnId?: string; reason?: string } = {},
): Promise<{ cancelled: boolean }> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(
`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat/cancel`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(options.turnId ? { turnId: options.turnId } : {}),
...(options.reason ? { reason: options.reason } : {}),
}),
},
)
if (!response.ok) return { cancelled: false }
return (await response.json()) as { cancelled: boolean }
}
export async function fetchHarnessAgentHistory(
agentId: string,
): Promise<HarnessAgentHistoryPage> {
const baseUrl = await getAgentServerUrl()
return agentsFetch<HarnessAgentHistoryPage>(
baseUrl,
`/${encodeURIComponent(agentId)}/sessions/main/history`,
)
}
export interface EnqueueMessageInput {
message: string
attachments?: ReadonlyArray<unknown>
}
export async function enqueueHarnessMessage(
agentId: string,
input: EnqueueMessageInput,
): Promise<HarnessQueuedMessage> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(
`${baseUrl}/agents/${encodeURIComponent(agentId)}/queue`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: input.message,
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),
}),
},
)
if (!response.ok) {
let message = `Request failed with status ${response.status}`
try {
const body = (await response.json()) as { error?: string }
if (body.error) message = body.error
} catch {}
throw new Error(message)
}
const body = (await response.json()) as { queued: HarnessQueuedMessage }
return body.queued
}
export async function removeHarnessQueuedMessage(
agentId: string,
messageId: string,
): Promise<{ removed: boolean }> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(
`${baseUrl}/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(
messageId,
)}`,
{ method: 'DELETE' },
)
if (!response.ok) return { removed: false }
return (await response.json()) as { removed: boolean }
}
/**
* Optimistic enqueue: writes the new queued message into the listing
* cache immediately so the queue panel reflects the change without
* waiting for the next poll. Rolls back if the server rejects.
*/
export function useEnqueueHarnessMessage() {
const { baseUrl } = useAgentServerUrl()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: { agentId: string } & EnqueueMessageInput) =>
enqueueHarnessMessage(input.agentId, input),
onMutate: async (input) => {
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
if (!previous) return { previous: undefined }
const optimistic: HarnessQueuedMessage = {
id: `optimistic-${Math.random().toString(36).slice(2, 10)}`,
createdAt: Date.now(),
message: input.message,
}
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
...previous,
agents: previous.agents.map((agent) =>
agent.id === input.agentId
? { ...agent, queue: [...(agent.queue ?? []), optimistic] }
: agent,
),
})
return { previous }
},
onError: (_err, _vars, context) => {
if (!context?.previous) return
queryClient.setQueryData(
[AGENT_QUERY_KEYS.agents, baseUrl],
context.previous,
)
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: [AGENT_QUERY_KEYS.agents],
})
},
})
}
/**
* Optimistic queue removal mirror of `useEnqueueHarnessMessage`.
*/
export function useRemoveHarnessQueuedMessage() {
const { baseUrl } = useAgentServerUrl()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: { agentId: string; messageId: string }) =>
removeHarnessQueuedMessage(input.agentId, input.messageId),
onMutate: async (input) => {
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
if (!previous) return { previous: undefined }
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
...previous,
agents: previous.agents.map((agent) =>
agent.id === input.agentId
? {
...agent,
queue: (agent.queue ?? []).filter(
(entry) => entry.id !== input.messageId,
),
}
: agent,
),
})
return { previous }
},
onError: (_err, _vars, context) => {
if (!context?.previous) return
queryClient.setQueryData(
[AGENT_QUERY_KEYS.agents, baseUrl],
context.previous,
)
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: [AGENT_QUERY_KEYS.agents],
})
},
})
}

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
export interface AgentEntry {
@@ -6,7 +7,6 @@ export interface AgentEntry {
name: string
workspace: string
model?: unknown
source?: 'openclaw' | 'agent-harness'
}
export interface OpenClawStatus {
@@ -41,7 +41,6 @@ export interface OpenClawAgentMutationInput {
baseUrl?: string
apiKey?: string
modelId?: string
supportsImages?: boolean
}
export interface OpenClawSetupInput {
@@ -50,10 +49,6 @@ export interface OpenClawSetupInput {
baseUrl?: string
apiKey?: string
modelId?: string
// Mirrors LlmProviderConfig.supportsImages — pass-through so the gateway
// can declare the model's input modalities correctly when persisting the
// custom-provider config.
supportsImages?: boolean
}
export function getModelDisplayName(model: unknown): string | undefined {
@@ -64,8 +59,14 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
podmanOverrides: 'openclaw-podman-overrides',
} as const
export interface PodmanOverrides {
podmanPath: string | null
effectivePodmanPath: string
}
export type GatewayLifecycleAction =
| 'setup'
| 'start'
@@ -98,10 +99,7 @@ async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
return (data.agents ?? []).map((agent) => ({
...agent,
source: 'openclaw',
}))
return data.agents ?? []
}
async function invalidateOpenClawQueries(
@@ -264,6 +262,50 @@ export function useOpenClawMutations() {
}
}
export function usePodmanOverrides() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const queryClient = useQueryClient()
const query = useQuery<PodmanOverrides, Error>({
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
queryFn: () =>
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
enabled: !!baseUrl && !urlLoading,
})
const saveMutation = useMutation({
mutationFn: async (podmanPath: string | null) =>
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath }),
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.status],
}),
])
},
})
return {
overrides: query.data ?? null,
loading: query.isLoading || urlLoading,
error: (query.error ?? urlError) as Error | null,
saving: saveMutation.isPending,
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
clearOverrides: () => saveMutation.mutateAsync(null),
}
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
@@ -318,3 +360,19 @@ export function buildChatHistoryFromTurns(
return messages
}
export async function chatWithAgent(
agentId: string,
message: string,
sessionKey?: string,
history: OpenClawChatHistoryMessage[] = [],
signal?: AbortSignal,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, sessionKey, history }),
signal,
})
}

View File

@@ -85,8 +85,7 @@ export const SidebarLayout: FC = () => {
return (
<RpcClientProvider>
{/* pl-14 offsets all content by the collapsed sidebar width (w-14 = 56px) so it never sits under the rail */}
<div className="relative min-h-screen bg-background pl-14">
<div className="relative min-h-screen bg-background">
{/* Sidebar - fixed overlay */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover interactions needed */}
<div
@@ -97,6 +96,7 @@ export const SidebarLayout: FC = () => {
<AppSidebar expanded={sidebarOpen} onOpenShortcuts={openShortcuts} />
</div>
{/* Main content - full width, centered */}
{location.pathname === '/home/chat' ? (
<main className="relative h-dvh overflow-hidden">
<Outlet />

View File

@@ -8,7 +8,6 @@ import {
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { CloudSyncDisclosure } from '@/components/auth/CloudSyncDisclosure'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
@@ -200,8 +199,6 @@ export const LoginPage: FC = () => {
)}
Continue with Google
</Button>
<CloudSyncDisclosure />
</CardContent>
</Card>
)

View File

@@ -1,8 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
export const MEMORY_QUERY_KEY = 'memory'
async function fetchMemory(baseUrl: string): Promise<string> {
const response = await fetch(`${baseUrl}/memory`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
@@ -32,7 +30,7 @@ export function useMemoryContent() {
const queryClient = useQueryClient()
const { data, isLoading, error, refetch } = useQuery<string, Error>({
queryKey: [MEMORY_QUERY_KEY, baseUrl],
queryKey: ['memory', baseUrl],
queryFn: () => fetchMemory(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
})
@@ -40,7 +38,7 @@ export function useMemoryContent() {
const saveMutation = useMutation({
mutationFn: (content: string) => saveMemory(baseUrl as string, content),
onSuccess: (_data, content) => {
queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], content)
queryClient.setQueryData(['memory', baseUrl], content)
},
})

View File

@@ -1,180 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Brain, FileText, Loader2, RotateCcw } from 'lucide-react'
import { type FC, type ReactNode, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { MEMORY_QUERY_KEY } from '../memory/useMemoryContent'
import { SOUL_QUERY_KEY } from '../soul/useSoulContent'
type ResetTarget = 'memory' | 'soul'
type ResetAction = {
target: ResetTarget
title: string
description: string
buttonLabel: string
icon: ReactNode
}
async function deleteServerResource(
baseUrl: string,
resource: ResetTarget,
): Promise<void> {
const response = await fetch(`${baseUrl}/${resource}`, { method: 'DELETE' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
}
export const ResetDataPage: FC = () => {
const {
baseUrl,
isLoading: isUrlLoading,
error: urlError,
} = useAgentServerUrl()
const queryClient = useQueryClient()
const [pendingAction, setPendingAction] = useState<ResetAction | null>(null)
const resetMutation = useMutation({
mutationFn: async (target: ResetTarget) => {
if (!baseUrl) throw new Error('BrowserOS server URL is unavailable')
await deleteServerResource(baseUrl, target)
return target
},
onSuccess: async (target) => {
if (target === 'memory') {
queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], '')
}
await queryClient.invalidateQueries({
queryKey: target === 'memory' ? [MEMORY_QUERY_KEY] : [SOUL_QUERY_KEY],
})
toast.success(target === 'memory' ? 'Memory reset' : 'SOUL.md reset')
},
onError: (_error, target) => {
toast.error(
target === 'memory'
? 'Failed to reset memory'
: 'Failed to reset SOUL.md',
)
},
})
const actions: ResetAction[] = [
{
target: 'memory',
title: 'Reset memory?',
description:
'This deletes CORE.md and daily memory files. This cannot be undone.',
buttonLabel: 'Reset memory',
icon: <Brain className="h-4 w-4 text-muted-foreground" />,
},
{
target: 'soul',
title: 'Reset SOUL.md?',
description:
'This replaces SOUL.md with the default template. This cannot be undone.',
buttonLabel: 'Reset SOUL.md',
icon: <FileText className="h-4 w-4 text-muted-foreground" />,
},
]
const isBusy = isUrlLoading || resetMutation.isPending
const disabled = isBusy || Boolean(urlError) || !baseUrl
const handleConfirm = () => {
if (!pendingAction) return
resetMutation.mutate(pendingAction.target)
setPendingAction(null)
}
return (
<div className="mx-auto w-full max-w-3xl space-y-6 p-6">
<div>
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RotateCcw className="h-4 w-4" />
<span className="font-medium text-xs uppercase tracking-wider">
Reset
</span>
</div>
<h1 className="font-semibold text-2xl">Reset Data</h1>
</div>
{urlError ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
<p className="text-destructive text-sm">
BrowserOS server is unavailable.
</p>
</div>
) : null}
<div className="space-y-3">
{actions.map((action) => (
<div
key={action.target}
className="flex flex-col gap-3 rounded-lg border bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-center gap-3">
{action.icon}
<div className="min-w-0">
<h2 className="font-medium text-sm">{action.buttonLabel}</h2>
<p className="mt-1 text-muted-foreground text-xs">
{action.description}
</p>
</div>
</div>
<Button
type="button"
variant="destructive"
size="sm"
className="shrink-0"
disabled={disabled}
onClick={() => setPendingAction(action)}
>
{resetMutation.isPending &&
resetMutation.variables === action.target ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
{action.buttonLabel}
</Button>
</div>
))}
</div>
<AlertDialog
open={Boolean(pendingAction)}
onOpenChange={(open) => {
if (!open) setPendingAction(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pendingAction?.title}</AlertDialogTitle>
<AlertDialogDescription>
{pendingAction?.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{pendingAction?.buttonLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More