mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 16:14:28 +00:00
Compare commits
76 Commits
feat/phoen
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be59dccdd | ||
|
|
dad2331448 | ||
|
|
d7e1125db3 | ||
|
|
8b6483a633 | ||
|
|
f54eff4543 | ||
|
|
f1ebfa5232 | ||
|
|
b89ea201fa | ||
|
|
4e405681a7 | ||
|
|
b445615d61 | ||
|
|
d68e8905fe | ||
|
|
e89fccd997 | ||
|
|
805ae8e607 | ||
|
|
833baec84d | ||
|
|
7a2a8e09bc | ||
|
|
6f8da5b7fb | ||
|
|
50cbe48558 | ||
|
|
d81b99c8e3 | ||
|
|
86cb03a1fc | ||
|
|
7765d99c73 | ||
|
|
db5e55a174 | ||
|
|
fbae45eb97 | ||
|
|
554fcd7c06 | ||
|
|
eed158eca0 | ||
|
|
d61d6fc8a9 | ||
|
|
d383b5e344 | ||
|
|
ce4bb44083 | ||
|
|
0d56815cba | ||
|
|
c07d3d95d4 | ||
|
|
32530ec418 | ||
|
|
e7105ae50b | ||
|
|
1d42a973ea | ||
|
|
921a797c5b | ||
|
|
d94597bbf9 | ||
|
|
ecc6bac070 | ||
|
|
84e2739663 | ||
|
|
974e7e9b86 | ||
|
|
19e07c086f | ||
|
|
ab354d7dd7 | ||
|
|
0e779fa344 | ||
|
|
dfbce48994 | ||
|
|
7c942e91ce | ||
|
|
1ff92c44b3 | ||
|
|
c81906ecbf | ||
|
|
ffc0f09c86 | ||
|
|
7fb53c9921 | ||
|
|
d38b01a8c7 | ||
|
|
ff36c8412b | ||
|
|
fd5aba249b | ||
|
|
492f3fcdf2 | ||
|
|
cb0c0dd0c1 | ||
|
|
8712f89f18 | ||
|
|
ba60bf466f | ||
|
|
26afb826c6 | ||
|
|
b2340c8afa | ||
|
|
790a270f47 | ||
|
|
84a79ba0a1 | ||
|
|
6e3306f5e5 | ||
|
|
c244462b29 | ||
|
|
ebf97f74f6 | ||
|
|
561f2baf97 | ||
|
|
df0f45dd29 | ||
|
|
edfc5c751c | ||
|
|
471256f31c | ||
|
|
4c90ca696b | ||
|
|
f2ac87d7c3 | ||
|
|
231bd6821d | ||
|
|
a228c278c6 | ||
|
|
e2ec1991cf | ||
|
|
0c84547e8f | ||
|
|
2ff5c12840 | ||
|
|
d87422eea1 | ||
|
|
1946ca0cf8 | ||
|
|
754f7d0e1d | ||
|
|
85bb3f7b42 | ||
|
|
cb32b8191d | ||
|
|
7a92654abc |
152
.claude/skills/ask-internal/SKILL.md
Normal file
152
.claude/skills/ask-internal/SKILL.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
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.
|
||||
208
.claude/skills/document-internal/SKILL.md
Normal file
208
.claude/skills/document-internal/SKILL.md
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
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.
|
||||
51
.claude/skills/document-internal/seeds/README.md
Normal file
51
.claude/skills/document-internal/seeds/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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.>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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.>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
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
157
.github/workflows/build-agent.yml
vendored
@@ -1,157 +0,0 @@
|
||||
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"
|
||||
67
.github/workflows/eval-weekly.yml
vendored
67
.github/workflows/eval-weekly.yml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
config:
|
||||
description: 'Eval config file (relative to apps/eval/)'
|
||||
required: false
|
||||
default: 'configs/browseros-agent-weekly.json'
|
||||
default: 'configs/legacy/browseros-agent-weekly.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -42,10 +42,25 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: packages/browseros-agent
|
||||
run: bun install --ignore-scripts && bun run build:agent-sdk
|
||||
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
|
||||
run: pip install agisdk requests
|
||||
# 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
|
||||
@@ -60,21 +75,44 @@ 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
|
||||
- name: Run eval and publish to R2
|
||||
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
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
||||
# 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' }}
|
||||
run: |
|
||||
echo "Running eval with config: $EVAL_CONFIG"
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts -c "$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"
|
||||
|
||||
- name: Upload runs to R2
|
||||
- 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
|
||||
if: success()
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
@@ -83,10 +121,7 @@ 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 }}
|
||||
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"
|
||||
run: bun run src/index.ts publish --run "$EVAL_RUN_DIR" --target r2
|
||||
|
||||
- name: Generate trend report
|
||||
if: success()
|
||||
@@ -101,9 +136,17 @@ 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 report as artifact
|
||||
- name: Upload trend 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
|
||||
|
||||
165
.github/workflows/release-agent-sdk.yml
vendored
165
.github/workflows/release-agent-sdk.yml
vendored
@@ -1,168 +1,11 @@
|
||||
name: Release BrowserOS Agent SDK
|
||||
name: Release BrowserOS Agent SDK (disabled)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-agent-sdk
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
disabled:
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent/packages/agent-sdk
|
||||
|
||||
steps:
|
||||
- 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 }}
|
||||
- run: echo "Agent SDK publishing is disabled."
|
||||
|
||||
62
.github/workflows/sync-internal-docs.yml
vendored
Normal file
62
.github/workflows/sync-internal-docs.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
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
|
||||
18
.github/workflows/test.yml
vendored
18
.github/workflows/test.yml
vendored
@@ -54,28 +54,24 @@ jobs:
|
||||
command: (cd apps/server && bun run test:integration)
|
||||
junit_path: test-results/server-integration.xml
|
||||
needs_browser: true
|
||||
- suite: server-sdk
|
||||
command: (cd apps/server && bun run test:sdk)
|
||||
junit_path: test-results/server-sdk.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-root
|
||||
command: (cd apps/server && bun run test:root)
|
||||
junit_path: test-results/server-root.xml
|
||||
needs_browser: false
|
||||
- suite: agent
|
||||
command: bun run test:agent
|
||||
command: (cd apps/agent && bun run test)
|
||||
junit_path: test-results/agent.xml
|
||||
needs_browser: false
|
||||
- suite: eval
|
||||
command: bun run test:eval
|
||||
command: (cd apps/eval && bun run test)
|
||||
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 test:build
|
||||
command: bun run ./scripts/run-bun-test.ts ./scripts/build
|
||||
junit_path: test-results/build.xml
|
||||
needs_browser: false
|
||||
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -0,0 +1,4 @@
|
||||
[submodule ".internal-docs"]
|
||||
path = .internal-docs
|
||||
url = git@github.com:browseros-ai/internal-docs.git
|
||||
branch = main
|
||||
|
||||
1
.internal-docs
Submodule
1
.internal-docs
Submodule
Submodule .internal-docs added at 590799ae1c
15
README.md
15
README.md
@@ -188,6 +188,21 @@ 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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BrowserOS Agent
|
||||
|
||||
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, evaluation framework, and SDK.
|
||||
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, and evaluation framework.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
@@ -12,7 +12,6 @@ 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)
|
||||
```
|
||||
@@ -23,7 +22,6 @@ 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 |
|
||||
|
||||
@@ -81,14 +79,15 @@ 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, generate agent code, and sync the VM cache
|
||||
# Install deps and generate agent code
|
||||
bun run dev:setup
|
||||
|
||||
# Start the full dev environment
|
||||
bun run dev:watch
|
||||
```
|
||||
|
||||
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
|
||||
`dev:watch` starts the server immediately. OpenClaw VM/image prewarm runs from
|
||||
the server startup path and pulls the configured GHCR image on demand.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -158,9 +157,14 @@ bun run build:server # Build production server resource artifacts and u
|
||||
bun run build:agent # Build agent extension
|
||||
|
||||
# Test
|
||||
bun run test # Run standard tests
|
||||
bun run test:cdp # Run CDP-based tests
|
||||
bun run test:integration # Run integration tests
|
||||
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
|
||||
|
||||
# Quality
|
||||
bun run lint # Check with Biome
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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(' · ')
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import { Bot, Check, Plus } from 'lucide-react'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
@@ -17,6 +17,11 @@ 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 {
|
||||
@@ -29,54 +34,58 @@ 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-48 p-0">
|
||||
<PopoverContent side="bottom" align="start" className="w-64 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search providers..." className="h-9" />
|
||||
<CommandInput
|
||||
placeholder="Search providers or agents..."
|
||||
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>
|
||||
<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}
|
||||
/>
|
||||
{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',
|
||||
)}
|
||||
</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>
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<div className="border-border border-t p-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -96,3 +105,9 @@ 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} />
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
export type ChatProviderType = ProviderType | 'acp'
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
type: ProviderType
|
||||
type: ChatProviderType
|
||||
kind: 'llm' | 'acp'
|
||||
agentId?: string
|
||||
adapterName?: string
|
||||
modelLabel?: string
|
||||
modelControl?: 'runtime-supported' | 'best-effort'
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Bot, Loader2, Wrench } 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'
|
||||
}
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
if (usd < 0.005) return `$${usd.toFixed(4)}`
|
||||
return `$${usd.toFixed(2)}`
|
||||
}
|
||||
|
||||
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 space-y-1.5 text-muted-foreground text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{formatTimestamp(agent.lastMessageTimestamp)}</span>
|
||||
{agent.costUsd ? (
|
||||
<span className="tabular-nums opacity-70">
|
||||
{formatCost(agent.costUsd)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{agent.status === 'working' && agent.currentTool ? (
|
||||
<div className="flex items-center gap-1.5 text-[var(--accent-orange)]/70">
|
||||
<Loader2 className="size-3 shrink-0 animate-spin" />
|
||||
<span className="truncate">{agent.currentTool}</span>
|
||||
</div>
|
||||
) : agent.activitySummary ? (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground/60">
|
||||
<Wrench className="size-3 shrink-0" />
|
||||
<span className="truncate">{agent.activitySummary}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
)
|
||||
@@ -1,70 +1,71 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAdapterHealth,
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentCardCompact, AgentCardExpanded } from './AgentCard'
|
||||
import { HomeAgentCard } from './HomeAgentCard'
|
||||
|
||||
interface AgentCardDockProps {
|
||||
agents: AgentCardData[]
|
||||
agents: HarnessAgent[]
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
activeAgentId?: string
|
||||
onSelectAgent: (agentId: string) => void
|
||||
onCreateAgent?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function CreateAgentButton({
|
||||
compact,
|
||||
onCreateAgent,
|
||||
}: {
|
||||
compact?: boolean
|
||||
onCreateAgent: () => void
|
||||
}) {
|
||||
function CreateAgentButton({ onCreateAgent }: { onCreateAgent: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateAgent}
|
||||
className={cn(
|
||||
'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',
|
||||
'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)]',
|
||||
)}
|
||||
>
|
||||
<Plus className={compact ? 'size-3.5' : 'size-5'} />
|
||||
<span>{compact ? 'New' : 'Create agent'}</span>
|
||||
<Plus className="size-5" />
|
||||
<span>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 Card = compact ? AgentCardCompact : AgentCardExpanded
|
||||
const adapterHealth = new Map<HarnessAgentAdapter, HarnessAdapterHealth>()
|
||||
for (const descriptor of adapters) {
|
||||
if (descriptor.health) adapterHealth.set(descriptor.id, descriptor.health)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
compact
|
||||
? 'flex items-center gap-2 overflow-x-auto pb-1'
|
||||
: 'grid gap-4 md:grid-cols-3',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.agentId}
|
||||
<HomeAgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
active={agent.agentId === activeAgentId}
|
||||
onClick={() => onSelectAgent(agent.agentId)}
|
||||
adapter={agent.adapter}
|
||||
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
|
||||
active={agent.id === activeAgentId}
|
||||
onClick={() => onSelectAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
{onCreateAgent ? (
|
||||
<CreateAgentButton compact={compact} onCreateAgent={onCreateAgent} />
|
||||
<CreateAgentButton onCreateAgent={onCreateAgent} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,395 +1,258 @@
|
||||
import { ArrowLeft, Bot, Home } from 'lucide-react'
|
||||
import { ArrowLeft, PanelRight } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type {
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
cancelHarnessTurn,
|
||||
useAgentAdapters,
|
||||
useEnqueueHarnessMessage,
|
||||
useHarnessAgents,
|
||||
useRemoveHarnessQueuedMessage,
|
||||
useUpdateHarnessAgent,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { type ProducedFilesRailGroup, useAgentOutputs } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentRail } from './AgentRail'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import {
|
||||
OutputsRail,
|
||||
useOutputsRailOpen,
|
||||
} from './agent-conversation.outputs-rail'
|
||||
import { ClawChat } from './ClawChat'
|
||||
import { ConversationHeader } from './ConversationHeader'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
filterTurnsPersistedInHistory,
|
||||
flattenHistoryPages,
|
||||
mapHistoryToProducedFilesGroups,
|
||||
selectStripOnlyTurns,
|
||||
} from './claw-chat-types'
|
||||
import { consumePendingInitialMessage } from './pending-initial-message'
|
||||
import { QueuePanel } from './QueuePanel'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
import { useClawChatHistory } from './useClawChatHistory'
|
||||
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
||||
import { useOutboundQueue } from './useOutboundQueue'
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1 text-[11px] text-muted-foreground uppercase tracking-[0.18em]">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'Working on your request'
|
||||
? 'bg-amber-500'
|
||||
: status === 'Ready'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'Offline'
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-[var(--accent-orange)]',
|
||||
)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentIdentity({
|
||||
name,
|
||||
meta,
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
meta: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs leading-5">
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationHeader({
|
||||
agentName,
|
||||
agentMeta,
|
||||
status,
|
||||
backLabel,
|
||||
backTarget,
|
||||
onGoHome,
|
||||
}: {
|
||||
agentName: string
|
||||
agentMeta: string
|
||||
status: string
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
}) {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl lg:hidden"
|
||||
title={backLabel}
|
||||
>
|
||||
<BackIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={agentName} meta={agentMeta} />
|
||||
</div>
|
||||
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
|
||||
return (
|
||||
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">
|
||||
Agents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailList({
|
||||
activeAgentId,
|
||||
agents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId: string
|
||||
agents: AgentEntry[]
|
||||
onSelectAgent: (entry: AgentEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{agents.map((entry) => {
|
||||
const active = entry.agentId === activeAgentId
|
||||
const modelName = getAgentEntryMeta(entry)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(entry)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
active
|
||||
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
|
||||
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl',
|
||||
active
|
||||
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={entry.name} meta={modelName} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
|
||||
if (agent?.source === 'agent-harness') {
|
||||
return getModelDisplayName(agent.model) ?? 'ACP agent'
|
||||
}
|
||||
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
||||
}
|
||||
|
||||
function getConversationStatusCopy(status: string | undefined): string {
|
||||
if (status === 'running') return 'Ready'
|
||||
if (status === 'starting') return 'Connecting'
|
||||
if (status === 'error') return 'Attention'
|
||||
if (status === 'stopped') return 'Offline'
|
||||
return 'Setup'
|
||||
}
|
||||
|
||||
function AgentConversationController({
|
||||
agentId,
|
||||
initialMessage,
|
||||
onInitialMessageConsumed,
|
||||
status,
|
||||
agents,
|
||||
agentPathPrefix,
|
||||
createAgentPath,
|
||||
onOpenOutputsRail,
|
||||
}: {
|
||||
agentId: string
|
||||
initialMessage: string | null
|
||||
onInitialMessageConsumed: () => void
|
||||
status: ReturnType<typeof useAgentCommandData>['status']
|
||||
agents: AgentEntry[]
|
||||
agentPathPrefix: string
|
||||
createAgentPath: string
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const initialMessageSentRef = useRef<string | null>(null)
|
||||
const onInitialMessageConsumedRef = useRef(onInitialMessageConsumed)
|
||||
const [streamSessionKey, setStreamSessionKey] = useState<string | null>(null)
|
||||
const agent = agents.find((entry) => entry.agentId === agentId)
|
||||
const agentName = agent?.name || agentId || 'Agent'
|
||||
const isAgentHarnessAgent = agent?.source === 'agent-harness'
|
||||
const clawHistoryQuery = useClawChatHistory({
|
||||
agentId,
|
||||
sessionKey: streamSessionKey,
|
||||
enabled: Boolean(agent) && !isAgentHarnessAgent,
|
||||
})
|
||||
const harnessHistoryQuery = useHarnessChatHistory(
|
||||
agentId,
|
||||
Boolean(agent) && isAgentHarnessAgent,
|
||||
)
|
||||
// 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(
|
||||
isAgentHarnessAgent
|
||||
? harnessHistoryQuery.data
|
||||
? [harnessHistoryQuery.data]
|
||||
: []
|
||||
: (clawHistoryQuery.data?.pages ?? []),
|
||||
harnessHistoryQuery.data ? [harnessHistoryQuery.data] : [],
|
||||
),
|
||||
[
|
||||
clawHistoryQuery.data?.pages,
|
||||
harnessHistoryQuery.data,
|
||||
isAgentHarnessAgent,
|
||||
],
|
||||
[harnessHistoryQuery.data],
|
||||
)
|
||||
const chatHistory = useMemo(
|
||||
() => buildChatHistoryFromClawMessages(historyMessages),
|
||||
[historyMessages],
|
||||
)
|
||||
const resolvedSessionKey =
|
||||
streamSessionKey ??
|
||||
(isAgentHarnessAgent
|
||||
? null
|
||||
: (clawHistoryQuery.data?.pages?.[0]?.sessionKey ?? null))
|
||||
|
||||
// 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: isAgentHarnessAgent ? 'agent-harness' : 'openclaw',
|
||||
sessionKey: resolvedSessionKey,
|
||||
runtime: 'agent-harness',
|
||||
sessionKey: null,
|
||||
history: chatHistory,
|
||||
activeTurnId,
|
||||
onComplete: () => {
|
||||
if (isAgentHarnessAgent) {
|
||||
void harnessHistoryQuery.refetch()
|
||||
}
|
||||
},
|
||||
onSessionKeyChange: (sessionKey) => {
|
||||
setStreamSessionKey(sessionKey)
|
||||
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(
|
||||
() =>
|
||||
isAgentHarnessAgent
|
||||
? filterTurnsPersistedInHistory(turns, historyMessages)
|
||||
: turns,
|
||||
[historyMessages, isAgentHarnessAgent, turns],
|
||||
() => filterTurnsPersistedInHistory(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
)
|
||||
const outboundQueue = useOutboundQueue({
|
||||
agentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
enabled: Boolean(agent) && !isAgentHarnessAgent,
|
||||
})
|
||||
// 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
|
||||
|
||||
// Refetch history whenever a server-dispatched queue item completes.
|
||||
// The server worker streams the queued turn into OpenClaw directly, so
|
||||
// the client never observes the live tokens — we only see the new
|
||||
// assistant turn once the JSONL is updated. Watching the queue for
|
||||
// any 'sending' item dropping out is the cleanest "turn finalized"
|
||||
// signal we have without exposing per-turn SSE.
|
||||
const previousSendingIdsRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (isAgentHarnessAgent) return
|
||||
const currentSending = new Set(
|
||||
outboundQueue.queue
|
||||
.filter((item) => item.status === 'sending')
|
||||
.map((item) => item.id),
|
||||
)
|
||||
const dropped = [...previousSendingIdsRef.current].filter(
|
||||
(id) => !currentSending.has(id),
|
||||
)
|
||||
previousSendingIdsRef.current = currentSending
|
||||
if (dropped.length > 0) {
|
||||
void clawHistoryQuery.refetch()
|
||||
}
|
||||
}, [clawHistoryQuery, isAgentHarnessAgent, outboundQueue.queue])
|
||||
|
||||
const disabled =
|
||||
!agent || (!isAgentHarnessAgent && status?.status !== 'running')
|
||||
// Two-part gate: cover both "still fetching" AND "just got enabled but
|
||||
// hasn't started fetching yet". When `enabled` flips true (baseUrl
|
||||
// resolves), there's a render frame where React Query reports
|
||||
// isLoading=false but hasn't run the queryFn yet — `isFetched` is still
|
||||
// false. Without this we render EmptyState during that one frame.
|
||||
const isInitialLoading =
|
||||
!isAgentHarnessAgent &&
|
||||
(clawHistoryQuery.isLoading ||
|
||||
(!clawHistoryQuery.isFetched && !clawHistoryQuery.isError))
|
||||
|
||||
const disabled = !agent
|
||||
const historyReady =
|
||||
(isAgentHarnessAgent &&
|
||||
(harnessHistoryQuery.isFetched || harnessHistoryQuery.isError)) ||
|
||||
(!isAgentHarnessAgent &&
|
||||
(clawHistoryQuery.isFetched || clawHistoryQuery.isError))
|
||||
harnessHistoryQuery.isFetched || harnessHistoryQuery.isError
|
||||
const initialMessageKey = initialMessage
|
||||
? `${agentId}:${initialMessage}`
|
||||
: null
|
||||
const error = isAgentHarnessAgent
|
||||
? (harnessHistoryQuery.error ?? null)
|
||||
: (clawHistoryQuery.error ?? null)
|
||||
const error = harnessHistoryQuery.error ?? null
|
||||
|
||||
const enqueueRef = useRef(outboundQueue.enqueue)
|
||||
enqueueRef.current = outboundQueue.enqueue
|
||||
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 ||
|
||||
disabled ||
|
||||
!historyReady
|
||||
) {
|
||||
if (!query || initialMessageSentRef.current === initialMessageKey) {
|
||||
return
|
||||
}
|
||||
|
||||
initialMessageSentRef.current = initialMessageKey
|
||||
onInitialMessageConsumedRef.current()
|
||||
if (isAgentHarnessAgent) {
|
||||
void sendRef.current({ text: query })
|
||||
} else {
|
||||
enqueueRef.current({ text: query })
|
||||
}
|
||||
}, [
|
||||
disabled,
|
||||
historyReady,
|
||||
initialMessage,
|
||||
initialMessageKey,
|
||||
isAgentHarnessAgent,
|
||||
])
|
||||
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-col overflow-hidden">
|
||||
<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={
|
||||
isAgentHarnessAgent ? harnessHistoryQuery.isLoading : isInitialLoading
|
||||
}
|
||||
isInitialLoading={harnessHistoryQuery.isLoading}
|
||||
error={error}
|
||||
hasNextPage={
|
||||
isAgentHarnessAgent ? false : Boolean(clawHistoryQuery.hasNextPage)
|
||||
}
|
||||
isFetchingNextPage={
|
||||
isAgentHarnessAgent ? false : clawHistoryQuery.isFetchingNextPage
|
||||
}
|
||||
onFetchNextPage={() => {
|
||||
if (!isAgentHarnessAgent) {
|
||||
void clawHistoryQuery.fetchNextPage()
|
||||
}
|
||||
}}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
onFetchNextPage={() => {}}
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
onRetry={() => {
|
||||
if (isAgentHarnessAgent) {
|
||||
void harnessHistoryQuery.refetch()
|
||||
} else {
|
||||
void clawHistoryQuery.refetch()
|
||||
}
|
||||
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">
|
||||
<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}
|
||||
@@ -404,31 +267,30 @@ function AgentConversationController({
|
||||
name: a.name,
|
||||
dataUrl: a.dataUrl,
|
||||
}))
|
||||
if (isAgentHarnessAgent) {
|
||||
void send({ text: input.text, attachments, attachmentPreviews })
|
||||
} else {
|
||||
outboundQueue.enqueue({
|
||||
text: input.text,
|
||||
// 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,
|
||||
attachmentPreviews,
|
||||
history: chatHistory,
|
||||
})
|
||||
return
|
||||
}
|
||||
void send({ text: input.text, attachments, attachmentPreviews })
|
||||
}}
|
||||
onCreateAgent={() => navigate(createAgentPath)}
|
||||
onStop={handleStop}
|
||||
streaming={streaming}
|
||||
disabled={disabled}
|
||||
status={isAgentHarnessAgent ? 'running' : status?.status}
|
||||
attachmentsEnabled={!isAgentHarnessAgent}
|
||||
placeholder={`Message ${agentName}...`}
|
||||
outboundQueue={
|
||||
isAgentHarnessAgent ? undefined : outboundQueue.queue
|
||||
}
|
||||
onCancelQueued={
|
||||
isAgentHarnessAgent ? undefined : outboundQueue.cancel
|
||||
}
|
||||
onRetryQueued={
|
||||
isAgentHarnessAgent ? undefined : outboundQueue.retry
|
||||
status="running"
|
||||
attachmentsEnabled={true}
|
||||
placeholder={
|
||||
streaming
|
||||
? `Type to queue another message for ${agentName}...`
|
||||
: `Message ${agentName}...`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -444,6 +306,22 @@ interface AgentCommandConversationProps {
|
||||
createAgentPath?: string
|
||||
}
|
||||
|
||||
function inferAdapterFromEntry(
|
||||
entry: AgentEntry | undefined,
|
||||
): HarnessAgentAdapter | 'unknown' {
|
||||
if (!entry) return 'unknown'
|
||||
if (entry.source === 'agent-harness') {
|
||||
// Harness entries don't carry the adapter on AgentEntry; the rail
|
||||
// / header read the harness record directly. This branch only runs
|
||||
// before the harness query resolves, so 'unknown' is correct — the
|
||||
// tile's bot fallback renders until data arrives.
|
||||
return 'unknown'
|
||||
}
|
||||
// OpenClaw-only entries (no harness shadow) are deprecated in
|
||||
// practice but the rail still tolerates them.
|
||||
return 'openclaw'
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
variant = 'command',
|
||||
backPath = '/home',
|
||||
@@ -453,61 +331,192 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
const { agentId } = useParams<{ agentId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const { agents } = useAgentCommandData()
|
||||
const { harnessAgents } = useHarnessAgents()
|
||||
const { adapters } = useAgentAdapters()
|
||||
const updateAgent = useUpdateHarnessAgent()
|
||||
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const agentMeta = getAgentEntryMeta(agent)
|
||||
const harnessAgent = harnessAgents.find(
|
||||
(entry) => entry.id === resolvedAgentId,
|
||||
)
|
||||
const entry = agents.find((item) => item.agentId === resolvedAgentId)
|
||||
const fallbackName = entry?.name || resolvedAgentId || 'Agent'
|
||||
const fallbackAdapter = inferAdapterFromEntry(entry)
|
||||
const initialMessage = searchParams.get('q')
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
||||
const [outputsRailOpen, setOutputsRailOpen] =
|
||||
useOutputsRailOpen(resolvedAgentId)
|
||||
const railVisible = isOpenClawAgent && outputsRailOpen
|
||||
|
||||
// Deep-link target for the rail. Set when (a) the user clicks
|
||||
// View / +N on an inline file-card strip, or (b) an external nav
|
||||
// arrived with `?outputsTurn=<turnId>`. Cleared by the rail
|
||||
// itself once it has scrolled to + expanded the matching group.
|
||||
const urlOutputsTurn = searchParams.get('outputsTurn')
|
||||
const [focusTurnId, setFocusTurnId] = useState<string | null>(urlOutputsTurn)
|
||||
// If the URL param flips while we're already on this agent, sync.
|
||||
useEffect(() => {
|
||||
if (!urlOutputsTurn) return
|
||||
setFocusTurnId(urlOutputsTurn)
|
||||
if (isOpenClawAgent) setOutputsRailOpen(true)
|
||||
}, [urlOutputsTurn, isOpenClawAgent, setOutputsRailOpen])
|
||||
|
||||
const handleOpenOutputsRail = (turnId?: string | null) => {
|
||||
if (!isOpenClawAgent) return
|
||||
setOutputsRailOpen(true)
|
||||
setFocusTurnId(turnId ?? null)
|
||||
}
|
||||
const handleFocusTurnConsumed = () => {
|
||||
setFocusTurnId(null)
|
||||
if (urlOutputsTurn) {
|
||||
// Drop the URL param so a back-nav doesn't re-trigger the
|
||||
// scroll. `replace: true` keeps history clean.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('outputsTurn')
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
|
||||
const adapterId = harnessAgent?.adapter
|
||||
if (!adapterId) return null
|
||||
const descriptor = adapters.find((item) => item.id === adapterId)
|
||||
if (!descriptor?.health) return null
|
||||
return {
|
||||
healthy: descriptor.health.healthy,
|
||||
reason: descriptor.health.reason,
|
||||
}
|
||||
}, [adapters, harnessAgent?.adapter])
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
const handleSelectHarnessAgent = (target: HarnessAgent) => {
|
||||
navigate(`${agentPathPrefix}/${target.id}`)
|
||||
}
|
||||
|
||||
const statusCopy =
|
||||
agent?.source === 'agent-harness'
|
||||
? 'Ready'
|
||||
: getConversationStatusCopy(status?.status)
|
||||
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
|
||||
if (!target) return
|
||||
updateAgent.mutate({
|
||||
agentId: target.id,
|
||||
patch: { pinned: next },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
||||
<div className="mx-auto grid h-full w-full max-w-[1480px] lg:grid-cols-[288px_minmax(0,1fr)] lg:grid-rows-[3.5rem_minmax(0,1fr)]">
|
||||
<AgentRailHeader onGoHome={() => navigate(backPath)} />
|
||||
<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>
|
||||
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
agentMeta={agentMeta}
|
||||
status={statusCopy}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
onGoHome={() => navigate(backPath)}
|
||||
/>
|
||||
{/* Body grid: rail list + chat (+ outputs rail when an
|
||||
openclaw agent has it open). Columns share the same top
|
||||
edge as the band above so headers can never drift. */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]',
|
||||
railVisible
|
||||
? 'lg:grid-cols-[288px_minmax(0,1fr)_320px]'
|
||||
: 'lg:grid-cols-[288px_minmax(0,1fr)]',
|
||||
)}
|
||||
>
|
||||
<AgentRail
|
||||
agents={harnessAgents}
|
||||
adapters={adapters}
|
||||
activeAgentId={resolvedAgentId}
|
||||
onSelectAgent={handleSelectHarnessAgent}
|
||||
onPinToggle={(target, next) => handlePinToggle(target, next)}
|
||||
/>
|
||||
|
||||
<AgentRailList
|
||||
activeAgentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<AgentConversationController
|
||||
key={resolvedAgentId}
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
status={status}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
/>
|
||||
{railVisible ? (
|
||||
<OutputsRail
|
||||
agentId={resolvedAgentId}
|
||||
onClose={() => setOutputsRailOpen(false)}
|
||||
focusTurnId={focusTurnId}
|
||||
onFocusTurnConsumed={handleFocusTurnConsumed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgent,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import {
|
||||
useAgentAdapters,
|
||||
useHarnessAgents,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
||||
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
||||
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import { AgentCardDock } from './AgentCardDock'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import { buildAgentCardData } from './useAgentCardData'
|
||||
import {
|
||||
ConversationInput,
|
||||
type ConversationInputSendInput,
|
||||
} from './ConversationInput'
|
||||
import { orderHomeAgents } from './home-agent-card.helpers'
|
||||
import { setPendingInitialMessage } from './pending-initial-message'
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
@@ -38,11 +49,13 @@ function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
function RecentThreads({
|
||||
activeAgentId,
|
||||
agents,
|
||||
adapters,
|
||||
onOpenAgents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId?: string | null
|
||||
agents: AgentCardData[]
|
||||
agents: HarnessAgent[]
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
onOpenAgents: () => void
|
||||
onSelectAgent: (agentId: string) => void
|
||||
}) {
|
||||
@@ -68,6 +81,7 @@ function RecentThreads({
|
||||
</div>
|
||||
<AgentCardDock
|
||||
agents={agents}
|
||||
adapters={adapters}
|
||||
activeAgentId={activeAgentId ?? undefined}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onOpenAgents}
|
||||
@@ -79,28 +93,46 @@ function RecentThreads({
|
||||
export const AgentCommandHome: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const activeHint = useActiveHint()
|
||||
const { agents, status } = useAgentCommandData()
|
||||
// The conversation input still consumes the merged AgentEntry list
|
||||
// from the layout context (handles legacy /claw/agents entries that
|
||||
// haven't yet been backfilled into the harness store). The Recent
|
||||
// Agents grid below reads the richer harness payload directly.
|
||||
const { agents: legacyAgents, status } = useAgentCommandData()
|
||||
const { harnessAgents } = useHarnessAgents()
|
||||
const { adapters } = useAgentAdapters()
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||
const cardData = buildAgentCardData(agents, status?.status, undefined)
|
||||
|
||||
const orderedAgents = useMemo(
|
||||
() => orderHomeAgents(harnessAgents),
|
||||
[harnessAgents],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
if (selectedAgentId) {
|
||||
setSelectedAgentId(null)
|
||||
}
|
||||
if (legacyAgents.length === 0) {
|
||||
if (selectedAgentId) setSelectedAgentId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!selectedAgentId ||
|
||||
!agents.some((agent) => agent.agentId === selectedAgentId)
|
||||
!legacyAgents.some((agent) => agent.agentId === selectedAgentId)
|
||||
) {
|
||||
setSelectedAgentId(agents[0].agentId)
|
||||
setSelectedAgentId(legacyAgents[0].agentId)
|
||||
}
|
||||
}, [agents, selectedAgentId])
|
||||
}, [legacyAgents, selectedAgentId])
|
||||
|
||||
const handleSend = (input: { text: string }) => {
|
||||
const handleSend = (input: ConversationInputSendInput) => {
|
||||
if (!selectedAgentId) return
|
||||
// Stash text + attachments in the in-memory registry. Text also
|
||||
// travels in `?q=` so a hard refresh / shareable URL still works
|
||||
// for text-only prompts; attachments are registry-only because a
|
||||
// multi-megabyte dataUrl can't ride a URL search param. The chat
|
||||
// screen prefers the registry when both are present.
|
||||
setPendingInitialMessage({
|
||||
agentId: selectedAgentId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
navigate(
|
||||
`/home/agents/${selectedAgentId}?q=${encodeURIComponent(input.text)}`,
|
||||
)
|
||||
@@ -110,7 +142,7 @@ export const AgentCommandHome: FC = () => {
|
||||
setSelectedAgentId(agent.agentId)
|
||||
}
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
const selectedAgent = legacyAgents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
const selectedAgentReady = selectedAgent
|
||||
@@ -118,29 +150,35 @@ export const AgentCommandHome: FC = () => {
|
||||
: false
|
||||
const selectedAgentStatus =
|
||||
selectedAgent?.source === 'agent-harness' ? 'running' : status?.status
|
||||
const selectedCard =
|
||||
cardData.find((agent) => agent.agentId === selectedAgentId) ?? cardData[0]
|
||||
const selectedAgentName =
|
||||
selectedAgent?.name ?? orderedAgents[0]?.name ?? 'your agent'
|
||||
|
||||
const hasAgents = legacyAgents.length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-full px-4 py-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
|
||||
{cardData.length > 0 ? (
|
||||
{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(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
<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">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6 [text-wrap:pretty]">
|
||||
Start a task, continue a thread, or hand off to a different
|
||||
agent — all without leaving this tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-3xl">
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
agents={legacyAgents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
@@ -148,10 +186,10 @@ export const AgentCommandHome: FC = () => {
|
||||
streaming={false}
|
||||
disabled={!selectedAgentReady}
|
||||
status={selectedAgentStatus}
|
||||
attachmentsEnabled={false}
|
||||
attachmentsEnabled={true}
|
||||
placeholder={
|
||||
selectedAgentReady
|
||||
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
|
||||
? `Ask ${selectedAgentName} to handle a task...`
|
||||
: 'Agent runtime is not running...'
|
||||
}
|
||||
/>
|
||||
@@ -162,7 +200,8 @@ export const AgentCommandHome: FC = () => {
|
||||
|
||||
<RecentThreads
|
||||
activeAgentId={selectedAgentId}
|
||||
agents={cardData}
|
||||
agents={orderedAgents}
|
||||
adapters={adapters}
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
}
|
||||
@@ -27,6 +27,14 @@ 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) {
|
||||
@@ -42,31 +50,49 @@ 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>
|
||||
<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>
|
||||
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="w-72 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search agents..." className="h-9" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Bot, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from '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'
|
||||
@@ -15,6 +17,29 @@ 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
|
||||
@@ -22,6 +47,8 @@ interface ClawChatProps {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -78,6 +105,9 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
agentName,
|
||||
historyMessages,
|
||||
turns,
|
||||
stripOnlyTurns,
|
||||
filesByAssistantId,
|
||||
tailStripGroups,
|
||||
streaming,
|
||||
isInitialLoading,
|
||||
error,
|
||||
@@ -85,6 +115,7 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
onRetry,
|
||||
onOpenOutputsRail,
|
||||
className,
|
||||
}) => {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -147,14 +178,44 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
Start of conversation
|
||||
</div>
|
||||
) : null}
|
||||
{historyMessages.map((message) => (
|
||||
<ClawChatMessage key={message.id} message={message} />
|
||||
{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 ? (
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
Loader2,
|
||||
Mic,
|
||||
Paperclip,
|
||||
RefreshCw,
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -38,7 +36,6 @@ import { cn } from '@/lib/utils'
|
||||
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||
import { AgentSelector } from './AgentSelector'
|
||||
import type { OutboundMessage } from './useOutboundQueue'
|
||||
|
||||
export interface ConversationInputSendInput {
|
||||
text: string
|
||||
@@ -57,34 +54,40 @@ interface ConversationInputProps {
|
||||
placeholder?: string
|
||||
attachmentsEnabled?: boolean
|
||||
variant?: 'home' | 'conversation'
|
||||
// Outbound queue: when present, the composer renders the queue strip
|
||||
// above the textarea and lets the user keep sending while a previous
|
||||
// turn is in flight. Optional so non-conversation variants (the home
|
||||
// page) can opt out — the queue only makes sense in the conversation
|
||||
// page where each enqueued message will eventually be delivered to the
|
||||
// active agent.
|
||||
outboundQueue?: OutboundMessage[]
|
||||
onCancelQueued?: (id: string) => void
|
||||
onRetryQueued?: (id: string) => void
|
||||
/**
|
||||
* 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"
|
||||
>
|
||||
{streaming ? (
|
||||
{showSpinner ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
@@ -93,6 +96,22 @@ 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,
|
||||
@@ -145,7 +164,16 @@ function VoiceButton({
|
||||
)
|
||||
}
|
||||
|
||||
function ContextControls({
|
||||
/**
|
||||
* 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({
|
||||
agents,
|
||||
onCreateAgent,
|
||||
onSelectAgent,
|
||||
@@ -182,110 +210,128 @@ function ContextControls({
|
||||
)?.is_authenticated
|
||||
})
|
||||
|
||||
const showApps = supports(Feature.MANAGED_MCP_SUPPORT)
|
||||
const showWorkspace = supports(Feature.WORKSPACE_FOLDER_SUPPORT)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{showAgentSelector ? (
|
||||
<div className="mx-3 flex items-center gap-1 border-border/60 border-t border-dashed py-2">
|
||||
{showAgentSelector ? (
|
||||
<>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onCreateAgent}
|
||||
status={status}
|
||||
triggerVariant="pill"
|
||||
/>
|
||||
) : 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}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
selectedTabs.length > 0
|
||||
? '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',
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>Tabs</span>
|
||||
</Button>
|
||||
</TabPickerPopover>
|
||||
<Button
|
||||
<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"
|
||||
variant="ghost"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span>Attach</span>
|
||||
</Button>
|
||||
</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">
|
||||
<Layers className="size-3" />
|
||||
<span>Tabs</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[10.5px]',
|
||||
selectedTabs.length > 0
|
||||
? 'text-white/80'
|
||||
: 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
{connectedManagedServers.slice(0, 4).map((server) => (
|
||||
<div
|
||||
<span
|
||||
key={server.id}
|
||||
className="rounded-full ring-2 ring-card"
|
||||
>
|
||||
<McpServerIcon
|
||||
serverName={server.managedServerName ?? ''}
|
||||
size={16}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
</span>
|
||||
) : (
|
||||
<FileText className="size-3" />
|
||||
)}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</AppSelector>
|
||||
) : 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">
|
||||
<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)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -293,7 +339,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">
|
||||
<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)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -311,9 +357,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
placeholder,
|
||||
attachmentsEnabled = true,
|
||||
variant = 'conversation',
|
||||
outboundQueue,
|
||||
onCancelQueued,
|
||||
onRetryQueued,
|
||||
onStop,
|
||||
}) => {
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
@@ -394,15 +438,17 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
|
||||
const hasContent = input.trim().length > 0 || attachments.length > 0
|
||||
const queueEnabled = outboundQueue !== undefined
|
||||
// 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()
|
||||
// The outbound queue accepts new messages while streaming; legacy
|
||||
// direct-send callers (e.g., the home composer) keep the original
|
||||
// streaming-blocks-send semantic.
|
||||
if (disabled || isStaging) return
|
||||
if (!queueEnabled && streaming) return
|
||||
if (streaming && !queueAware) return
|
||||
if (!text && attachments.length === 0) return
|
||||
onSend({ text, attachments })
|
||||
setInput('')
|
||||
@@ -494,13 +540,6 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
error={attachmentError}
|
||||
/>
|
||||
) : null}
|
||||
{queueEnabled && outboundQueue && outboundQueue.length > 0 ? (
|
||||
<OutboundQueueStrip
|
||||
messages={outboundQueue}
|
||||
onCancel={onCancelQueued}
|
||||
onRetry={onRetryQueued}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3',
|
||||
@@ -530,7 +569,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className={cn(
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
|
||||
'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'
|
||||
@@ -539,6 +578,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{streaming && onStop ? <StopButton onStop={onStop} /> : null}
|
||||
<VoiceButton
|
||||
isRecording={voice.isRecording}
|
||||
isTranscribing={voice.isTranscribing}
|
||||
@@ -556,15 +596,13 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
!!disabled ||
|
||||
voice.isRecording ||
|
||||
voice.isTranscribing ||
|
||||
// Only block on `streaming` for the legacy direct-send path
|
||||
// (no queue). With the queue active the press always
|
||||
// succeeds — it just enqueues instead of dispatching.
|
||||
(!queueEnabled && streaming)
|
||||
(streaming && !queueAware)
|
||||
}
|
||||
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 ? (
|
||||
@@ -572,7 +610,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
{voice.error}
|
||||
</div>
|
||||
) : null}
|
||||
<ContextControls
|
||||
<CalmContextControls
|
||||
agents={agents}
|
||||
onCreateAgent={onCreateAgent}
|
||||
onSelectAgent={onSelectAgent}
|
||||
@@ -595,117 +633,6 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
function OutboundQueueStrip({
|
||||
messages,
|
||||
onCancel,
|
||||
onRetry,
|
||||
}: {
|
||||
messages: OutboundMessage[]
|
||||
onCancel?: (id: string) => void
|
||||
onRetry?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-border/40 border-b px-4 pt-3 pb-2">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{messages.map((message) => (
|
||||
<OutboundQueueItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OutboundQueueItem({
|
||||
message,
|
||||
onCancel,
|
||||
onRetry,
|
||||
}: {
|
||||
message: OutboundMessage
|
||||
onCancel?: (id: string) => void
|
||||
onRetry?: (id: string) => void
|
||||
}) {
|
||||
const preview = message.text.trim() || '(attachments only)'
|
||||
return (
|
||||
<li className="flex items-center gap-2 rounded-md px-2 py-1 text-xs">
|
||||
<OutboundQueueStatusIcon status={message.status} />
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{preview}
|
||||
</span>
|
||||
{message.attachmentPreviews.length > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground/70">
|
||||
<Paperclip className="size-3" />
|
||||
<span className="tabular-nums">
|
||||
{message.attachmentPreviews.length}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{message.status === 'queued' && onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(message.id)}
|
||||
className="ml-1 inline-flex size-5 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Cancel queued message"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
{message.status === 'failed' ? (
|
||||
<span className="ml-1 inline-flex items-center gap-2 text-destructive">
|
||||
<span className="max-w-[160px] truncate" title={message.error}>
|
||||
{message.error ?? 'Failed'}
|
||||
</span>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(message.id)}
|
||||
className="inline-flex size-5 items-center justify-center rounded-full hover:bg-accent hover:text-foreground"
|
||||
aria-label="Retry failed message"
|
||||
title="Retry"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(message.id)}
|
||||
className="inline-flex size-5 items-center justify-center rounded-full hover:bg-accent hover:text-foreground"
|
||||
aria-label="Discard failed message"
|
||||
title="Discard"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function OutboundQueueStatusIcon({
|
||||
status,
|
||||
}: {
|
||||
status: OutboundMessage['status']
|
||||
}) {
|
||||
if (status === 'sending') {
|
||||
return (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
)
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return <AlertTriangle className="size-3.5 shrink-0 text-destructive" />
|
||||
}
|
||||
return (
|
||||
<span className="inline-block size-2 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentStrip({
|
||||
attachments,
|
||||
onRemove,
|
||||
|
||||
@@ -22,10 +22,26 @@ import type {
|
||||
AgentConversationTurn,
|
||||
ToolEntry,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { FileCardStrip } from './agent-conversation.file-card-strip'
|
||||
|
||||
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 {
|
||||
@@ -88,9 +104,22 @@ function ToolStatusIcon({ status }: { status: ToolEntry['status'] }) {
|
||||
export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
turn,
|
||||
streaming,
|
||||
onOpenOutputsRail,
|
||||
stripOnly,
|
||||
}) => {
|
||||
const entries = useMemo(() => buildRenderEntries(turn), [turn])
|
||||
|
||||
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">
|
||||
@@ -185,6 +214,14 @@ export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{turn.producedFiles && turn.producedFiles.length > 0 ? (
|
||||
<FileCardStrip
|
||||
turnId={turn.turnId ?? null}
|
||||
files={turn.producedFiles}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!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">
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,15 @@ export const AgentCommandLayout: FC = () => {
|
||||
const { agents: harnessAgents, loading: harnessAgentsLoading } =
|
||||
useHarnessAgents()
|
||||
const visibleOpenClawAgents = openClawEnabled ? openClawAgents : []
|
||||
const agents = [...visibleOpenClawAgents, ...harnessAgents]
|
||||
// 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 = [...dedupedOpenClawAgents, ...harnessAgents]
|
||||
|
||||
return (
|
||||
<Outlet
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -23,9 +24,9 @@ export interface BrowserOSChatHistoryToolCall {
|
||||
toolName: string
|
||||
label: string
|
||||
subject?: string
|
||||
status: 'completed' | 'failed'
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
@@ -234,6 +235,30 @@ export function filterTurnsPersistedInHistory(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[],
|
||||
@@ -285,3 +310,59 @@ function getClawMessageText(message: ClawChatMessage): string {
|
||||
.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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
type OpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import type { AgentOverview } from './useAgentDashboard'
|
||||
|
||||
function resolveAgentStatus(
|
||||
gatewayStatus: OpenClawStatus['status'] | undefined,
|
||||
liveStatus: AgentOverview['status'] | undefined,
|
||||
): AgentCardData['status'] {
|
||||
// Gateway-level errors take precedence
|
||||
if (gatewayStatus === 'error') return 'error'
|
||||
if (gatewayStatus === 'starting') return 'working'
|
||||
|
||||
// Per-agent live status from the WS observer
|
||||
if (liveStatus === 'working') return 'working'
|
||||
if (liveStatus === 'error') return 'error'
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent card display data by merging the raw agent entries from
|
||||
* the gateway with enriched overview data from the dashboard API.
|
||||
*
|
||||
* Pure function — no hooks, no IndexedDB, no async.
|
||||
*/
|
||||
export function buildAgentCardData(
|
||||
agents: AgentEntry[],
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
dashboard: AgentOverview[] | undefined,
|
||||
): AgentCardData[] {
|
||||
return agents.map((agent) => {
|
||||
const overview = dashboard?.find((d) => d.agentId === agent.agentId)
|
||||
|
||||
return {
|
||||
agentId: agent.agentId,
|
||||
name: agent.name,
|
||||
model: getModelDisplayName(agent.model),
|
||||
status:
|
||||
agent.source === 'agent-harness'
|
||||
? 'idle'
|
||||
: resolveAgentStatus(status, overview?.status),
|
||||
lastMessage: overview?.latestMessage?.slice(0, 200) ?? undefined,
|
||||
lastMessageTimestamp: overview?.latestMessageAt ?? undefined,
|
||||
activitySummary: overview?.activitySummary ?? undefined,
|
||||
currentTool: overview?.currentTool ?? undefined,
|
||||
costUsd: overview?.totalCostUsd ?? undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type AgentHarnessStreamEvent,
|
||||
attachToHarnessTurn,
|
||||
cancelHarnessTurn,
|
||||
chatWithHarnessAgent,
|
||||
fetchActiveHarnessTurn,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
chatWithAgent,
|
||||
type OpenClawChatHistoryMessage,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type {
|
||||
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'
|
||||
@@ -29,11 +30,23 @@ export interface SendInput {
|
||||
}
|
||||
|
||||
interface UseAgentConversationOptions {
|
||||
runtime?: 'openclaw' | 'agent-harness'
|
||||
// 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(
|
||||
@@ -42,6 +55,12 @@ export function useAgentConversation(
|
||||
) {
|
||||
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 textAccRef = useRef('')
|
||||
@@ -49,6 +68,11 @@ export function useAgentConversation(
|
||||
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 ?? ''
|
||||
@@ -72,6 +96,12 @@ 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 updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
@@ -82,85 +112,6 @@ export function useAgentConversation(
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
appendTextDelta((event.data.text as string) ?? '')
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
appendThinkingDelta((event.data.text as string) ?? '')
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const rawName = (event.data.toolName as string) ?? 'unknown'
|
||||
const args = event.data.args as Record<string, unknown> | undefined
|
||||
const { label, subject } = buildToolLabel(rawName, args)
|
||||
const tool = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: rawName,
|
||||
label,
|
||||
subject,
|
||||
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': {
|
||||
markCurrentTurnDone()
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
appendErrorText(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const appendTextDelta = (delta: string) => {
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
@@ -209,6 +160,17 @@ export function useAgentConversation(
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
@@ -265,6 +227,9 @@ export function useAgentConversation(
|
||||
case 'tool_call':
|
||||
upsertAgentHarnessTool(event)
|
||||
break
|
||||
case 'produced_files':
|
||||
setProducedFilesOnCurrentTurn(event.files)
|
||||
break
|
||||
case 'done':
|
||||
markCurrentTurnDone()
|
||||
break
|
||||
@@ -275,6 +240,165 @@ export function useAgentConversation(
|
||||
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,
|
||||
})
|
||||
if (!response.ok) return
|
||||
await consumeSSEStream<AgentHarnessStreamEvent>(
|
||||
response,
|
||||
(event, meta) => {
|
||||
if (typeof meta.seq === 'number') lastSeqRef.current = meta.seq
|
||||
processEventRef.current(event)
|
||||
},
|
||||
abortController.signal,
|
||||
)
|
||||
} 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
@@ -304,24 +428,13 @@ export function useAgentConversation(
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response =
|
||||
options.runtime === 'agent-harness'
|
||||
? await chatWithHarnessAgent(agentId, trimmed, abortController.signal)
|
||||
: await chatWithAgent(
|
||||
agentId,
|
||||
trimmed,
|
||||
sessionKeyRef.current || undefined,
|
||||
historyRef.current,
|
||||
abortController.signal,
|
||||
attachments,
|
||||
)
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
const response = await openSendStream(
|
||||
agentId,
|
||||
trimmed,
|
||||
attachments,
|
||||
abortController.signal,
|
||||
)
|
||||
applyResponseHeadersToTurn(response)
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
@@ -330,19 +443,14 @@ export function useAgentConversation(
|
||||
])
|
||||
return
|
||||
}
|
||||
if (options.runtime === 'agent-harness') {
|
||||
await consumeSSEStream<AgentHarnessStreamEvent>(
|
||||
response,
|
||||
processAgentHarnessStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} else {
|
||||
await consumeSSEStream<OpenClawStreamEvent>(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
}
|
||||
await consumeSSEStream<AgentHarnessStreamEvent>(
|
||||
response,
|
||||
(event, meta) => {
|
||||
if (typeof meta.seq === 'number') lastSeqRef.current = meta.seq
|
||||
processAgentHarnessStreamEvent(event)
|
||||
},
|
||||
abortController.signal,
|
||||
)
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
@@ -354,14 +462,40 @@ 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 = () => {
|
||||
streamAbortRef.current?.abort()
|
||||
streamAbortRef.current = null
|
||||
void stop()
|
||||
setTurns([])
|
||||
setStreaming(false)
|
||||
}
|
||||
@@ -371,6 +505,7 @@ export function useAgentConversation(
|
||||
streaming,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
send,
|
||||
stop,
|
||||
resetConversation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export interface AgentOverview {
|
||||
agentId: string
|
||||
status: 'working' | 'idle' | 'error' | 'unknown'
|
||||
latestMessage: string | null
|
||||
latestMessageAt: number | null
|
||||
activitySummary: string | null
|
||||
currentTool: string | null
|
||||
totalCostUsd: number
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
agents: AgentOverview[]
|
||||
summary: {
|
||||
totalAgents: number
|
||||
totalCostUsd: number
|
||||
}
|
||||
}
|
||||
|
||||
interface StatusEvent {
|
||||
agentId: string
|
||||
status: AgentOverview['status']
|
||||
currentTool: string | null
|
||||
error: string | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const DASHBOARD_QUERY_KEY = ['claw', 'dashboard']
|
||||
|
||||
export function useAgentDashboard(enabled: boolean) {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
const ready = enabled && Boolean(baseUrl) && !urlLoading
|
||||
|
||||
// Initial data load + periodic refresh as fallback
|
||||
const query = useQuery<DashboardResponse>({
|
||||
queryKey: [...DASHBOARD_QUERY_KEY, baseUrl],
|
||||
queryFn: async () => {
|
||||
const url = new URL('/claw/dashboard', baseUrl as string)
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) throw new Error('Failed to fetch dashboard')
|
||||
return response.json()
|
||||
},
|
||||
enabled: ready,
|
||||
})
|
||||
|
||||
// SSE subscription for real-time status patches
|
||||
useEffect(() => {
|
||||
if (!ready || !baseUrl) return
|
||||
|
||||
const streamUrl = new URL('/claw/dashboard/stream', baseUrl)
|
||||
const eventSource = new EventSource(streamUrl.toString())
|
||||
|
||||
eventSource.addEventListener('snapshot', (event) => {
|
||||
try {
|
||||
const dashboard = JSON.parse(event.data) as DashboardResponse
|
||||
queryClient.setQueryData([...DASHBOARD_QUERY_KEY, baseUrl], dashboard)
|
||||
} catch {}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('status', (event) => {
|
||||
try {
|
||||
const status = JSON.parse(event.data) as StatusEvent
|
||||
queryClient.setQueryData<DashboardResponse>(
|
||||
[...DASHBOARD_QUERY_KEY, baseUrl],
|
||||
(prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
agents: prev.agents.map((agent) =>
|
||||
agent.agentId === status.agentId
|
||||
? {
|
||||
...agent,
|
||||
status: status.status,
|
||||
currentTool: status.currentTool,
|
||||
}
|
||||
: agent,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch {}
|
||||
})
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}, [ready, baseUrl, queryClient])
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { AgentHistoryPageResponse } from './claw-chat-types'
|
||||
|
||||
const HISTORY_QUERY_KEY = 'claw-agent-history'
|
||||
|
||||
async function fetchClawJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function buildClawUrl(baseUrl: string, path: string): URL {
|
||||
return new URL(`/claw${path}`, baseUrl)
|
||||
}
|
||||
|
||||
export function useClawChatHistory({
|
||||
agentId,
|
||||
sessionKey,
|
||||
enabled = true,
|
||||
limit = 50,
|
||||
}: {
|
||||
agentId: string
|
||||
// null lets the server resolve the most recent user-chat session for the
|
||||
// agent — avoids an extra /session round-trip and the race that came with it.
|
||||
sessionKey: string | null
|
||||
enabled?: boolean
|
||||
limit?: number
|
||||
}) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useInfiniteQuery<AgentHistoryPageResponse, Error>({
|
||||
queryKey: [HISTORY_QUERY_KEY, baseUrl, agentId, sessionKey],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const url = buildClawUrl(baseUrl as string, `/agents/${agentId}/history`)
|
||||
url.searchParams.set('limit', String(limit))
|
||||
|
||||
if (sessionKey) {
|
||||
url.searchParams.set('sessionKey', sessionKey)
|
||||
}
|
||||
if (typeof pageParam === 'string' && pageParam) {
|
||||
url.searchParams.set('cursor', pageParam)
|
||||
}
|
||||
|
||||
return fetchClawJson<AgentHistoryPageResponse>(url.toString())
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page.hasMore ? lastPage.page.cursor : undefined,
|
||||
enabled: enabled && Boolean(baseUrl) && !urlLoading && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
error: query.error ?? urlError,
|
||||
isLoading: query.isLoading || urlLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { HarnessAgentHistoryPage } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { fetchHarnessAgentHistory } from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type {
|
||||
AgentHistoryPageResponse,
|
||||
BrowserOSChatHistoryItem,
|
||||
} from './claw-chat-types'
|
||||
import type { AgentHistoryPageResponse } from './claw-chat-types'
|
||||
import { mapHarnessHistoryPage } from './harness-history-mapper'
|
||||
|
||||
const HISTORY_QUERY_KEY = 'harness-agent-history'
|
||||
|
||||
@@ -30,39 +27,3 @@ export function useHarnessChatHistory(agentId: string, enabled = true) {
|
||||
isLoading: query.isLoading || urlLoading,
|
||||
}
|
||||
}
|
||||
|
||||
function mapHarnessHistoryPage(
|
||||
page: HarnessAgentHistoryPage,
|
||||
): AgentHistoryPageResponse {
|
||||
const items: BrowserOSChatHistoryItem[] = page.items.map((item, index) => ({
|
||||
id: item.id,
|
||||
role: item.role,
|
||||
text: item.text,
|
||||
timestamp: item.createdAt,
|
||||
messageSeq: index + 1,
|
||||
sessionKey: 'main',
|
||||
source: 'user-chat',
|
||||
}))
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { UserAttachmentPreview } from '@/lib/agent-conversations/types'
|
||||
import type { ServerAttachmentPayload } from '@/lib/attachments'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export type OutboundMessageStatus = 'queued' | 'sending' | 'failed'
|
||||
|
||||
export interface OutboundMessage {
|
||||
id: string
|
||||
text: string
|
||||
attachments: ServerAttachmentPayload[]
|
||||
attachmentPreviews: UserAttachmentPreview[]
|
||||
status: OutboundMessageStatus
|
||||
error?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface OutboundQueueEnqueueInput {
|
||||
text: string
|
||||
attachments?: ServerAttachmentPayload[]
|
||||
attachmentPreviews?: UserAttachmentPreview[]
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
}
|
||||
|
||||
export interface OutboundQueueApi {
|
||||
queue: OutboundMessage[]
|
||||
enqueue(input: OutboundQueueEnqueueInput): void
|
||||
cancel(id: string): void
|
||||
retry(id: string): void
|
||||
}
|
||||
|
||||
interface UseOutboundQueueOptions {
|
||||
agentId: string | null | undefined
|
||||
sessionKey?: string | null
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface ServerQueuedItem {
|
||||
id: string
|
||||
status: 'queued' | 'dispatching' | 'failed'
|
||||
message: string
|
||||
attachmentsPreview: Array<{
|
||||
kind: 'image' | 'file'
|
||||
mediaType: string
|
||||
name?: string
|
||||
}>
|
||||
error?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
function makeId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-backed outbound message queue. The browser is purely a
|
||||
* projection of server state — closing the tab is safe because the queue
|
||||
* keeps draining server-side via the OutboundQueueService.
|
||||
*
|
||||
* Single id-keyed list: the client generates the queue id and hands it
|
||||
* to the server in the POST body, so the optimistic row and the SSE
|
||||
* snapshot reconcile on the same key from frame zero — there is no
|
||||
* window in which the message renders twice.
|
||||
*/
|
||||
export function useOutboundQueue(
|
||||
options: UseOutboundQueueOptions,
|
||||
): OutboundQueueApi {
|
||||
const { agentId, enabled = true, sessionKey } = options
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
const sessionKeyRef = useRef<string | null | undefined>(sessionKey)
|
||||
sessionKeyRef.current = sessionKey
|
||||
|
||||
const [items, setItems] = useState<OutboundMessage[]>([])
|
||||
// Track which ids the server has confirmed seeing in any SSE snapshot.
|
||||
// We use this to know whether a missing-from-snapshot id is "drained
|
||||
// by the server" (drop it) or "still in flight client-side" (keep
|
||||
// showing the optimistic row).
|
||||
const everSeenByServerRef = useRef<Set<string>>(new Set())
|
||||
// Local-only attachment previews, keyed by queue id. Data URLs never
|
||||
// leave the browser — the SSE feed only carries metadata, so we hold
|
||||
// them here so the chip strip keeps rendering after server takeover.
|
||||
const previewMapRef = useRef<Map<string, UserAttachmentPreview[]>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !baseUrl || !agentId) {
|
||||
setItems([])
|
||||
everSeenByServerRef.current = new Set()
|
||||
previewMapRef.current = new Map()
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const url = `${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue/stream`
|
||||
const source = new EventSource(url)
|
||||
source.onmessage = (event) => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as { items: ServerQueuedItem[] }
|
||||
const snapshotIds = new Set(parsed.items.map((item) => item.id))
|
||||
for (const id of snapshotIds) everSeenByServerRef.current.add(id)
|
||||
|
||||
setItems((prev) => {
|
||||
const next: OutboundMessage[] = parsed.items.map((item) => ({
|
||||
id: item.id,
|
||||
text: item.message,
|
||||
attachments: [],
|
||||
attachmentPreviews: previewMapRef.current.get(item.id) ?? [],
|
||||
status: serverStatusToClient(item.status),
|
||||
error: item.error,
|
||||
createdAt: item.createdAt,
|
||||
}))
|
||||
// Carry forward any optimistic / failed entries the server
|
||||
// doesn't know about yet (POST in flight) or has finished
|
||||
// dispatching but the client wants to keep visible (failed).
|
||||
const carried = prev.filter((local) => {
|
||||
if (snapshotIds.has(local.id)) return false
|
||||
if (everSeenByServerRef.current.has(local.id)) {
|
||||
// Server saw it before and it's gone now — drained.
|
||||
previewMapRef.current.delete(local.id)
|
||||
return false
|
||||
}
|
||||
return local.status !== 'failed' || Boolean(local.error)
|
||||
})
|
||||
return [...carried, ...next]
|
||||
})
|
||||
} catch {
|
||||
// Malformed event — ignore; next snapshot will recover.
|
||||
}
|
||||
}
|
||||
source.onerror = () => {
|
||||
// Auto-reconnects; nothing to do here.
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
source.close()
|
||||
}
|
||||
}, [baseUrl, agentId, enabled])
|
||||
|
||||
const enqueue = useCallback(
|
||||
(input: OutboundQueueEnqueueInput) => {
|
||||
if (!enabled || !baseUrl || !agentId) return
|
||||
const trimmed = input.text.trim()
|
||||
const attachments = input.attachments ?? []
|
||||
if (!trimmed && attachments.length === 0) return
|
||||
|
||||
const id = makeId()
|
||||
const previews = input.attachmentPreviews ?? []
|
||||
previewMapRef.current.set(id, previews)
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
text: trimmed,
|
||||
attachments,
|
||||
attachmentPreviews: previews,
|
||||
status: 'queued',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
])
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
message: trimmed,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
sessionKey: sessionKeyRef.current ?? undefined,
|
||||
history: input.history,
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
previewMapRef.current.delete(id)
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
status: 'failed',
|
||||
error:
|
||||
text || `Failed to enqueue (status ${response.status})`,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
// Only mark as failed if the SSE snapshot hasn't already
|
||||
// taken ownership of the entry (i.e. the request actually
|
||||
// reached the server).
|
||||
if (everSeenByServerRef.current.has(id)) return
|
||||
previewMapRef.current.delete(id)
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
status: 'failed',
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to enqueue message',
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
})()
|
||||
},
|
||||
[baseUrl, agentId, enabled],
|
||||
)
|
||||
|
||||
const cancel = useCallback(
|
||||
(id: string) => {
|
||||
// If the server has never seen this id, just drop it locally.
|
||||
if (!everSeenByServerRef.current.has(id)) {
|
||||
previewMapRef.current.delete(id)
|
||||
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||
return
|
||||
}
|
||||
if (!enabled || !baseUrl || !agentId) return
|
||||
void fetch(
|
||||
`${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => {})
|
||||
},
|
||||
[baseUrl, agentId, enabled],
|
||||
)
|
||||
|
||||
const retry = useCallback(
|
||||
(id: string) => {
|
||||
if (!everSeenByServerRef.current.has(id)) {
|
||||
// Optimistic-only entry, never made it to the server. Reset
|
||||
// status so the user can press Send again.
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: 'queued', error: undefined }
|
||||
: item,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!enabled || !baseUrl || !agentId) return
|
||||
void fetch(
|
||||
`${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(id)}/retry`,
|
||||
{ method: 'POST' },
|
||||
).catch(() => {})
|
||||
},
|
||||
[baseUrl, agentId, enabled],
|
||||
)
|
||||
|
||||
return { queue: items, enqueue, cancel, retry }
|
||||
}
|
||||
|
||||
function serverStatusToClient(
|
||||
status: ServerQueuedItem['status'],
|
||||
): OutboundMessageStatus {
|
||||
if (status === 'dispatching') return 'sending'
|
||||
if (status === 'failed') return 'failed'
|
||||
return 'queued'
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -1,117 +1,165 @@
|
||||
import { Bot, Cpu, Loader2, MessageSquare, Plus, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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
|
||||
onChatAgent: (agent: AgentListItem) => void
|
||||
onCreateAgent: () => void
|
||||
onDeleteAgent: (agent: AgentListItem) => void
|
||||
onPinToggle: (agent: AgentListItem, next: boolean) => void
|
||||
}
|
||||
|
||||
export const AgentList: FC<AgentListProps> = ({
|
||||
agents,
|
||||
activity,
|
||||
harnessAgentLookup,
|
||||
adapters,
|
||||
loading,
|
||||
deletingAgentKey,
|
||||
onChatAgent,
|
||||
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-lg border border-border/70">
|
||||
<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 (
|
||||
<Card>
|
||||
<CardContent className="flex h-48 flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-medium text-base">No agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create an OpenClaw, Claude Code, or Codex agent.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onCreateAgent}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <AgentsEmptyState onCreateAgent={onCreateAgent} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{agents.map((agent) => (
|
||||
<Card key={agent.key} className="rounded-lg border-border/70">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
{agent.source === 'openclaw' ? (
|
||||
<Cpu className="size-5" />
|
||||
) : (
|
||||
<Bot className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
{agent.name}
|
||||
</CardTitle>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
|
||||
<Badge variant="outline" className="rounded-md">
|
||||
{agent.runtimeLabel}
|
||||
</Badge>
|
||||
<span>{agent.modelLabel}</span>
|
||||
<Badge variant="outline" className="rounded-md">
|
||||
main
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-muted-foreground text-xs">
|
||||
{agent.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChatAgent(agent)}
|
||||
disabled={!agent.canChat}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
</Button>
|
||||
{agent.canDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete agent"
|
||||
onClick={() => onDeleteAgent(agent)}
|
||||
disabled={deletingAgentKey === agent.key}
|
||||
>
|
||||
{deletingAgentKey === agent.key ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import { type FC, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { AgentList } from './AgentList'
|
||||
import { AgentsHeader } from './AgentsHeader'
|
||||
import { AgentTerminal } from './AgentTerminal'
|
||||
import type { HarnessAgentAdapter } from './agent-harness-types'
|
||||
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
|
||||
import { createAgentPageActions } from './agents-page-actions'
|
||||
import {
|
||||
useDefaultAgentName,
|
||||
useHarnessAgentDefaults,
|
||||
useHermesProviderSelection,
|
||||
useOpenClawProviderSelection,
|
||||
} from './agents-page-hooks'
|
||||
import {
|
||||
@@ -29,9 +31,9 @@ import {
|
||||
toHarnessListItem,
|
||||
toOpenClawListItem,
|
||||
} from './agents-page-utils'
|
||||
import { GatewayStatusBar } from './GatewayStatusBar'
|
||||
import { NewAgentDialog } from './NewAgentDialog'
|
||||
import {
|
||||
AgentsPageHeader,
|
||||
ControlPlaneAlert,
|
||||
GatewayStateCards,
|
||||
InlineErrorAlert,
|
||||
@@ -43,51 +45,45 @@ import {
|
||||
useCreateHarnessAgent,
|
||||
useDeleteHarnessAgent,
|
||||
useHarnessAgents,
|
||||
useUpdateHarnessAgent,
|
||||
} from './useAgents'
|
||||
import {
|
||||
useOpenClawAgents,
|
||||
useOpenClawMutations,
|
||||
useOpenClawStatus,
|
||||
} from './useOpenClaw'
|
||||
import { useOpenClawAgents, useOpenClawMutations } from './useOpenClaw'
|
||||
|
||||
export const AgentsPage: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
status,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
refetch: refetchStatus,
|
||||
} = useOpenClawStatus()
|
||||
const { providers, defaultProviderId } = useLlmProviders()
|
||||
const {
|
||||
adapters,
|
||||
loading: adaptersLoading,
|
||||
error: adaptersError,
|
||||
refetch: refetchAdapters,
|
||||
} = useAgentAdapters()
|
||||
|
||||
// The harness listing now carries the gateway lifecycle snapshot
|
||||
// alongside the agents — one polling source for everything the
|
||||
// agents page renders. The legacy `/claw/status` poll is dead from
|
||||
// this surface; the chat-panel layout still uses it for now.
|
||||
const {
|
||||
harnessAgents,
|
||||
gateway: status,
|
||||
loading: harnessAgentsLoading,
|
||||
error: harnessAgentsError,
|
||||
} = useHarnessAgents()
|
||||
|
||||
const openClawAgentsEnabled =
|
||||
status?.status === 'running' && status.controlPlaneStatus === 'connected'
|
||||
const {
|
||||
agents: openClawAgents,
|
||||
loading: openClawAgentsLoading,
|
||||
error: openClawAgentsError,
|
||||
refetch: refetchOpenClawAgents,
|
||||
} = useOpenClawAgents(openClawAgentsEnabled)
|
||||
const {
|
||||
harnessAgents,
|
||||
loading: harnessAgentsLoading,
|
||||
error: harnessAgentsError,
|
||||
refetch: refetchHarnessAgents,
|
||||
} = useHarnessAgents()
|
||||
const createHarnessAgent = useCreateHarnessAgent()
|
||||
const deleteHarnessAgent = useDeleteHarnessAgent()
|
||||
const updateHarnessAgent = useUpdateHarnessAgent()
|
||||
const {
|
||||
setupOpenClaw,
|
||||
createAgent: createOpenClawAgent,
|
||||
deleteAgent: deleteOpenClawAgent,
|
||||
startOpenClaw,
|
||||
stopOpenClaw,
|
||||
restartOpenClaw,
|
||||
reconnectOpenClaw,
|
||||
actionInProgress,
|
||||
@@ -111,6 +107,7 @@ export const AgentsPage: FC = () => {
|
||||
)
|
||||
const [harnessModelId, setHarnessModelId] = useState('')
|
||||
const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('')
|
||||
const [createHermesProviderId, setCreateHermesProviderId] = useState('')
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [cliAuthModalOpen, setCliAuthModalOpen] = useState(false)
|
||||
const [pageError, setPageError] = useState<string | null>(null)
|
||||
@@ -138,6 +135,14 @@ export const AgentsPage: FC = () => {
|
||||
cliAuthModalOpen,
|
||||
setCliAuthModalOpen,
|
||||
})
|
||||
const { selectableHermesProviders } = useHermesProviderSelection({
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
setCreateHermesProviderId,
|
||||
})
|
||||
useDefaultAgentName(createOpen, setNewName)
|
||||
useHarnessAgentDefaults({
|
||||
adapters,
|
||||
@@ -158,42 +163,68 @@ export const AgentsPage: FC = () => {
|
||||
openClawAgentsEnabled,
|
||||
openClawAgents,
|
||||
)
|
||||
const agentListItems = useMemo(
|
||||
() => [
|
||||
...visibleOpenClawAgents.map((agent) =>
|
||||
const agentListItems = useMemo(() => {
|
||||
// Dual-created OpenClaw agents (and the backfilled `main`/orphans
|
||||
// post Step 9) live in both `/claw/agents` and `/agents` under the
|
||||
// same id. Prefer the harness entry — it carries adapter/model/
|
||||
// reasoning/lastUsedAt/status that the chat path actually uses —
|
||||
// and drop the legacy duplicate so the rail doesn't show every
|
||||
// OpenClaw agent twice.
|
||||
const harnessIds = new Set(harnessAgents.map((agent) => agent.id))
|
||||
const dedupedOpenClawAgents = visibleOpenClawAgents.filter(
|
||||
(agent) => !harnessIds.has(agent.agentId),
|
||||
)
|
||||
return [
|
||||
...dedupedOpenClawAgents.map((agent) =>
|
||||
toOpenClawListItem(agent, openClawManageable),
|
||||
),
|
||||
...harnessAgents.map(toHarnessListItem),
|
||||
],
|
||||
[harnessAgents, openClawManageable, visibleOpenClawAgents],
|
||||
)
|
||||
]
|
||||
}, [harnessAgents, openClawManageable, visibleOpenClawAgents])
|
||||
// Lookup map so AgentList can render adapter chips, reasoning, etc.
|
||||
// Computed up here to keep all hooks above the early returns below.
|
||||
const harnessAgentLookup = useMemo(() => {
|
||||
const map = new Map<string, HarnessAgent>()
|
||||
for (const agent of harnessAgents) map.set(agent.id, agent)
|
||||
return map
|
||||
}, [harnessAgents])
|
||||
// Activity map keyed by agentId. Sourced from the harness listing's
|
||||
// server-side enrichment (`status` + `lastUsedAt`). Legacy gateway
|
||||
// agents that don't have a harness record yet (rare post-backfill)
|
||||
// simply miss from the map and render with the default `unknown`
|
||||
// dot until reconciliation picks them up.
|
||||
const agentActivity = useMemo(() => {
|
||||
const map: Record<
|
||||
string,
|
||||
{
|
||||
status: 'working' | 'idle' | 'asleep' | 'error'
|
||||
lastUsedAt: number | null
|
||||
}
|
||||
> = {}
|
||||
for (const agent of harnessAgents) {
|
||||
if (!agent.status) continue
|
||||
map[agent.id] = {
|
||||
status: agent.status,
|
||||
lastUsedAt: agent.lastUsedAt ?? null,
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [harnessAgents])
|
||||
const inlineError = getInlineError({
|
||||
lifecyclePending,
|
||||
pageError,
|
||||
statusError,
|
||||
openClawAgentsError,
|
||||
adaptersError,
|
||||
harnessAgentsError,
|
||||
})
|
||||
const agentsLoading = getAgentsLoading({
|
||||
statusLoading,
|
||||
adaptersLoading,
|
||||
harnessAgentsLoading,
|
||||
openClawAgentsEnabled,
|
||||
openClawAgentsLoading,
|
||||
})
|
||||
const creatingAgent = creatingOpenClawAgent || createHarnessAgent.isPending
|
||||
const deletingAgent = deletingOpenClawAgent || deleteHarnessAgent.isPending
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
refetchStatus(),
|
||||
refetchAdapters(),
|
||||
refetchHarnessAgents(),
|
||||
openClawAgentsEnabled ? refetchOpenClawAgents() : Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
const handleHarnessAdapterChange = (adapter: HarnessAgentAdapter) => {
|
||||
const descriptor = adapters.find((entry) => entry.id === adapter)
|
||||
setHarnessAdapterId(adapter)
|
||||
@@ -205,11 +236,13 @@ export const AgentsPage: FC = () => {
|
||||
createAgentPageActions({
|
||||
createProviderId,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
navigate,
|
||||
newName,
|
||||
selectableOpenClawProviders,
|
||||
selectableHermesProviders,
|
||||
setupProviderId,
|
||||
createHarnessAgent: createHarnessAgent.mutateAsync,
|
||||
createOpenClawAgent,
|
||||
@@ -239,7 +272,9 @@ export const AgentsPage: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (statusLoading && !status) {
|
||||
// First-paint loader: until the harness listing has resolved at
|
||||
// least once we don't know which adapters / agents to render.
|
||||
if (harnessAgentsLoading && !status) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
@@ -255,27 +290,18 @@ export const AgentsPage: FC = () => {
|
||||
const recoveryDetail = status ? getRecoveryDetail(status) : null
|
||||
const controlPlaneCopy = getControlPlaneCopyForStatus(status)
|
||||
|
||||
// Bar only makes sense when the gateway is meaningfully alive AND
|
||||
// there's at least one OpenClaw agent in the merged list. Hide it
|
||||
// for Claude/Codex-only setups so the page stays uncluttered.
|
||||
const showGatewayStatusBar =
|
||||
status?.status === 'running' &&
|
||||
(visibleOpenClawAgents.length > 0 ||
|
||||
harnessAgents.some((agent) => agent.adapter === 'openclaw'))
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-background px-6 py-8">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<AgentsPageHeader
|
||||
actionInProgress={actionInProgress}
|
||||
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
|
||||
reconnecting={reconnecting}
|
||||
status={status}
|
||||
onCreateAgent={() => setCreateOpen(true)}
|
||||
onOpenTerminal={() => setShowTerminal(true)}
|
||||
onReconnect={() => {
|
||||
void runWithPageErrorHandling(reconnectOpenClaw)
|
||||
}}
|
||||
onRefresh={() => void refreshAll()}
|
||||
onRestart={() => {
|
||||
void runWithPageErrorHandling(restartOpenClaw)
|
||||
}}
|
||||
onStop={() => {
|
||||
void runWithPageErrorHandling(stopOpenClaw)
|
||||
}}
|
||||
/>
|
||||
<div className="fade-in slide-in-from-bottom-5 mx-auto flex w-full max-w-5xl animate-in flex-col gap-6 duration-500">
|
||||
<AgentsHeader onCreateAgent={() => setCreateOpen(true)} />
|
||||
|
||||
{lifecycleBanner ? <LifecycleAlert message={lifecycleBanner} /> : null}
|
||||
|
||||
@@ -315,15 +341,39 @@ export const AgentsPage: FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{showGatewayStatusBar ? (
|
||||
<GatewayStatusBar
|
||||
status={status}
|
||||
actionInProgress={actionInProgress}
|
||||
onOpenTerminal={() => setShowTerminal(true)}
|
||||
onRestart={() => {
|
||||
void runWithPageErrorHandling(restartOpenClaw)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AgentList
|
||||
agents={agentListItems}
|
||||
activity={agentActivity}
|
||||
harnessAgentLookup={harnessAgentLookup}
|
||||
adapters={adapters}
|
||||
loading={agentsLoading}
|
||||
deletingAgentKey={deletingAgent ? deletingAgentKey : null}
|
||||
onChatAgent={(agent) => navigate(`/agents/${agent.agentId}`)}
|
||||
onCreateAgent={() => setCreateOpen(true)}
|
||||
onDeleteAgent={(agent) => {
|
||||
void handleDelete(agent)
|
||||
}}
|
||||
onPinToggle={(agent, next) => {
|
||||
// Optimistic mutation; harness-only — gateway-original
|
||||
// OpenClaw entries are gated server-side via the harness
|
||||
// backfill, so we only fire when the row maps to a
|
||||
// harness agent record.
|
||||
if (!harnessAgentLookup.has(agent.agentId)) return
|
||||
updateHarnessAgent.mutate({
|
||||
agentId: agent.agentId,
|
||||
patch: { pinned: next },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<SetupOpenClawDialog
|
||||
@@ -348,6 +398,8 @@ export const AgentsPage: FC = () => {
|
||||
harnessAdapterId={harnessAdapterId}
|
||||
harnessModelId={harnessModelId}
|
||||
harnessReasoningEffort={harnessReasoningEffort}
|
||||
hermesProviders={selectableHermesProviders}
|
||||
hermesSelectedProviderId={createHermesProviderId}
|
||||
name={newName}
|
||||
open={createOpen}
|
||||
providers={selectableOpenClawProviders}
|
||||
@@ -363,12 +415,14 @@ export const AgentsPage: FC = () => {
|
||||
if (!open) {
|
||||
setCreateError(null)
|
||||
createHarnessAgent.reset()
|
||||
setCreateHermesProviderId('')
|
||||
}
|
||||
}}
|
||||
onRuntimeChange={setCreateRuntime}
|
||||
onHarnessAdapterChange={handleHarnessAdapterChange}
|
||||
onHarnessModelChange={setHarnessModelId}
|
||||
onHarnessReasoningChange={setHarnessReasoningEffort}
|
||||
onHermesProviderChange={setCreateHermesProviderId}
|
||||
onNameChange={setNewName}
|
||||
onProviderChange={setCreateProviderId}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,8 @@ interface NewAgentDialogProps {
|
||||
harnessAdapterId: HarnessAgentAdapter
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
hermesProviders: ProviderOption[]
|
||||
hermesSelectedProviderId: string
|
||||
name: string
|
||||
open: boolean
|
||||
providers: ProviderOption[]
|
||||
@@ -55,6 +57,7 @@ interface NewAgentDialogProps {
|
||||
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
|
||||
onHarnessModelChange: (modelId: string) => void
|
||||
onHarnessReasoningChange: (reasoningEffort: string) => void
|
||||
onHermesProviderChange: (providerId: string) => void
|
||||
onNameChange: (name: string) => void
|
||||
onProviderChange: (providerId: string) => void
|
||||
}
|
||||
@@ -69,6 +72,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
harnessAdapterId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
hermesProviders,
|
||||
hermesSelectedProviderId,
|
||||
name,
|
||||
open,
|
||||
providers,
|
||||
@@ -84,22 +89,29 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
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))
|
||||
@@ -143,7 +155,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
if (
|
||||
value === 'openclaw' ||
|
||||
value === 'claude' ||
|
||||
value === 'codex'
|
||||
value === 'codex' ||
|
||||
value === 'hermes'
|
||||
) {
|
||||
onRuntimeChange(value)
|
||||
if (value !== 'openclaw') onHarnessAdapterChange(value)
|
||||
@@ -154,7 +167,6 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openclaw">OpenClaw</SelectItem>
|
||||
{adapters.map((adapter) => (
|
||||
<SelectItem key={adapter.id} value={adapter.id}>
|
||||
{adapter.name}
|
||||
@@ -197,7 +209,16 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isHarnessRuntime ? (
|
||||
{isHermesRuntime ? (
|
||||
<ProviderSelector
|
||||
providers={hermesProviders}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={hermesSelectedProviderId}
|
||||
onSelect={onHermesProviderChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isClassicHarnessRuntime ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="harness-model">Model</Label>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { AgentEntry } from './useOpenClaw'
|
||||
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex'
|
||||
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 =
|
||||
| {
|
||||
@@ -22,6 +37,10 @@ export type AgentHarnessStreamEvent =
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'produced_files'
|
||||
files: HarnessProducedFile[]
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
@@ -33,6 +52,8 @@ export type AgentHarnessStreamEvent =
|
||||
code?: string
|
||||
}
|
||||
|
||||
export type HarnessAgentLiveness = 'working' | 'idle' | 'asleep' | 'error'
|
||||
|
||||
export interface HarnessAgent {
|
||||
id: string
|
||||
name: string
|
||||
@@ -43,6 +64,54 @@ export interface HarnessAgent {
|
||||
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 {
|
||||
@@ -53,6 +122,7 @@ export interface HarnessAdapterDescriptor {
|
||||
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 {
|
||||
@@ -60,21 +130,48 @@ export interface CreateHarnessAgentInput {
|
||||
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 HarnessTranscriptEntry {
|
||||
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: HarnessTranscriptEntry[]
|
||||
items: HarnessHistoryEntry[]
|
||||
}
|
||||
|
||||
export function mapHarnessAgentToEntry(agent: HarnessAgent): AgentEntry {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
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>
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
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+/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,51 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
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)
|
||||
}
|
||||
@@ -20,17 +20,22 @@ import type {
|
||||
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,
|
||||
@@ -114,20 +119,37 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
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: input.harnessModelId || undefined,
|
||||
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: input.harnessModelId || undefined,
|
||||
model_id: effectiveModelId,
|
||||
reasoning_effort: input.harnessReasoningEffort || undefined,
|
||||
provider_type: hermesProvider?.type,
|
||||
})
|
||||
input.navigate(`/agents/${agent.id}`)
|
||||
} catch (err) {
|
||||
@@ -140,6 +162,7 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
openclaw: handleOpenClawCreate,
|
||||
claude: handleHarnessCreate,
|
||||
codex: handleHarnessCreate,
|
||||
hermes: handleHarnessCreate,
|
||||
}
|
||||
void createByRuntime[input.createRuntime]()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type { CreateAgentRuntime } from './agents-page-types'
|
||||
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
|
||||
import { toProviderOptions } from './agents-page-utils'
|
||||
import { getHermesSupportedProviders } from './hermes-supported-providers'
|
||||
import {
|
||||
buildOpenClawCliProviderOptions,
|
||||
findOpenClawCliProviderById,
|
||||
@@ -171,3 +172,60 @@ export function useOpenClawProviderSelection(input: {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -138,24 +138,20 @@ export function getVisibleOpenClawAgents(
|
||||
}
|
||||
|
||||
export function getAgentsLoading(input: {
|
||||
statusLoading: boolean
|
||||
adaptersLoading: boolean
|
||||
harnessAgentsLoading: boolean
|
||||
openClawAgentsEnabled: boolean
|
||||
openClawAgentsLoading: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
input.statusLoading ||
|
||||
input.adaptersLoading ||
|
||||
input.harnessAgentsLoading ||
|
||||
(input.openClawAgentsEnabled && input.openClawAgentsLoading)
|
||||
input.openClawAgentsLoading
|
||||
)
|
||||
}
|
||||
|
||||
export function getInlineError(input: {
|
||||
lifecyclePending: boolean
|
||||
pageError: string | null
|
||||
statusError: Error | null
|
||||
openClawAgentsError: Error | null
|
||||
adaptersError: Error | null
|
||||
harnessAgentsError: Error | null
|
||||
@@ -163,7 +159,6 @@ export function getInlineError(input: {
|
||||
if (input.lifecyclePending) return null
|
||||
return (
|
||||
input.pageError ??
|
||||
input.statusError?.message ??
|
||||
input.openClawAgentsError?.message ??
|
||||
input.adaptersError?.message ??
|
||||
input.harnessAgentsError?.message ??
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -8,17 +8,35 @@ import {
|
||||
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 }
|
||||
|
||||
const AGENT_QUERY_KEYS = {
|
||||
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
|
||||
|
||||
async function agentsFetch<T>(
|
||||
export async function agentsFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
@@ -69,21 +87,31 @@ export function useHarnessAgents(enabled = true) {
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<HarnessAgent[], Error>({
|
||||
const query = useQuery<HarnessAgentsResponse, Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents, baseUrl],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<{ agents: HarnessAgent[] }>(
|
||||
const data = await agentsFetch<HarnessAgentsResponse>(
|
||||
baseUrl as string,
|
||||
'/',
|
||||
)
|
||||
return data.agents ?? []
|
||||
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 ?? []).map(mapHarnessAgentToEntry),
|
||||
harnessAgents: query.data ?? [],
|
||||
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,
|
||||
@@ -114,6 +142,63 @@ export function useCreateHarnessAgent() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
@@ -141,16 +226,97 @@ 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 }),
|
||||
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> {
|
||||
@@ -160,3 +326,145 @@ export async function fetchHarnessAgentHistory(
|
||||
`/${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],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export interface AgentEntry {
|
||||
@@ -319,25 +318,3 @@ export function buildChatHistoryFromTurns(
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export async function chatWithAgent(
|
||||
agentId: string,
|
||||
message: string,
|
||||
sessionKey?: string,
|
||||
history: OpenClawChatHistoryMessage[] = [],
|
||||
signal?: AbortSignal,
|
||||
attachments?: ReadonlyArray<unknown>,
|
||||
): 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,
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ export const SidebarLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<div className="relative min-h-screen bg-background">
|
||||
{/* 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">
|
||||
{/* Sidebar - fixed overlay */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover interactions needed */}
|
||||
<div
|
||||
@@ -96,7 +97,6 @@ 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 />
|
||||
|
||||
@@ -164,9 +164,17 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
const resolvedProvider: Provider | null = (() => {
|
||||
const id = selectedProviderId ?? defaultProviderId
|
||||
const found = providers.find((p) => p.id === id)
|
||||
if (found) return { id: found.id, name: found.name, type: found.type }
|
||||
if (found) {
|
||||
return {
|
||||
kind: 'llm' as const,
|
||||
id: found.id,
|
||||
name: found.name,
|
||||
type: found.type,
|
||||
}
|
||||
}
|
||||
if (providers[0])
|
||||
return {
|
||||
kind: 'llm' as const,
|
||||
id: providers[0].id,
|
||||
name: providers[0].name,
|
||||
type: providers[0].type,
|
||||
@@ -175,6 +183,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
})()
|
||||
|
||||
const providerOptions: Provider[] = providers.map((p) => ({
|
||||
kind: 'llm',
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Github, History, Plus, SettingsIcon } from 'lucide-react'
|
||||
import { Bot, Github, History, Plus, SettingsIcon } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
@@ -64,7 +64,9 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
{selectedProvider.kind === 'acp' ? (
|
||||
<Bot className="h-[18px] w-[18px]" />
|
||||
) : selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgent,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import {
|
||||
buildSidepanelChatTargets,
|
||||
persistSidepanelChatTargetSelection,
|
||||
resolveSidepanelChatTarget,
|
||||
type SidepanelChatTargetSelection,
|
||||
toLlmProviderConfig,
|
||||
} from './sidepanel-chat-targets'
|
||||
|
||||
const timestamp = 1000
|
||||
|
||||
const providers: LlmProviderConfig[] = [
|
||||
{
|
||||
id: 'browseros',
|
||||
type: 'browseros',
|
||||
name: 'BrowserOS',
|
||||
baseUrl: 'https://api.browseros.com/v1',
|
||||
modelId: 'browseros-auto',
|
||||
supportsImages: true,
|
||||
contextWindow: 200000,
|
||||
temperature: 0.2,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
id: 'anthropic-sonnet',
|
||||
type: 'anthropic',
|
||||
name: 'Anthropic Sonnet',
|
||||
modelId: 'claude-sonnet-4-6',
|
||||
apiKey: 'sk-ant',
|
||||
supportsImages: true,
|
||||
contextWindow: 200000,
|
||||
temperature: 0.2,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
]
|
||||
|
||||
const adapters: HarnessAdapterDescriptor[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude Code',
|
||||
defaultModelId: 'haiku',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
models: [
|
||||
{ id: 'sonnet', label: 'Sonnet' },
|
||||
{ id: 'haiku', label: 'Haiku', recommended: true },
|
||||
],
|
||||
reasoningEfforts: [
|
||||
{ id: 'medium', label: 'Medium', recommended: true },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
defaultModelId: 'gpt-5.5',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'runtime-supported',
|
||||
models: [{ id: 'gpt-5.5', label: 'GPT-5.5', recommended: true }],
|
||||
reasoningEfforts: [{ id: 'medium', label: 'Medium', recommended: true }],
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
name: 'OpenClaw',
|
||||
defaultModelId: 'default',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
models: [],
|
||||
reasoningEfforts: [
|
||||
{ id: 'medium', label: 'Medium', recommended: true },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const agents: HarnessAgent[] = [
|
||||
{
|
||||
id: 'agent-codex',
|
||||
name: 'Review Bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-codex:main',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
id: 'agent-openclaw',
|
||||
name: 'Research Claw',
|
||||
adapter: 'openclaw',
|
||||
modelId: 'default',
|
||||
reasoningEffort: 'high',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-openclaw:main',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
]
|
||||
|
||||
describe('buildSidepanelChatTargets', () => {
|
||||
it('returns LLM targets plus one ACP target per persisted harness agent', () => {
|
||||
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
|
||||
|
||||
expect(targets.map((target) => target.id)).toEqual([
|
||||
'browseros',
|
||||
'anthropic-sonnet',
|
||||
'agent-codex',
|
||||
'agent-openclaw',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not emit catalog-only ACP targets without persisted agents', () => {
|
||||
const targets = buildSidepanelChatTargets({
|
||||
providers,
|
||||
adapters,
|
||||
agents: [],
|
||||
})
|
||||
|
||||
expect(targets.map((target) => target.id)).toEqual([
|
||||
'browseros',
|
||||
'anthropic-sonnet',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the created OpenClaw agent name instead of a generic adapter target', () => {
|
||||
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
|
||||
const openclaw = targets.find((target) => target.id === 'agent-openclaw')
|
||||
|
||||
expect(openclaw).toMatchObject({
|
||||
kind: 'acp',
|
||||
id: 'agent-openclaw',
|
||||
agentId: 'agent-openclaw',
|
||||
adapter: 'openclaw',
|
||||
adapterName: 'OpenClaw',
|
||||
modelId: 'default',
|
||||
modelLabel: 'default',
|
||||
name: 'Research Claw',
|
||||
modelControl: 'best-effort',
|
||||
reasoningEffort: 'high',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves adapter metadata for created agent targets', () => {
|
||||
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
|
||||
const codex = targets.find((target) => target.id === 'agent-codex')
|
||||
|
||||
expect(codex).toMatchObject({
|
||||
kind: 'acp',
|
||||
agentId: 'agent-codex',
|
||||
adapter: 'codex',
|
||||
adapterName: 'Codex',
|
||||
modelId: 'gpt-5.5',
|
||||
modelLabel: 'GPT-5.5',
|
||||
modelControl: 'runtime-supported',
|
||||
recommended: true,
|
||||
reasoningEffort: 'medium',
|
||||
reasoningEffortLabel: 'Medium',
|
||||
})
|
||||
})
|
||||
|
||||
it('still returns LLM targets when agents and adapters are unavailable', () => {
|
||||
expect(
|
||||
buildSidepanelChatTargets({ providers, adapters: [], agents: [] }),
|
||||
).toEqual([
|
||||
{
|
||||
kind: 'llm',
|
||||
id: 'browseros',
|
||||
name: 'BrowserOS',
|
||||
type: 'browseros',
|
||||
provider: providers[0],
|
||||
},
|
||||
{
|
||||
kind: 'llm',
|
||||
id: 'anthropic-sonnet',
|
||||
name: 'Anthropic Sonnet',
|
||||
type: 'anthropic',
|
||||
provider: providers[1],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSidepanelChatTarget', () => {
|
||||
it('resolves selected LLM targets back to their provider config', () => {
|
||||
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
|
||||
const resolved = resolveSidepanelChatTarget({
|
||||
targets,
|
||||
defaultProviderId: 'browseros',
|
||||
selection: { kind: 'llm', id: 'anthropic-sonnet' },
|
||||
})
|
||||
|
||||
expect(resolved?.kind).toBe('llm')
|
||||
expect(toLlmProviderConfig(resolved)?.modelId).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
it('falls back to the current default LLM provider when a persisted ACP target is stale', () => {
|
||||
const targets = buildSidepanelChatTargets({
|
||||
providers,
|
||||
adapters,
|
||||
agents: [],
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveSidepanelChatTarget({
|
||||
targets,
|
||||
defaultProviderId: 'anthropic-sonnet',
|
||||
selection: { kind: 'acp', id: 'agent-codex' },
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'llm',
|
||||
id: 'anthropic-sonnet',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back when an old catalog-style ACP target id is persisted', () => {
|
||||
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
|
||||
|
||||
expect(
|
||||
resolveSidepanelChatTarget({
|
||||
targets,
|
||||
defaultProviderId: 'anthropic-sonnet',
|
||||
selection: { kind: 'acp', id: 'acp:codex:gpt-5.5:medium' },
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'llm',
|
||||
id: 'anthropic-sonnet',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistSidepanelChatTargetSelection', () => {
|
||||
it('stores only target identity and does not mutate LLM provider arrays', async () => {
|
||||
let savedSelection: SidepanelChatTargetSelection | null = null
|
||||
const originalProviders = providers.map((provider) => ({ ...provider }))
|
||||
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
|
||||
const target = targets.find((candidate) => candidate.id === 'agent-codex')
|
||||
|
||||
await persistSidepanelChatTargetSelection(target, {
|
||||
setValue: async (value) => {
|
||||
savedSelection = value
|
||||
},
|
||||
})
|
||||
|
||||
expect(savedSelection as SidepanelChatTargetSelection | null).toEqual({
|
||||
kind: 'acp',
|
||||
id: 'agent-codex',
|
||||
})
|
||||
expect(providers).toEqual(originalProviders)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
export type SidepanelTargetKind = 'llm' | 'acp'
|
||||
|
||||
export type SidepanelChatTarget =
|
||||
| {
|
||||
kind: 'llm'
|
||||
id: string
|
||||
name: string
|
||||
type: ProviderType
|
||||
provider: LlmProviderConfig
|
||||
}
|
||||
| {
|
||||
kind: 'acp'
|
||||
id: string
|
||||
name: string
|
||||
type: 'acp'
|
||||
agentId: string
|
||||
adapter: HarnessAgentAdapter
|
||||
adapterName: string
|
||||
modelId: string
|
||||
modelLabel: string
|
||||
modelControl: HarnessAdapterDescriptor['modelControl']
|
||||
recommended?: boolean
|
||||
reasoningEffort: string
|
||||
reasoningEffortLabel?: string
|
||||
}
|
||||
|
||||
export type SidepanelChatTargetSelection = Pick<
|
||||
SidepanelChatTarget,
|
||||
'kind' | 'id'
|
||||
>
|
||||
|
||||
interface BuildSidepanelChatTargetsInput {
|
||||
providers: LlmProviderConfig[]
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
agents?: HarnessAgent[]
|
||||
}
|
||||
|
||||
interface ResolveSidepanelChatTargetInput {
|
||||
targets: SidepanelChatTarget[]
|
||||
defaultProviderId: string
|
||||
selection?: SidepanelChatTargetSelection | null
|
||||
}
|
||||
|
||||
interface SidepanelChatTargetSelectionWriter {
|
||||
setValue(value: SidepanelChatTargetSelection | null): Promise<void>
|
||||
}
|
||||
|
||||
interface SidepanelChatTargetSelectionReader {
|
||||
getValue(): Promise<SidepanelChatTargetSelection | null>
|
||||
}
|
||||
|
||||
type SidepanelChatTargetSelectionStore = SidepanelChatTargetSelectionReader &
|
||||
SidepanelChatTargetSelectionWriter
|
||||
|
||||
let sidepanelChatTargetSelectionStorage:
|
||||
| SidepanelChatTargetSelectionStore
|
||||
| undefined
|
||||
|
||||
export function buildSidepanelChatTargets({
|
||||
providers,
|
||||
adapters,
|
||||
agents = [],
|
||||
}: BuildSidepanelChatTargetsInput): SidepanelChatTarget[] {
|
||||
return [
|
||||
...providers.map(toLlmTarget),
|
||||
...agents.map((agent) => toAcpTargetForAgent(agent, adapters)),
|
||||
]
|
||||
}
|
||||
|
||||
function toAcpTargetForAgent(
|
||||
agent: HarnessAgent,
|
||||
adapters: HarnessAdapterDescriptor[],
|
||||
): SidepanelChatTarget {
|
||||
const adapter = adapters.find((entry) => entry.id === agent.adapter)
|
||||
const modelId = agent.modelId ?? adapter?.defaultModelId ?? 'default'
|
||||
const reasoningEffort =
|
||||
agent.reasoningEffort ?? adapter?.defaultReasoningEffort ?? 'medium'
|
||||
const model = adapter?.models.find((entry) => entry.id === modelId)
|
||||
const reasoning = adapter?.reasoningEfforts.find(
|
||||
(effort) => effort.id === reasoningEffort,
|
||||
)
|
||||
|
||||
return {
|
||||
kind: 'acp',
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
type: 'acp',
|
||||
agentId: agent.id,
|
||||
adapter: agent.adapter,
|
||||
adapterName: adapter?.name ?? formatAdapterName(agent.adapter),
|
||||
modelId,
|
||||
modelLabel: model?.label ?? modelId,
|
||||
modelControl: adapter?.modelControl ?? 'best-effort',
|
||||
recommended: model?.recommended,
|
||||
reasoningEffort,
|
||||
reasoningEffortLabel: reasoning?.label,
|
||||
}
|
||||
}
|
||||
|
||||
function formatAdapterName(adapter: HarnessAgentAdapter): string {
|
||||
if (adapter === 'claude') return 'Claude Code'
|
||||
if (adapter === 'codex') return 'Codex'
|
||||
if (adapter === 'openclaw') return 'OpenClaw'
|
||||
if (adapter === 'hermes') return 'Hermes'
|
||||
return adapter
|
||||
}
|
||||
|
||||
export function resolveSidepanelChatTarget({
|
||||
targets,
|
||||
defaultProviderId,
|
||||
selection,
|
||||
}: ResolveSidepanelChatTargetInput): SidepanelChatTarget | undefined {
|
||||
if (selection) {
|
||||
const selected = targets.find(
|
||||
(target) => target.kind === selection.kind && target.id === selection.id,
|
||||
)
|
||||
if (selected) return selected
|
||||
}
|
||||
|
||||
return (
|
||||
targets.find(
|
||||
(target) => target.kind === 'llm' && target.id === defaultProviderId,
|
||||
) ?? targets.find((target) => target.kind === 'llm')
|
||||
)
|
||||
}
|
||||
|
||||
export function toLlmProviderConfig(
|
||||
target: SidepanelChatTarget | undefined,
|
||||
): LlmProviderConfig | undefined {
|
||||
return target?.kind === 'llm' ? target.provider : undefined
|
||||
}
|
||||
|
||||
export async function persistSidepanelChatTargetSelection(
|
||||
target: SidepanelChatTarget | undefined,
|
||||
store?: SidepanelChatTargetSelectionWriter,
|
||||
): Promise<void> {
|
||||
const targetStore = store ?? (await getSidepanelChatTargetSelectionStorage())
|
||||
await targetStore.setValue(
|
||||
target ? { kind: target.kind, id: target.id } : null,
|
||||
)
|
||||
}
|
||||
|
||||
export async function loadSidepanelChatTargetSelection(
|
||||
store?: SidepanelChatTargetSelectionReader,
|
||||
): Promise<SidepanelChatTargetSelection | null> {
|
||||
const targetStore = store ?? (await getSidepanelChatTargetSelectionStorage())
|
||||
return targetStore.getValue()
|
||||
}
|
||||
|
||||
function toLlmTarget(provider: LlmProviderConfig): SidepanelChatTarget {
|
||||
return {
|
||||
kind: 'llm',
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSidepanelChatTargetSelectionStorage(): Promise<SidepanelChatTargetSelectionStore> {
|
||||
if (sidepanelChatTargetSelectionStorage) {
|
||||
return sidepanelChatTargetSelectionStorage
|
||||
}
|
||||
|
||||
const { storage } = await import('@wxt-dev/storage')
|
||||
sidepanelChatTargetSelectionStorage =
|
||||
storage.defineItem<SidepanelChatTargetSelection | null>(
|
||||
'local:sidepanel-chat-target-selection',
|
||||
{ fallback: null },
|
||||
)
|
||||
return sidepanelChatTargetSelectionStorage
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import {
|
||||
useAgentAdapters,
|
||||
useHarnessAgents,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { type McpServer, useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||
import { usePersonalization } from '@/lib/personalization/personalizationStorage'
|
||||
import {
|
||||
buildSidepanelChatTargets,
|
||||
loadSidepanelChatTargetSelection,
|
||||
persistSidepanelChatTargetSelection,
|
||||
resolveSidepanelChatTarget,
|
||||
type SidepanelChatTarget,
|
||||
type SidepanelChatTargetSelection,
|
||||
} from './sidepanel-chat-targets'
|
||||
|
||||
const constructMcpServers = (servers: McpServer[]) => {
|
||||
return servers
|
||||
@@ -23,14 +35,53 @@ const constructCustomServers = (servers: McpServer[]) => {
|
||||
export const useChatRefs = () => {
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
const {
|
||||
providers: llmProviders,
|
||||
selectedProvider: selectedLlmProvider,
|
||||
setDefaultProvider,
|
||||
isLoading: isLoadingProviders,
|
||||
} = useLlmProviders()
|
||||
const { adapters, loading: isLoadingAdapters } = useAgentAdapters()
|
||||
const { harnessAgents, loading: isLoadingAgents } = useHarnessAgents()
|
||||
const { personalization } = usePersonalization()
|
||||
const [targetSelection, setTargetSelection] =
|
||||
useState<SidepanelChatTargetSelection | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadSidepanelChatTargetSelection().then((selection) => {
|
||||
if (!cancelled) setTargetSelection(selection)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const chatTargets = useMemo(
|
||||
() =>
|
||||
buildSidepanelChatTargets({
|
||||
providers: llmProviders,
|
||||
adapters,
|
||||
agents: harnessAgents,
|
||||
}),
|
||||
[llmProviders, adapters, harnessAgents],
|
||||
)
|
||||
|
||||
const selectedChatTarget = useMemo(
|
||||
() =>
|
||||
resolveSidepanelChatTarget({
|
||||
targets: chatTargets,
|
||||
defaultProviderId: selectedLlmProvider?.id ?? llmProviders[0]?.id ?? '',
|
||||
selection: targetSelection,
|
||||
}),
|
||||
[chatTargets, llmProviders, selectedLlmProvider, targetSelection],
|
||||
)
|
||||
|
||||
const selectedLlmProviderRef = useRef<LlmProviderConfig | null>(
|
||||
selectedLlmProvider,
|
||||
)
|
||||
const selectedChatTargetRef = useRef<SidepanelChatTarget | undefined>(
|
||||
selectedChatTarget,
|
||||
)
|
||||
const enabledMcpServersRef = useRef(constructMcpServers(mcpServers))
|
||||
const enabledCustomServersRef = useRef(constructCustomServers(mcpServers))
|
||||
const personalizationRef = useRef(personalization)
|
||||
@@ -41,16 +92,36 @@ export const useChatRefs = () => {
|
||||
enabledCustomServersRef.current = constructCustomServers(mcpServers)
|
||||
}, [selectedLlmProvider, mcpServers])
|
||||
|
||||
useEffect(() => {
|
||||
selectedChatTargetRef.current = selectedChatTarget
|
||||
}, [selectedChatTarget])
|
||||
|
||||
useEffect(() => {
|
||||
personalizationRef.current = personalization
|
||||
}, [personalization])
|
||||
|
||||
const selectChatTarget = useCallback(
|
||||
async (target: SidepanelChatTarget | undefined) => {
|
||||
selectedChatTargetRef.current = target
|
||||
setTargetSelection(target ? { kind: target.kind, id: target.id } : null)
|
||||
await persistSidepanelChatTargetSelection(target)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedLlmProviderRef,
|
||||
selectedChatTargetRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
llmProviders,
|
||||
setDefaultProvider,
|
||||
chatTargets,
|
||||
selectedChatTarget,
|
||||
selectChatTarget,
|
||||
selectedLlmProvider,
|
||||
isLoadingProviders,
|
||||
isLoadingProviders:
|
||||
isLoadingProviders || isLoadingAdapters || isLoadingAgents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import type { ChatMode } from './chatTypes'
|
||||
import type { SidepanelChatTarget } from './sidepanel-chat-targets'
|
||||
import { buildSidepanelPreparedSendMessagesRequest } from './useChatSessionRequest'
|
||||
|
||||
const conversationId = '00000000-0000-4000-8000-000000000001'
|
||||
|
||||
describe('buildSidepanelPreparedSendMessagesRequest', () => {
|
||||
it('keeps LLM targets on the existing /chat request body', () => {
|
||||
const request = buildSidepanelPreparedSendMessagesRequest({
|
||||
agentServerUrl: 'http://127.0.0.1:5151',
|
||||
target: llmTarget,
|
||||
fallbackProvider,
|
||||
message: 'Summarize this page',
|
||||
...commonRequestInput(),
|
||||
})
|
||||
|
||||
expect(request.api).toBe('http://127.0.0.1:5151/chat')
|
||||
expect(request.body).toMatchObject({
|
||||
message: 'Summarize this page',
|
||||
conversationId,
|
||||
provider: 'browseros',
|
||||
providerType: 'browseros',
|
||||
providerName: 'BrowserOS',
|
||||
model: 'gpt-5',
|
||||
mode: 'agent',
|
||||
browserContext: {
|
||||
activeTab: { id: 10, url: 'https://example.com', title: 'Example' },
|
||||
enabledMcpServers: ['slack'],
|
||||
},
|
||||
userSystemPrompt: 'Be concise',
|
||||
userWorkingDir: '/tmp/work',
|
||||
previousConversation: [{ role: 'assistant', content: 'Prior answer' }],
|
||||
selectedText: 'selected text',
|
||||
selectedTextSource: {
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('sends created-agent targets to the agent-id sidepanel route', () => {
|
||||
const request = buildSidepanelPreparedSendMessagesRequest({
|
||||
agentServerUrl: 'http://127.0.0.1:5151',
|
||||
target: acpTarget,
|
||||
fallbackProvider,
|
||||
message: 'Inspect the current tab',
|
||||
approvalResponses: [
|
||||
{ approvalId: 'approval-1', approved: true, reason: 'ok' },
|
||||
],
|
||||
...commonRequestInput(),
|
||||
})
|
||||
|
||||
expect(request.api).toBe(
|
||||
'http://127.0.0.1:5151/agents/agent-codex/sidepanel/chat',
|
||||
)
|
||||
expect(request.body).toEqual({
|
||||
conversationId,
|
||||
message: 'Inspect the current tab',
|
||||
browserContext: {
|
||||
activeTab: { id: 10, url: 'https://example.com', title: 'Example' },
|
||||
enabledMcpServers: ['slack'],
|
||||
},
|
||||
userSystemPrompt: 'Be concise',
|
||||
userWorkingDir: '/tmp/work',
|
||||
selectedText: 'selected text',
|
||||
selectedTextSource: {
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps tool approval retry payloads scoped to LLM chat', () => {
|
||||
const request = buildSidepanelPreparedSendMessagesRequest({
|
||||
agentServerUrl: 'http://127.0.0.1:5151',
|
||||
target: llmTarget,
|
||||
fallbackProvider,
|
||||
approvalResponses: [
|
||||
{ approvalId: 'approval-1', approved: false, reason: 'no' },
|
||||
],
|
||||
...commonRequestInput(),
|
||||
})
|
||||
|
||||
expect(request.api).toBe('http://127.0.0.1:5151/chat')
|
||||
expect(request.body).toMatchObject({
|
||||
message: '',
|
||||
toolApprovalResponses: [
|
||||
{ approvalId: 'approval-1', approved: false, reason: 'no' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function commonRequestInput() {
|
||||
return {
|
||||
conversationId,
|
||||
mode: 'agent' as ChatMode,
|
||||
browserContext: {
|
||||
activeTab: { id: 10, url: 'https://example.com', title: 'Example' },
|
||||
enabledMcpServers: ['slack'],
|
||||
},
|
||||
userSystemPrompt: 'Be concise',
|
||||
userWorkingDir: '/tmp/work',
|
||||
previousConversation: [
|
||||
{ role: 'assistant' as const, content: 'Prior answer' },
|
||||
],
|
||||
declinedApps: ['gmail'],
|
||||
aclRules: [{ id: 'rule-1', sitePattern: '*://*/*', enabled: true }],
|
||||
selectedText: 'selected text',
|
||||
selectedTextSource: {
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
},
|
||||
toolApprovalConfig: { categories: { navigation: true } },
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackProvider: LlmProviderConfig = {
|
||||
id: 'browseros',
|
||||
type: 'browseros',
|
||||
name: 'BrowserOS',
|
||||
modelId: 'gpt-5',
|
||||
supportsImages: true,
|
||||
contextWindow: 128000,
|
||||
temperature: 0.7,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
|
||||
const llmTarget: SidepanelChatTarget = {
|
||||
kind: 'llm',
|
||||
id: fallbackProvider.id,
|
||||
name: fallbackProvider.name,
|
||||
type: fallbackProvider.type,
|
||||
provider: fallbackProvider,
|
||||
}
|
||||
|
||||
const acpTarget: SidepanelChatTarget = {
|
||||
kind: 'acp',
|
||||
id: 'agent-codex',
|
||||
name: 'Review bot',
|
||||
type: 'acp',
|
||||
agentId: 'agent-codex',
|
||||
adapter: 'codex',
|
||||
adapterName: 'Codex',
|
||||
modelId: 'gpt-5.5',
|
||||
modelLabel: 'GPT-5.5',
|
||||
modelControl: 'best-effort',
|
||||
reasoningEffort: 'medium',
|
||||
reasoningEffortLabel: 'Medium',
|
||||
}
|
||||
@@ -26,15 +26,14 @@ import { useInvalidateCredits } from '@/lib/credits/useCredits'
|
||||
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
|
||||
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
||||
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import {
|
||||
type ApprovalResponseData,
|
||||
buildChatRequestBody,
|
||||
type ChatRequestBrowserContext,
|
||||
import type {
|
||||
ApprovalResponseData,
|
||||
ChatRequestBrowserContext,
|
||||
} from '@/lib/messaging/server/buildChatRequestBody'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
||||
import {
|
||||
type ApprovalResponse,
|
||||
@@ -52,7 +51,12 @@ import {
|
||||
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
|
||||
import type { ChatMode } from './chatTypes'
|
||||
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
|
||||
import { toLlmProviderConfig } from './sidepanel-chat-targets'
|
||||
import { useChatRefs } from './useChatRefs'
|
||||
import {
|
||||
buildSidepanelPreparedSendMessagesRequest,
|
||||
toProviderOption,
|
||||
} from './useChatSessionRequest'
|
||||
import { useExecutionHistoryTracker } from './useExecutionHistoryTracker'
|
||||
import { useNotifyActiveTab } from './useNotifyActiveTab'
|
||||
import { useRemoteConversationSave } from './useRemoteConversationSave'
|
||||
@@ -186,16 +190,19 @@ const buildRequestBrowserContext = ({
|
||||
export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
selectedChatTargetRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
setDefaultProvider,
|
||||
chatTargets,
|
||||
selectedChatTarget,
|
||||
selectChatTarget,
|
||||
selectedLlmProvider,
|
||||
isLoadingProviders,
|
||||
} = useChatRefs()
|
||||
const invalidateCredits = useInvalidateCredits()
|
||||
|
||||
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
||||
|
||||
const {
|
||||
baseUrl: agentServerUrl,
|
||||
isLoading: isLoadingAgentUrl,
|
||||
@@ -218,11 +225,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
agentUrlRef.current = agentServerUrl
|
||||
}, [agentServerUrl])
|
||||
|
||||
const providers: Provider[] = llmProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
}))
|
||||
const providers: Provider[] = chatTargets.map(toProviderOption)
|
||||
|
||||
const [mode, setMode] = useState<ChatMode>('agent')
|
||||
const [textToAction, setTextToAction] = useState<Map<string, ChatAction>>(
|
||||
@@ -324,15 +327,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
textToActionRef.current = textToAction
|
||||
}, [mode, textToAction])
|
||||
|
||||
const selectedProvider = selectedLlmProvider
|
||||
? {
|
||||
id: selectedLlmProvider.id,
|
||||
name: selectedLlmProvider.name,
|
||||
type:
|
||||
selectedLlmProvider.id === 'browseros'
|
||||
? ('browseros' as const)
|
||||
: selectedLlmProvider.type,
|
||||
}
|
||||
const selectedProvider = selectedChatTarget
|
||||
? toProviderOption(selectedChatTarget)
|
||||
: providers[0]
|
||||
|
||||
const {
|
||||
@@ -346,7 +342,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const provider =
|
||||
const target = selectedChatTargetRef.current
|
||||
const fallbackProvider =
|
||||
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||
const activeTabsList = await chrome.tabs.query({
|
||||
active: true,
|
||||
@@ -395,51 +392,46 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
personalizationRef.current,
|
||||
)
|
||||
|
||||
const approvalResponses = extractApprovalResponses(messages)
|
||||
const commonRequest = {
|
||||
conversationId: conversationIdRef.current,
|
||||
mode: currentMode,
|
||||
browserContext: requestBrowserContext,
|
||||
userSystemPrompt,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
previousConversation,
|
||||
declinedApps,
|
||||
aclRules: enabledAclRules,
|
||||
toolApprovalConfig: approvalConfig,
|
||||
}
|
||||
|
||||
const approvalResponses =
|
||||
target?.kind === 'acp' ? null : extractApprovalResponses(messages)
|
||||
if (approvalResponses) {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/chat`,
|
||||
body: buildChatRequestBody({
|
||||
conversationId: conversationIdRef.current,
|
||||
provider,
|
||||
mode: currentMode,
|
||||
browserContext: requestBrowserContext,
|
||||
userSystemPrompt,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
previousConversation,
|
||||
declinedApps,
|
||||
aclRules: enabledAclRules,
|
||||
toolApprovalConfig: approvalConfig,
|
||||
toolApprovalResponses: approvalResponses,
|
||||
}),
|
||||
}
|
||||
return buildSidepanelPreparedSendMessagesRequest({
|
||||
agentServerUrl: agentUrlRef.current ?? undefined,
|
||||
target,
|
||||
fallbackProvider,
|
||||
...commonRequest,
|
||||
approvalResponses,
|
||||
})
|
||||
}
|
||||
|
||||
const message = getLastMessageText(messages)
|
||||
|
||||
const result = {
|
||||
api: `${agentUrlRef.current}/chat`,
|
||||
body: buildChatRequestBody({
|
||||
message,
|
||||
conversationId: conversationIdRef.current,
|
||||
provider,
|
||||
mode: currentMode,
|
||||
browserContext: requestBrowserContext,
|
||||
userSystemPrompt,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
previousConversation,
|
||||
declinedApps,
|
||||
aclRules: enabledAclRules,
|
||||
selectedText: activeTabSelection?.text,
|
||||
selectedTextSource: activeTabSelection
|
||||
? {
|
||||
url: activeTabSelection.url,
|
||||
title: activeTabSelection.title,
|
||||
}
|
||||
: undefined,
|
||||
toolApprovalConfig: approvalConfig,
|
||||
}),
|
||||
}
|
||||
const result = buildSidepanelPreparedSendMessagesRequest({
|
||||
agentServerUrl: agentUrlRef.current ?? undefined,
|
||||
target,
|
||||
fallbackProvider,
|
||||
message,
|
||||
...commonRequest,
|
||||
selectedText: activeTabSelection?.text,
|
||||
selectedTextSource: activeTabSelection
|
||||
? {
|
||||
url: activeTabSelection.url,
|
||||
title: activeTabSelection.title,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Track which tab's selection was sent so we can clear it on success
|
||||
pendingSelectionTabKeyRef.current =
|
||||
@@ -451,7 +443,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
sendAutomaticallyWhen: () => {
|
||||
if (approvalJustRespondedRef.current) {
|
||||
approvalJustRespondedRef.current = false
|
||||
return true
|
||||
return selectedChatTargetRef.current?.kind !== 'acp'
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -686,10 +678,22 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
}, [dispatchMessage, isIntegrationsSynced])
|
||||
|
||||
const sendMessage = (params: { text: string; action?: ChatAction }) => {
|
||||
const target = selectedChatTargetRef.current
|
||||
const llmTargetProvider = toLlmProviderConfig(target)
|
||||
const agentTarget = target?.kind === 'acp' ? target : undefined
|
||||
track(MESSAGE_SENT_EVENT, {
|
||||
mode,
|
||||
provider_type: selectedLlmProvider?.type,
|
||||
model: selectedLlmProvider?.modelId,
|
||||
provider_id:
|
||||
agentTarget?.agentId ??
|
||||
llmTargetProvider?.id ??
|
||||
selectedLlmProvider?.id,
|
||||
provider_type: agentTarget ? 'acp' : llmTargetProvider?.type,
|
||||
agent_id: agentTarget?.agentId,
|
||||
adapter: agentTarget?.adapter,
|
||||
model:
|
||||
agentTarget?.modelId ??
|
||||
llmTargetProvider?.modelId ??
|
||||
selectedLlmProvider?.modelId,
|
||||
})
|
||||
|
||||
if (!isIntegrationsSyncedRef.current) {
|
||||
@@ -741,14 +745,54 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
addToolApprovalResponse(params)
|
||||
}
|
||||
|
||||
const resetConversationState = () => {
|
||||
stop()
|
||||
void finishExecutionTask({ isAbort: true })
|
||||
setConversationId(crypto.randomUUID())
|
||||
setMessages([])
|
||||
setTextToAction(new Map())
|
||||
setLiked({})
|
||||
setDisliked({})
|
||||
setRestoredConversationId(null)
|
||||
resetRemoteConversation()
|
||||
}
|
||||
|
||||
const handleSelectProvider = (provider: Provider) => {
|
||||
const fullProvider = llmProviders.find((p) => p.id === provider.id)
|
||||
const target = chatTargets.find(
|
||||
(candidate) =>
|
||||
candidate.id === provider.id && candidate.kind === provider.kind,
|
||||
)
|
||||
if (!target) return
|
||||
|
||||
const previousTarget = selectedChatTargetRef.current
|
||||
track(PROVIDER_SELECTED_EVENT, {
|
||||
provider_id: provider.id,
|
||||
provider_type: provider.type,
|
||||
model_id: fullProvider?.modelId,
|
||||
provider_id: target.id,
|
||||
provider_type: target.kind === 'acp' ? 'acp' : target.type,
|
||||
model_id:
|
||||
target.kind === 'acp' ? target.modelId : target.provider.modelId,
|
||||
agent_id: target.kind === 'acp' ? target.agentId : undefined,
|
||||
adapter: target.kind === 'acp' ? target.adapter : undefined,
|
||||
})
|
||||
setDefaultProvider(provider.id)
|
||||
|
||||
void selectChatTarget(target).catch((error) => {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to persist sidepanel chat target selection',
|
||||
targetId: target.id,
|
||||
targetKind: target.kind,
|
||||
},
|
||||
})
|
||||
})
|
||||
if (target.kind === 'llm') setDefaultProvider(target.provider.id)
|
||||
|
||||
if (
|
||||
previousTarget &&
|
||||
(previousTarget.kind !== target.kind ||
|
||||
previousTarget.id !== target.id) &&
|
||||
messagesRef.current.length > 0
|
||||
) {
|
||||
resetConversationState()
|
||||
}
|
||||
}
|
||||
|
||||
const getActionForMessage = (message: UIMessage) => {
|
||||
@@ -762,15 +806,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
|
||||
const resetConversation = () => {
|
||||
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
|
||||
stop()
|
||||
void finishExecutionTask({ isAbort: true })
|
||||
setConversationId(crypto.randomUUID())
|
||||
setMessages([])
|
||||
setTextToAction(new Map())
|
||||
setLiked({})
|
||||
setDisliked({})
|
||||
setRestoredConversationId(null)
|
||||
resetRemoteConversation()
|
||||
resetConversationState()
|
||||
}
|
||||
|
||||
const isRestoringConversation =
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Provider } from '../../../components/chat/chatComponentTypes'
|
||||
import type { LlmProviderConfig } from '../../../lib/llm-providers/types'
|
||||
import {
|
||||
type ApprovalResponseData,
|
||||
buildChatRequestBody,
|
||||
} from '../../../lib/messaging/server/buildChatRequestBody'
|
||||
import {
|
||||
type SidepanelChatTarget,
|
||||
toLlmProviderConfig,
|
||||
} from './sidepanel-chat-targets'
|
||||
|
||||
type LlmChatRequestBodyInput = Parameters<typeof buildChatRequestBody>[0]
|
||||
|
||||
type CommonSidepanelRequestInput = Omit<
|
||||
LlmChatRequestBodyInput,
|
||||
'provider' | 'message' | 'toolApprovalResponses' | 'isScheduledTask'
|
||||
>
|
||||
|
||||
interface BuildSidepanelPreparedSendMessagesRequestInput
|
||||
extends CommonSidepanelRequestInput {
|
||||
agentServerUrl: string | undefined
|
||||
target: SidepanelChatTarget | undefined
|
||||
fallbackProvider: LlmProviderConfig
|
||||
message?: string
|
||||
approvalResponses?: ApprovalResponseData[] | null
|
||||
}
|
||||
|
||||
export function buildSidepanelPreparedSendMessagesRequest({
|
||||
agentServerUrl,
|
||||
target,
|
||||
fallbackProvider,
|
||||
message,
|
||||
approvalResponses,
|
||||
...common
|
||||
}: BuildSidepanelPreparedSendMessagesRequestInput) {
|
||||
if (target?.kind === 'acp') {
|
||||
return {
|
||||
api: `${agentServerUrl}/agents/${encodeURIComponent(target.agentId)}/sidepanel/chat`,
|
||||
body: {
|
||||
conversationId: common.conversationId,
|
||||
message: message ?? '',
|
||||
browserContext: common.browserContext,
|
||||
userSystemPrompt: common.userSystemPrompt,
|
||||
userWorkingDir: common.userWorkingDir,
|
||||
selectedText: common.selectedText,
|
||||
selectedTextSource: common.selectedTextSource,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const provider = toLlmProviderConfig(target) ?? fallbackProvider
|
||||
return {
|
||||
api: `${agentServerUrl}/chat`,
|
||||
body: buildChatRequestBody({
|
||||
...common,
|
||||
provider,
|
||||
message,
|
||||
toolApprovalResponses: approvalResponses ?? undefined,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toProviderOption(target: SidepanelChatTarget): Provider {
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
kind: target.kind,
|
||||
agentId: target.kind === 'acp' ? target.agentId : undefined,
|
||||
adapterName: target.kind === 'acp' ? target.adapterName : undefined,
|
||||
modelLabel: target.kind === 'acp' ? target.modelLabel : undefined,
|
||||
modelControl: target.kind === 'acp' ? target.modelControl : undefined,
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,34 @@ export interface UserAttachmentPreview {
|
||||
dataUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Files attributed to this turn by the harness's per-turn workspace
|
||||
* diff. Populated either via the live `produced_files` SSE event or
|
||||
* (on resume) the `useAgentTurnFiles` fallback. Mirrors the wire
|
||||
* shape from `agent-harness-types.HarnessProducedFile` minus the
|
||||
* stream-only fields the inline card doesn't need.
|
||||
*/
|
||||
export interface ConversationTurnFile {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface AgentConversationTurn {
|
||||
id: string
|
||||
/**
|
||||
* Server-issued turn id, set as soon as the response headers arrive
|
||||
* (`X-Turn-Id`) for fresh sends, or from the active-turn payload on
|
||||
* resume. Required for the historic-files fallback fetch; absent on
|
||||
* the brief optimistic window before the first header.
|
||||
*/
|
||||
turnId?: string | null
|
||||
userText: string
|
||||
userAttachments?: UserAttachmentPreview[]
|
||||
parts: AssistantPart[]
|
||||
/** Files produced during this turn (openclaw only in v1). */
|
||||
producedFiles?: ConversationTurnFile[]
|
||||
done: boolean
|
||||
timestamp: number
|
||||
}
|
||||
@@ -59,15 +82,3 @@ export interface AgentConversation {
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AgentCardData {
|
||||
agentId: string
|
||||
name: string
|
||||
model?: string
|
||||
status: 'idle' | 'working' | 'error'
|
||||
lastMessage?: string
|
||||
lastMessageTimestamp?: number
|
||||
activitySummary?: string
|
||||
currentTool?: string
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure helpers used by the artifact card and the Outputs rail.
|
||||
* Display formatting only — no React, no fetch, no DOM. Anything
|
||||
* stateful belongs in `./useAgentOutputs` or `./useFilePreview`.
|
||||
*/
|
||||
|
||||
import { buildAgentApiUrl } from '@/entrypoints/app/agents/agent-api-url'
|
||||
|
||||
/**
|
||||
* Coarse classification of a file's intended preview / icon path.
|
||||
* Mirrors the server-side `FilePreviewKind` minus `missing` — the
|
||||
* client only ever computes a kind for a row it already has.
|
||||
*/
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'binary'
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'json',
|
||||
'jsonl',
|
||||
'csv',
|
||||
'tsv',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'log',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'swift',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'sh',
|
||||
'zsh',
|
||||
'bash',
|
||||
'sql',
|
||||
'svg',
|
||||
])
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'ico',
|
||||
'heic',
|
||||
'heif',
|
||||
])
|
||||
|
||||
/** Best-effort kind based on extension only. Server's preview API
|
||||
* is the source of truth for actual rendering — this is just for
|
||||
* picking an icon / sort hint without a network round-trip. */
|
||||
export function inferFileKind(path: string): FileKind {
|
||||
const ext = extensionOf(path).toLowerCase()
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||
if (TEXT_EXTENSIONS.has(ext)) return 'text'
|
||||
return 'binary'
|
||||
}
|
||||
|
||||
/** Plain extension without the leading dot. Empty string when none. */
|
||||
export function extensionOf(path: string): string {
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot === -1) return ''
|
||||
const slash = path.lastIndexOf('/')
|
||||
if (dot < slash) return ''
|
||||
return path.slice(dot + 1)
|
||||
}
|
||||
|
||||
/** File name (final path segment), no directory prefix. */
|
||||
export function basenameOf(path: string): string {
|
||||
const slash = path.lastIndexOf('/')
|
||||
return slash === -1 ? path : path.slice(slash + 1)
|
||||
}
|
||||
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
/** "2.4 MB" / "340 KB" / "78 B" — for the artifact card's right-side
|
||||
* metadata. Not localised; the rail uses one space + the unit. */
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} ${SIZE_UNITS[0]}`
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
// 1-digit precision below 10, integer above — feels less noisy.
|
||||
const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString()
|
||||
return `${formatted} ${SIZE_UNITS[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-file download URL using the same agent-api root the
|
||||
* rest of the harness hits. Returned URL is already absolute.
|
||||
*/
|
||||
export function buildFileDownloadUrl(baseUrl: string, fileId: string): string {
|
||||
return buildAgentApiUrl(
|
||||
baseUrl,
|
||||
`/files/${encodeURIComponent(fileId)}/download`,
|
||||
)
|
||||
}
|
||||
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export {
|
||||
basenameOf,
|
||||
buildFileDownloadUrl,
|
||||
extensionOf,
|
||||
type FileKind,
|
||||
formatFileSize,
|
||||
inferFileKind,
|
||||
} from './file-helpers'
|
||||
export type {
|
||||
BinaryFilePreview,
|
||||
FilePreview,
|
||||
FilePreviewKind,
|
||||
ImageFilePreview,
|
||||
MissingFilePreview,
|
||||
PdfFilePreview,
|
||||
ProducedFile,
|
||||
ProducedFilesRailGroup,
|
||||
TextFilePreview,
|
||||
} from './types'
|
||||
export {
|
||||
useAgentOutputs,
|
||||
useAgentTurnFiles,
|
||||
useInvalidateAgentOutputs,
|
||||
useRefreshAgentOutputs,
|
||||
} from './useAgentOutputs'
|
||||
export { useFilePreview } from './useFilePreview'
|
||||
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Wire types shared by the inline artifact card and the per-agent
|
||||
* Outputs rail. These mirror `ProducedFileEntry` /
|
||||
* `ProducedFilesRailGroup` on the server and the `FilePreview`
|
||||
* discriminated union from `apps/server/src/api/services/openclaw/file-preview.ts`.
|
||||
*
|
||||
* The schema mirror is deliberate (vs sharing a workspace package)
|
||||
* because the server keeps the on-disk row shape — `agentDefinitionId`,
|
||||
* `sessionKey` — out of the wire payload. Dropping those columns at the
|
||||
* type boundary keeps the client honest about what it can refer to.
|
||||
*/
|
||||
|
||||
export interface ProducedFile {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
/** Server clock when the file was first attributed to its turn. */
|
||||
createdAt: number
|
||||
detectedBy: 'diff' | 'tool'
|
||||
}
|
||||
|
||||
export interface ProducedFilesRailGroup {
|
||||
turnId: string
|
||||
/** First non-blank line of the user prompt that initiated this turn. */
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export type FilePreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
|
||||
|
||||
interface BasePreview {
|
||||
kind: FilePreviewKind
|
||||
mimeType: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface TextFilePreview extends BasePreview {
|
||||
kind: 'text'
|
||||
snippet: string
|
||||
/** True when the on-disk file is larger than the server's snippet cap. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface ImageFilePreview extends BasePreview {
|
||||
kind: 'image'
|
||||
/** Base64 data URL (incl. `data:` prefix). Suitable for `<img src>`. */
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export interface PdfFilePreview extends BasePreview {
|
||||
kind: 'pdf'
|
||||
}
|
||||
|
||||
export interface BinaryFilePreview extends BasePreview {
|
||||
kind: 'binary'
|
||||
}
|
||||
|
||||
export interface MissingFilePreview {
|
||||
kind: 'missing'
|
||||
}
|
||||
|
||||
export type FilePreview =
|
||||
| TextFilePreview
|
||||
| ImageFilePreview
|
||||
| PdfFilePreview
|
||||
| BinaryFilePreview
|
||||
| MissingFilePreview
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* React Query hooks backing the per-agent Outputs rail and the
|
||||
* inline artifact card.
|
||||
*
|
||||
* Live updates: the consumer of `useAgentConversation` (see Phase 5)
|
||||
* is expected to call `useInvalidateAgentOutputs(agentId)` whenever
|
||||
* an assistant turn completes, so the rail picks up the new
|
||||
* `produced_files` rows the server attributed during that turn.
|
||||
* No SSE channel here — invalidation off the existing chat-stream
|
||||
* completion is enough for v1.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { ProducedFile, ProducedFilesRailGroup } from './types'
|
||||
|
||||
interface OutputsResponse {
|
||||
groups: ProducedFilesRailGroup[]
|
||||
}
|
||||
|
||||
interface TurnFilesResponse {
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export function useAgentOutputs(agentId: string, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFilesRailGroup[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<OutputsResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files`,
|
||||
)
|
||||
return data.groups ?? []
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
groups: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn fetch for the inline artifact card. Used both as the
|
||||
* fallback when an SSE `produced_files` event was missed, and to
|
||||
* rehydrate a turn the user scrolled back to.
|
||||
*/
|
||||
export function useAgentTurnFiles(
|
||||
agentId: string,
|
||||
turnId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFile[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentTurnFiles, baseUrl, agentId, turnId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<TurnFilesResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files/turn/${encodeURIComponent(
|
||||
turnId as string,
|
||||
)}`,
|
||||
)
|
||||
return data.files ?? []
|
||||
},
|
||||
enabled:
|
||||
Boolean(baseUrl) &&
|
||||
!urlLoading &&
|
||||
enabled &&
|
||||
Boolean(agentId) &&
|
||||
Boolean(turnId),
|
||||
})
|
||||
|
||||
return {
|
||||
files: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callable that invalidates outputs / turn-files queries
|
||||
* for one agent across any baseUrl. Call after an assistant turn
|
||||
* completes so the rail (and the inline file-card strip) pick up
|
||||
* the new attributed rows. Cheap when the queries aren't mounted
|
||||
* — react-query just marks the cached value stale.
|
||||
*
|
||||
* Implementation note: react-query's `invalidateQueries({ queryKey })`
|
||||
* does positional partial-match, so passing `undefined` as the
|
||||
* baseUrl placeholder does NOT match a cached `[…, baseUrl, …]`
|
||||
* key — the cache stayed stale. Use a predicate so we ignore the
|
||||
* baseUrl position entirely.
|
||||
*/
|
||||
export function useInvalidateAgentOutputs() {
|
||||
const queryClient = useQueryClient()
|
||||
return async (agentId: string, turnId?: string) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === AGENT_QUERY_KEYS.agentOutputs &&
|
||||
key[2] === agentId
|
||||
)
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
if (
|
||||
!Array.isArray(key) ||
|
||||
key[0] !== AGENT_QUERY_KEYS.agentTurnFiles ||
|
||||
key[2] !== agentId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// When a turnId was supplied, scope to just that turn's
|
||||
// entry. Otherwise flush every cached turn for this agent.
|
||||
return turnId ? key[3] === turnId : true
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny mutation wrapper so the Outputs rail's "Refresh" button can
|
||||
* surface an `isPending` indicator while the new query is in flight.
|
||||
* No body — just triggers `refetch` on the rail's query for this
|
||||
* agent and resolves when it settles.
|
||||
*/
|
||||
export function useRefreshAgentOutputs(agentId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
exact: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Single-file preview hook used by the inline artifact card and the
|
||||
* Outputs rail's preview Sheet. Always opt-in (`enabled`) — the
|
||||
* preview is fetched only when the user clicks a row, never
|
||||
* eagerly.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { FilePreview } from './types'
|
||||
|
||||
export function useFilePreview(fileId: string | null, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<FilePreview, Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.filePreview, baseUrl, fileId],
|
||||
queryFn: async () => {
|
||||
return agentsFetch<FilePreview>(
|
||||
baseUrl as string,
|
||||
`/files/${encodeURIComponent(fileId as string)}/preview`,
|
||||
)
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(fileId),
|
||||
// Previews are immutable for a given fileId — once loaded, never
|
||||
// refetch on focus / reconnect. They go stale only when the
|
||||
// underlying file is removed (rare in v1; no rename / delete).
|
||||
staleTime: Infinity,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
return {
|
||||
preview: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
76
packages/browseros-agent/apps/agent/lib/attachments.test.ts
Normal file
76
packages/browseros-agent/apps/agent/lib/attachments.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { stageAttachment } from './attachments'
|
||||
|
||||
function restoreGlobal(name: string, value: unknown) {
|
||||
if (value === undefined) {
|
||||
Reflect.deleteProperty(globalThis, name)
|
||||
return
|
||||
}
|
||||
Reflect.set(globalThis, name, value)
|
||||
}
|
||||
|
||||
describe('stageAttachment', () => {
|
||||
it('uses the recompressed blob media type for large images', async () => {
|
||||
const originalCreateImageBitmap = Reflect.get(
|
||||
globalThis,
|
||||
'createImageBitmap',
|
||||
)
|
||||
const originalOffscreenCanvas = Reflect.get(globalThis, 'OffscreenCanvas')
|
||||
const originalHTMLCanvasElement = Reflect.get(
|
||||
globalThis,
|
||||
'HTMLCanvasElement',
|
||||
)
|
||||
|
||||
class FakeOffscreenCanvas {
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
getContext() {
|
||||
return {
|
||||
drawImage() {},
|
||||
}
|
||||
}
|
||||
|
||||
async convertToBlob(options: { type?: string }) {
|
||||
return new Blob([new Uint8Array([9, 8, 7])], {
|
||||
type: options.type ?? 'image/jpeg',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Reflect.set(globalThis, 'createImageBitmap', async () => ({
|
||||
width: 4096,
|
||||
height: 2048,
|
||||
close() {},
|
||||
}))
|
||||
Reflect.set(globalThis, 'OffscreenCanvas', FakeOffscreenCanvas)
|
||||
Reflect.set(globalThis, 'HTMLCanvasElement', class HTMLCanvasElement {})
|
||||
|
||||
const file = new File([new Uint8Array(2 * 1024 * 1024)], 'shot.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
const result = await stageAttachment(file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
expect(result.attachment.mediaType).toBe('image/jpeg')
|
||||
expect(result.attachment.dataUrl).toStartWith('data:image/jpeg;base64,')
|
||||
expect(result.attachment.payload).toMatchObject({
|
||||
kind: 'image',
|
||||
mediaType: 'image/jpeg',
|
||||
dataUrl: result.attachment.dataUrl,
|
||||
})
|
||||
} finally {
|
||||
restoreGlobal('createImageBitmap', originalCreateImageBitmap)
|
||||
restoreGlobal('OffscreenCanvas', originalOffscreenCanvas)
|
||||
restoreGlobal('HTMLCanvasElement', originalHTMLCanvasElement)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -100,6 +100,7 @@ export async function stageAttachment(
|
||||
try {
|
||||
const compressed = await compressImageIfNeeded(file)
|
||||
const dataUrl = await readAsDataUrl(compressed)
|
||||
const encodedMediaType = compressed.type || mediaType
|
||||
// Rough byte ceiling — `data:image/png;base64,...` doubles size with
|
||||
// base64. Reject early so we never POST something the route will 400.
|
||||
if (dataUrl.length > MAX_IMAGE_BYTES * 2) {
|
||||
@@ -118,12 +119,12 @@ export async function stageAttachment(
|
||||
attachment: {
|
||||
id: makeId(),
|
||||
kind: 'image',
|
||||
mediaType,
|
||||
mediaType: encodedMediaType,
|
||||
name: file.name || 'image',
|
||||
dataUrl,
|
||||
payload: {
|
||||
kind: 'image',
|
||||
mediaType,
|
||||
mediaType: encodedMediaType,
|
||||
dataUrl,
|
||||
name: file.name || undefined,
|
||||
},
|
||||
|
||||
@@ -2,29 +2,75 @@ function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
export interface ParsedSSEEvent<T> {
|
||||
data: T
|
||||
/** Numeric `id:` line on the same SSE event, if any. */
|
||||
seq?: number
|
||||
}
|
||||
|
||||
export function parseSSELines<T>(buffer: string): {
|
||||
events: T[]
|
||||
events: ParsedSSEEvent<T>[]
|
||||
remainder: string
|
||||
} {
|
||||
// SSE events are separated by blank lines. Buffer lines until we hit
|
||||
// a blank, then assemble each event. Lines we recognise: `id: <n>`
|
||||
// and `data: <payload>`. Everything else is ignored.
|
||||
const events: ParsedSSEEvent<T>[] = []
|
||||
const lines = buffer.split('\n')
|
||||
const remainder = lines.pop() ?? ''
|
||||
const events: T[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const payload = line.slice(6)
|
||||
if (payload === '[DONE]') continue
|
||||
try {
|
||||
events.push(JSON.parse(payload) as T)
|
||||
} catch {}
|
||||
// Find the last blank-line boundary; everything after it is the
|
||||
// remainder (next event partially received).
|
||||
let lastBoundary = -1
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i] === '') {
|
||||
lastBoundary = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const completeLines = lastBoundary >= 0 ? lines.slice(0, lastBoundary) : []
|
||||
const remainder =
|
||||
lastBoundary >= 0 ? lines.slice(lastBoundary + 1).join('\n') : buffer
|
||||
|
||||
let currentSeq: number | undefined
|
||||
let currentData: string | null = null
|
||||
const flush = () => {
|
||||
if (currentData != null && currentData !== '[DONE]') {
|
||||
try {
|
||||
events.push({
|
||||
data: JSON.parse(currentData) as T,
|
||||
seq: currentSeq,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
currentSeq = undefined
|
||||
currentData = null
|
||||
}
|
||||
|
||||
for (const line of completeLines) {
|
||||
if (line === '') {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('id: ')) {
|
||||
const n = Number.parseInt(line.slice(4).trim(), 10)
|
||||
if (Number.isFinite(n)) currentSeq = n
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('data: ')) {
|
||||
currentData = line.slice(6)
|
||||
}
|
||||
}
|
||||
// Catch a complete trailing event with no terminating blank line —
|
||||
// shouldn't happen in well-formed SSE, but be tolerant.
|
||||
flush()
|
||||
|
||||
return { events, remainder }
|
||||
}
|
||||
|
||||
export async function consumeSSEStream<T>(
|
||||
response: Response,
|
||||
onEvent: (event: T) => void,
|
||||
onEvent: (event: T, meta: { seq?: number }) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = response.body?.getReader()
|
||||
@@ -49,7 +95,7 @@ export async function consumeSSEStream<T>(
|
||||
buffer = remainder
|
||||
|
||||
for (const event of events) {
|
||||
onEvent(event)
|
||||
onEvent(event.data, { seq: event.seq })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -64,7 +110,7 @@ export async function consumeSSEStream<T>(
|
||||
if (buffer) {
|
||||
const { events } = parseSSELines<T>(buffer)
|
||||
for (const event of events) {
|
||||
onEvent(event)
|
||||
onEvent(event.data, { seq: event.seq })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user