mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-18 02:57:47 +00:00
Compare commits
95 Commits
feat/agent
...
fix/patch-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e83d3a670 | ||
|
|
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 | ||
|
|
91d3285aa0 | ||
|
|
7bb6dac949 | ||
|
|
d9c254053e | ||
|
|
6b9945f933 | ||
|
|
6a5a7775a9 | ||
|
|
af48a2110c | ||
|
|
c5ff8d75bc | ||
|
|
445a6a6c45 | ||
|
|
72d39b9a0f | ||
|
|
3b47f330f5 | ||
|
|
15a82ff9cb | ||
|
|
427549f081 | ||
|
|
a11f9caa64 | ||
|
|
da1397900b | ||
|
|
368c7dcfe8 | ||
|
|
599f8b6b9c | ||
|
|
27834b1d31 | ||
|
|
aa30eb3aaa | ||
|
|
e045e34b73 | ||
|
|
01d649da9a | ||
|
|
ddbb2cf492 | ||
|
|
711934555d | ||
|
|
5125dffbf3 | ||
|
|
0035893f33 | ||
|
|
4284e88625 | ||
|
|
0b91c735ab | ||
|
|
d189b50b03 | ||
|
|
a407e48209 | ||
|
|
1f75b91fba | ||
|
|
752f42d1fe | ||
|
|
2f8e36546f | ||
|
|
461dcd29e8 | ||
|
|
c6c902a4ab | ||
|
|
6e37742a5a | ||
|
|
1186c2c0d7 | ||
|
|
0288cc040d | ||
|
|
07b7bf5977 | ||
|
|
d1a3d67e29 | ||
|
|
35134518f0 | ||
|
|
4083155e81 | ||
|
|
72ef4f068e |
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"
|
|
||||||
80
.github/workflows/eval-weekly.yml
vendored
80
.github/workflows/eval-weekly.yml
vendored
@@ -14,7 +14,7 @@ on:
|
|||||||
config:
|
config:
|
||||||
description: 'Eval config file (relative to apps/eval/)'
|
description: 'Eval config file (relative to apps/eval/)'
|
||||||
required: false
|
required: false
|
||||||
default: 'configs/browseros-agent-weekly.json'
|
default: 'configs/legacy/browseros-agent-weekly.json'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -30,8 +30,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Install BrowserOS
|
- name: Install BrowserOS
|
||||||
run: |
|
run: |
|
||||||
wget -q https://github.com/browseros-ai/BrowserOS/releases/download/v0.44.0.1/BrowserOS_v0.44.0.1_amd64.deb
|
# Rolling stable channel — see https://cdn.browseros.com/download/BrowserOS.deb
|
||||||
sudo dpkg -i BrowserOS_v0.44.0.1_amd64.deb
|
wget -q -O BrowserOS.deb https://cdn.browseros.com/download/BrowserOS.deb
|
||||||
|
sudo dpkg -i BrowserOS.deb
|
||||||
browseros --version || echo "BrowserOS installed at $(which browseros)"
|
browseros --version || echo "BrowserOS installed at $(which browseros)"
|
||||||
|
|
||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
@@ -41,7 +42,28 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: packages/browseros-agent
|
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
|
||||||
|
# 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
|
||||||
|
|
||||||
- name: Install xvfb
|
- name: Install xvfb
|
||||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||||
@@ -53,19 +75,44 @@ jobs:
|
|||||||
curl -sL -o /tmp/nopecha.zip https://github.com/NopeCHALLC/nopecha-extension/releases/latest/download/chromium_automation.zip
|
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
|
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
|
working-directory: packages/browseros-agent/apps/eval
|
||||||
env:
|
env:
|
||||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
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 }}
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
|
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
|
||||||
BROWSEROS_BINARY: /usr/bin/browseros
|
BROWSEROS_BINARY: /usr/bin/browseros
|
||||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
WEBARENA_INFINITY_DIR: /tmp/webarena-infinity
|
||||||
|
# OpenClaw container runtime is macOS-only; opt the Linux runner
|
||||||
|
# into the no-op stub so the server can boot and the eval can run.
|
||||||
|
BROWSEROS_SKIP_OPENCLAW: '1'
|
||||||
|
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/legacy/browseros-agent-weekly.json' }}
|
||||||
run: |
|
run: |
|
||||||
echo "Running eval with config: $EVAL_CONFIG"
|
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()
|
if: success()
|
||||||
working-directory: packages/browseros-agent/apps/eval
|
working-directory: packages/browseros-agent/apps/eval
|
||||||
env:
|
env:
|
||||||
@@ -74,13 +121,12 @@ jobs:
|
|||||||
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
||||||
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
||||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
run: bun run src/index.ts publish --run "$EVAL_RUN_DIR" --target r2
|
||||||
run: |
|
|
||||||
CONFIG_NAME=$(basename "$EVAL_CONFIG" .json)
|
|
||||||
bun scripts/upload-run.ts "results/$CONFIG_NAME"
|
|
||||||
|
|
||||||
- name: Generate trend report
|
- name: Generate trend report
|
||||||
if: success()
|
if: success()
|
||||||
|
timeout-minutes: 5
|
||||||
|
continue-on-error: true
|
||||||
working-directory: packages/browseros-agent
|
working-directory: packages/browseros-agent
|
||||||
env:
|
env:
|
||||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||||
@@ -90,9 +136,17 @@ jobs:
|
|||||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||||
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
|
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()
|
if: success()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: eval-report-${{ github.run_id }}
|
name: eval-report-${{ github.run_id }}
|
||||||
path: /tmp/eval-report.html
|
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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release-agent-sdk
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
disabled:
|
||||||
if: github.ref == 'refs/heads/main'
|
if: ${{ false }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: packages/browseros-agent/packages/agent-sdk
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- run: echo "Agent SDK publishing is disabled."
|
||||||
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 }}
|
|
||||||
|
|||||||
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)
|
command: (cd apps/server && bun run test:integration)
|
||||||
junit_path: test-results/server-integration.xml
|
junit_path: test-results/server-integration.xml
|
||||||
needs_browser: true
|
needs_browser: true
|
||||||
- suite: server-sdk
|
- suite: server-lib
|
||||||
command: (cd apps/server && bun run test:sdk)
|
command: (cd apps/server && bun run test:lib)
|
||||||
junit_path: test-results/server-sdk.xml
|
junit_path: test-results/server-lib.xml
|
||||||
needs_browser: true
|
needs_browser: false
|
||||||
- suite: server-root
|
- suite: server-root
|
||||||
command: (cd apps/server && bun run test:root)
|
command: (cd apps/server && bun run test:root)
|
||||||
junit_path: test-results/server-root.xml
|
junit_path: test-results/server-root.xml
|
||||||
needs_browser: false
|
needs_browser: false
|
||||||
- suite: agent
|
- suite: agent
|
||||||
command: bun run test:agent
|
command: (cd apps/agent && bun run test)
|
||||||
junit_path: test-results/agent.xml
|
junit_path: test-results/agent.xml
|
||||||
needs_browser: false
|
needs_browser: false
|
||||||
- suite: eval
|
- suite: eval
|
||||||
command: bun run test:eval
|
command: (cd apps/eval && bun run test)
|
||||||
junit_path: test-results/eval.xml
|
junit_path: test-results/eval.xml
|
||||||
needs_browser: false
|
needs_browser: false
|
||||||
- suite: agent-sdk
|
|
||||||
command: bun run test:agent-sdk
|
|
||||||
junit_path: test-results/agent-sdk.xml
|
|
||||||
needs_browser: false
|
|
||||||
- suite: build
|
- suite: build
|
||||||
command: bun run test:build
|
command: bun run ./scripts/run-bun-test.ts ./scripts/build
|
||||||
junit_path: test-results/build.xml
|
junit_path: test-results/build.xml
|
||||||
needs_browser: false
|
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!
|
- [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.
|
- [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
|
## License
|
||||||
|
|
||||||
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
|
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
|
||||||
|
|||||||
1
packages/browseros-agent/.gitignore
vendored
1
packages/browseros-agent/.gitignore
vendored
@@ -180,6 +180,7 @@ packages/*/dist
|
|||||||
browseros-server
|
browseros-server
|
||||||
browseros-server.exe
|
browseros-server.exe
|
||||||
browseros-server-*
|
browseros-server-*
|
||||||
|
tools/dogfood/browseros-dogfood
|
||||||
tools/dev/browseros-dev
|
tools/dev/browseros-dev
|
||||||
|
|
||||||
log.txt
|
log.txt
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BrowserOS Agent
|
# 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
|
## Monorepo Structure
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ apps/
|
|||||||
eval/ # Evaluation framework for benchmarking agents
|
eval/ # Evaluation framework for benchmarking agents
|
||||||
|
|
||||||
packages/
|
packages/
|
||||||
agent-sdk/ # Node.js SDK (@browseros-ai/agent-sdk)
|
|
||||||
cdp-protocol/ # Type-safe Chrome DevTools Protocol bindings
|
cdp-protocol/ # Type-safe Chrome DevTools Protocol bindings
|
||||||
shared/ # Shared constants (ports, timeouts, limits)
|
shared/ # Shared constants (ports, timeouts, limits)
|
||||||
```
|
```
|
||||||
@@ -23,7 +22,6 @@ packages/
|
|||||||
| `apps/agent` | Agent UI — Chrome extension for the chat interface |
|
| `apps/agent` | Agent UI — Chrome extension for the chat interface |
|
||||||
| `apps/cli` | Go CLI — control BrowserOS from the terminal or AI coding agents |
|
| `apps/cli` | Go CLI — control BrowserOS from the terminal or AI coding agents |
|
||||||
| `apps/eval` | Benchmark framework — WebVoyager, Mind2Web evaluation |
|
| `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/cdp-protocol` | Auto-generated CDP type bindings used by the server |
|
||||||
| `packages/shared` | Shared constants used across packages |
|
| `packages/shared` | Shared constants used across packages |
|
||||||
|
|
||||||
@@ -75,26 +73,21 @@ packages/
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
Requires [process-compose](https://github.com/F1bonacc1/process-compose):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install process-compose
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Copy environment files for each package
|
# Copy environment files for each package
|
||||||
cp apps/server/.env.example apps/server/.env.development
|
cp apps/server/.env.example apps/server/.env.development
|
||||||
cp apps/agent/.env.example apps/agent/.env.development
|
cp apps/agent/.env.example apps/agent/.env.development
|
||||||
cp apps/server/.env.production.example apps/server/.env.production
|
cp apps/server/.env.production.example apps/server/.env.production
|
||||||
|
|
||||||
|
# Install deps and generate agent code
|
||||||
|
bun run dev:setup
|
||||||
|
|
||||||
# Start the full dev environment
|
# Start the full dev environment
|
||||||
process-compose up
|
bun run dev:watch
|
||||||
```
|
```
|
||||||
|
|
||||||
The `process-compose up` command runs the following in order:
|
`dev:watch` starts the server immediately. OpenClaw VM/image prewarm runs from
|
||||||
1. `bun install` — installs dependencies
|
the server startup path and pulls the configured GHCR image on demand.
|
||||||
2. `bun --cwd apps/agent codegen` — generates agent code
|
|
||||||
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
@@ -164,9 +157,14 @@ bun run build:server # Build production server resource artifacts and u
|
|||||||
bun run build:agent # Build agent extension
|
bun run build:agent # Build agent extension
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
bun run test # Run standard tests
|
bun run test # Run all tests
|
||||||
bun run test:cdp # Run CDP-based tests
|
bun run test:all # Run all tests
|
||||||
bun run test:integration # Run integration 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
|
# Quality
|
||||||
bun run lint # Check with Biome
|
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 type { FC, PropsWithChildren } from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
getProviderSearchValue,
|
||||||
|
getProviderSubtitle,
|
||||||
|
groupProviderOptions,
|
||||||
|
} from './ChatProviderSelector.helpers'
|
||||||
import type { Provider } from './chatComponentTypes'
|
import type { Provider } from './chatComponentTypes'
|
||||||
|
|
||||||
interface ChatProviderSelectorProps {
|
interface ChatProviderSelectorProps {
|
||||||
@@ -29,54 +34,58 @@ export const ChatProviderSelector: FC<
|
|||||||
PropsWithChildren<ChatProviderSelectorProps>
|
PropsWithChildren<ChatProviderSelectorProps>
|
||||||
> = ({ children, providers, selectedProvider, onSelectProvider }) => {
|
> = ({ children, providers, selectedProvider, onSelectProvider }) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const groups = groupProviderOptions(providers)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
<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>
|
<Command>
|
||||||
<CommandInput placeholder="Search providers..." className="h-9" />
|
<CommandInput
|
||||||
|
placeholder="Search providers or agents..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
<CommandList>
|
<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>
|
<CommandEmpty>No provider found</CommandEmpty>
|
||||||
<CommandGroup>
|
{groups.map((group) => (
|
||||||
{providers.map((provider) => {
|
<CommandGroup key={group.key} heading={group.label}>
|
||||||
const isSelected = selectedProvider.id === provider.id
|
{group.options.map((provider) => {
|
||||||
return (
|
const isSelected = selectedProvider.id === provider.id
|
||||||
<CommandItem
|
const subtitle = getProviderSubtitle(provider)
|
||||||
key={provider.id}
|
return (
|
||||||
value={`${provider.id} ${provider.name}`}
|
<CommandItem
|
||||||
onSelect={() => {
|
key={provider.id}
|
||||||
onSelectProvider(provider)
|
value={getProviderSearchValue(provider, group.label)}
|
||||||
setOpen(false)
|
onSelect={() => {
|
||||||
}}
|
onSelectProvider(provider)
|
||||||
className={cn(
|
setOpen(false)
|
||||||
'flex w-full items-center gap-3 rounded-md p-2 transition-colors',
|
}}
|
||||||
isSelected && 'bg-[var(--accent-orange)]/10',
|
className={cn(
|
||||||
)}
|
'flex w-full items-center gap-3 rounded-md p-2 transition-colors',
|
||||||
>
|
isSelected && 'bg-[var(--accent-orange)]/10',
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{provider.type === 'browseros' ? (
|
|
||||||
<BrowserOSIcon size={18} />
|
|
||||||
) : (
|
|
||||||
<ProviderIcon
|
|
||||||
type={provider.type as ProviderType}
|
|
||||||
size={18}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
>
|
||||||
<span className="flex-1 text-left text-sm">
|
<span className="text-muted-foreground">
|
||||||
{provider.name}
|
<ProviderOptionIcon provider={provider} />
|
||||||
</span>
|
</span>
|
||||||
{isSelected && (
|
<span className="min-w-0 flex-1 text-left">
|
||||||
<Check className="h-3.5 w-3.5 text-[var(--accent-orange)]" />
|
<span className="block truncate text-sm">
|
||||||
)}
|
{provider.name}
|
||||||
</CommandItem>
|
</span>
|
||||||
)
|
{subtitle && (
|
||||||
})}
|
<span className="block truncate text-muted-foreground text-xs">
|
||||||
</CommandGroup>
|
{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">
|
<div className="border-border border-t p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -96,3 +105,9 @@ export const ChatProviderSelector: FC<
|
|||||||
</Popover>
|
</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'
|
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||||
|
|
||||||
|
export type ChatProviderType = ProviderType | 'acp'
|
||||||
|
|
||||||
export interface Provider {
|
export interface Provider {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: ProviderType
|
type: ChatProviderType
|
||||||
|
kind: 'llm' | 'acp'
|
||||||
|
agentId?: string
|
||||||
|
adapterName?: string
|
||||||
|
modelLabel?: string
|
||||||
|
modelControl?: 'runtime-supported' | 'best-effort'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ const primaryNavItems: NavItem[] = [
|
|||||||
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function isNavItemActive(item: NavItem, pathname: string): boolean {
|
||||||
|
if (item.to === '/settings/ai') {
|
||||||
|
return pathname.startsWith('/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.to === '/agents') {
|
||||||
|
return pathname === '/agents' || pathname.startsWith('/agents/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname === item.to
|
||||||
|
}
|
||||||
|
|
||||||
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||||
expanded = true,
|
expanded = true,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -90,10 +102,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
|||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
const isActive =
|
const isActive = isNavItemActive(item, location.pathname)
|
||||||
item.to === '/settings/ai'
|
|
||||||
? location.pathname.startsWith('/settings')
|
|
||||||
: location.pathname === item.to
|
|
||||||
|
|
||||||
const navItem = (
|
const navItem = (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
@@ -113,7 +113,22 @@ export const App: FC = () => {
|
|||||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||||
{alphaEnabled ? (
|
{alphaEnabled ? (
|
||||||
<Route path="agents" element={<AgentsPage />} />
|
<>
|
||||||
|
<Route path="agents" element={<AgentsPage />} />
|
||||||
|
<Route element={<AgentCommandLayout />}>
|
||||||
|
<Route
|
||||||
|
path="agents/:agentId"
|
||||||
|
element={
|
||||||
|
<AgentCommandConversation
|
||||||
|
variant="page"
|
||||||
|
backPath="/agents"
|
||||||
|
agentPathPrefix="/agents"
|
||||||
|
createAgentPath="/agents"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{alphaEnabled ? (
|
{alphaEnabled ? (
|
||||||
<Route path="admin" element={<AdminDashboardPage />} />
|
<Route path="admin" element={<AdminDashboardPage />} />
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Bot } from 'lucide-react'
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface AgentCardProps {
|
|
||||||
agent: AgentCardData
|
|
||||||
onClick: () => void
|
|
||||||
active?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(timestamp?: number): string {
|
|
||||||
if (!timestamp) return 'No activity yet'
|
|
||||||
const diff = Date.now() - timestamp
|
|
||||||
const minutes = Math.floor(diff / 60000)
|
|
||||||
if (minutes < 1) return 'just now'
|
|
||||||
if (minutes < 60) return `${minutes}m ago`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
if (hours < 24) return `${hours}h ago`
|
|
||||||
return `${Math.floor(hours / 24)}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(status: AgentCardData['status']): string {
|
|
||||||
if (status === 'working') return 'Working'
|
|
||||||
if (status === 'error') return 'Error'
|
|
||||||
return 'Ready'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusTone(status: AgentCardData['status']): string {
|
|
||||||
if (status === 'working') return 'bg-amber-500'
|
|
||||||
if (status === 'error') return 'bg-destructive'
|
|
||||||
return 'bg-emerald-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentCardExpanded: FC<AgentCardProps> = ({
|
|
||||||
agent,
|
|
||||||
onClick,
|
|
||||||
active,
|
|
||||||
}) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'group flex min-h-32 w-full min-w-0 flex-col rounded-2xl border p-4 text-left shadow-sm transition-all duration-200',
|
|
||||||
active
|
|
||||||
? 'border-border/80 bg-card shadow-md ring-1 ring-[var(--accent-orange)]/20'
|
|
||||||
: 'border-border/60 bg-card/85 hover:border-border hover:bg-card hover:shadow-md',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex size-10 shrink-0 items-center justify-center rounded-xl',
|
|
||||||
active
|
|
||||||
? 'bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]'
|
|
||||||
: 'bg-muted text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Bot className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-semibold text-sm">{agent.name}</div>
|
|
||||||
<div className="truncate text-muted-foreground text-xs">
|
|
||||||
{agent.model ?? 'OpenClaw agent'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-[11px] text-muted-foreground">
|
|
||||||
<span
|
|
||||||
className={cn('size-2 rounded-full', getStatusTone(agent.status))}
|
|
||||||
/>
|
|
||||||
<span>{getStatusLabel(agent.status)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex-1">
|
|
||||||
<p className="line-clamp-2 text-foreground/90 text-sm">
|
|
||||||
{agent.lastMessage ??
|
|
||||||
'Start a conversation to see recent work and summaries.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between gap-3 text-muted-foreground text-xs">
|
|
||||||
<span>{formatTimestamp(agent.lastMessageTimestamp)}</span>
|
|
||||||
<span>Open conversation</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const AgentCardCompact: FC<AgentCardProps> = ({
|
|
||||||
agent,
|
|
||||||
onClick,
|
|
||||||
active,
|
|
||||||
}) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors',
|
|
||||||
active
|
|
||||||
? 'border-border bg-card shadow-sm ring-1 ring-[var(--accent-orange)]/20'
|
|
||||||
: 'border-border/60 bg-card/85 text-foreground hover:border-border hover:bg-card',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'size-2 rounded-full',
|
|
||||||
active ? 'bg-[var(--accent-orange)]' : getStatusTone(agent.status),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{agent.name}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
@@ -1,70 +1,71 @@
|
|||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import type { FC } from '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 { cn } from '@/lib/utils'
|
||||||
import { AgentCardCompact, AgentCardExpanded } from './AgentCard'
|
import { HomeAgentCard } from './HomeAgentCard'
|
||||||
|
|
||||||
interface AgentCardDockProps {
|
interface AgentCardDockProps {
|
||||||
agents: AgentCardData[]
|
agents: HarnessAgent[]
|
||||||
|
adapters: HarnessAdapterDescriptor[]
|
||||||
activeAgentId?: string
|
activeAgentId?: string
|
||||||
onSelectAgent: (agentId: string) => void
|
onSelectAgent: (agentId: string) => void
|
||||||
onCreateAgent?: () => void
|
onCreateAgent?: () => void
|
||||||
compact?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateAgentButton({
|
function CreateAgentButton({ onCreateAgent }: { onCreateAgent: () => void }) {
|
||||||
compact,
|
|
||||||
onCreateAgent,
|
|
||||||
}: {
|
|
||||||
compact?: boolean
|
|
||||||
onCreateAgent: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreateAgent}
|
onClick={onCreateAgent}
|
||||||
className={cn(
|
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)]',
|
'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',
|
||||||
compact
|
'hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
|
||||||
? 'rounded-full px-3 py-2 text-sm'
|
|
||||||
: 'min-h-32 rounded-2xl px-5 py-4',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Plus className={compact ? 'size-3.5' : 'size-5'} />
|
<Plus className="size-5" />
|
||||||
<span>{compact ? 'New' : 'Create agent'}</span>
|
<span>Create agent</span>
|
||||||
</button>
|
</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> = ({
|
export const AgentCardDock: FC<AgentCardDockProps> = ({
|
||||||
agents,
|
agents,
|
||||||
|
adapters,
|
||||||
activeAgentId,
|
activeAgentId,
|
||||||
onSelectAgent,
|
onSelectAgent,
|
||||||
onCreateAgent,
|
onCreateAgent,
|
||||||
compact,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (agents.length === 0 && !onCreateAgent) return null
|
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 (
|
return (
|
||||||
<div
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
className={cn(
|
|
||||||
compact
|
|
||||||
? 'flex items-center gap-2 overflow-x-auto pb-1'
|
|
||||||
: 'grid gap-4 md:grid-cols-3',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<Card
|
<HomeAgentCard
|
||||||
key={agent.agentId}
|
key={agent.id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
active={agent.agentId === activeAgentId}
|
adapter={agent.adapter}
|
||||||
onClick={() => onSelectAgent(agent.agentId)}
|
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
|
||||||
|
active={agent.id === activeAgentId}
|
||||||
|
onClick={() => onSelectAgent(agent.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{onCreateAgent ? (
|
{onCreateAgent ? (
|
||||||
<CreateAgentButton compact={compact} onCreateAgent={onCreateAgent} />
|
<CreateAgentButton onCreateAgent={onCreateAgent} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,194 +1,381 @@
|
|||||||
import { Bot, Home, RotateCcw } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import { type FC, useEffect, useRef } from 'react'
|
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type {
|
||||||
|
HarnessAgent,
|
||||||
|
HarnessAgentAdapter,
|
||||||
|
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||||
|
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||||
|
import {
|
||||||
|
cancelHarnessTurn,
|
||||||
|
useAgentAdapters,
|
||||||
|
useEnqueueHarnessMessage,
|
||||||
|
useHarnessAgents,
|
||||||
|
useRemoveHarnessQueuedMessage,
|
||||||
|
useUpdateHarnessAgent,
|
||||||
|
} from '@/entrypoints/app/agents/useAgents'
|
||||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||||
import { cn } from '@/lib/utils'
|
import { AgentRail } from './AgentRail'
|
||||||
import { useAgentCommandData } from './agent-command-layout'
|
import { useAgentCommandData } from './agent-command-layout'
|
||||||
|
import { ClawChat } from './ClawChat'
|
||||||
|
import { ConversationHeader } from './ConversationHeader'
|
||||||
import { ConversationInput } from './ConversationInput'
|
import { ConversationInput } from './ConversationInput'
|
||||||
import { ConversationMessage } from './ConversationMessage'
|
import {
|
||||||
|
buildChatHistoryFromClawMessages,
|
||||||
|
filterTurnsPersistedInHistory,
|
||||||
|
flattenHistoryPages,
|
||||||
|
} from './claw-chat-types'
|
||||||
|
import { consumePendingInitialMessage } from './pending-initial-message'
|
||||||
|
import { QueuePanel } from './QueuePanel'
|
||||||
import { useAgentConversation } from './useAgentConversation'
|
import { useAgentConversation } from './useAgentConversation'
|
||||||
|
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
||||||
|
|
||||||
function ConversationHeader({
|
function AgentConversationController({
|
||||||
agentName,
|
agentId,
|
||||||
status,
|
initialMessage,
|
||||||
onGoHome,
|
onInitialMessageConsumed,
|
||||||
onReset,
|
agents,
|
||||||
|
agentPathPrefix,
|
||||||
|
createAgentPath,
|
||||||
}: {
|
}: {
|
||||||
agentName: string
|
agentId: string
|
||||||
status: string
|
initialMessage: string | null
|
||||||
onGoHome: () => void
|
onInitialMessageConsumed: () => void
|
||||||
onReset: () => void
|
agents: AgentEntry[]
|
||||||
|
agentPathPrefix: string
|
||||||
|
createAgentPath: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
|
||||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
|
||||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onGoHome}
|
|
||||||
className="rounded-xl"
|
|
||||||
title="Back to home"
|
|
||||||
>
|
|
||||||
<Home className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
|
||||||
<Bot className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-semibold text-sm">{agentName}</div>
|
|
||||||
<div className="truncate text-muted-foreground text-sm">
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onReset}
|
|
||||||
className="rounded-xl text-muted-foreground"
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
New conversation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyConversationState({ agentName }: { agentName: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full items-center justify-center py-10">
|
|
||||||
<div className="max-w-md rounded-[1.5rem] border border-border/60 bg-card/90 px-8 py-10 text-center shadow-sm backdrop-blur">
|
|
||||||
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
|
||||||
<Bot className="size-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-4 font-semibold text-lg">{agentName}</h2>
|
|
||||||
<p className="mt-2 text-muted-foreground text-sm">
|
|
||||||
Send a message to start a focused conversation with this agent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConversationStatusCopy(
|
|
||||||
status: string | undefined,
|
|
||||||
streaming: boolean,
|
|
||||||
): string {
|
|
||||||
if (streaming) return 'Working on your request'
|
|
||||||
if (status === 'running') return 'Ready for the next task'
|
|
||||||
if (status === 'starting') return 'Connecting to OpenClaw'
|
|
||||||
if (status === 'error') return 'OpenClaw needs attention'
|
|
||||||
if (status === 'stopped') return 'OpenClaw is offline'
|
|
||||||
return 'Open agent setup to continue'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentCommandConversation: FC = () => {
|
|
||||||
const { agentId } = useParams<{ agentId: string }>()
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const initialMessageSentRef = useRef<string | null>(null)
|
||||||
const initialQuerySent = useRef(false)
|
const onInitialMessageConsumedRef = useRef(onInitialMessageConsumed)
|
||||||
const { status, agents } = useAgentCommandData()
|
const agent = agents.find((entry) => entry.agentId === agentId)
|
||||||
const shouldRedirectHome = !agentId
|
const agentName = agent?.name || agentId || 'Agent'
|
||||||
const resolvedAgentId = agentId ?? ''
|
// Routing is now harness-only. Every OpenClaw agent has a harness
|
||||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
// record post the gateway → harness backfill, so the chat panel
|
||||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
// always talks to /agents/<id>/chat. The legacy ClawChat surface
|
||||||
const { turns, streaming, loading, send, resetConversation } =
|
// was deleted with the /claw/agents/:id/chat server route.
|
||||||
useAgentConversation(resolvedAgentId, agentName)
|
const harnessHistoryQuery = useHarnessChatHistory(agentId, Boolean(agent))
|
||||||
const lastTurn = turns[turns.length - 1]
|
|
||||||
const lastTurnPartCount = lastTurn?.parts.length ?? 0
|
const historyMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
flattenHistoryPages(
|
||||||
|
harnessHistoryQuery.data ? [harnessHistoryQuery.data] : [],
|
||||||
|
),
|
||||||
|
[harnessHistoryQuery.data],
|
||||||
|
)
|
||||||
|
const chatHistory = useMemo(
|
||||||
|
() => buildChatHistoryFromClawMessages(historyMessages),
|
||||||
|
[historyMessages],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Listing query feeds queue + active-turn state for this agent. We
|
||||||
|
// already poll it every 5s for the rail; reusing the same cache
|
||||||
|
// keeps cross-tab queue state in sync without a second poll.
|
||||||
|
const { harnessAgents } = useHarnessAgents()
|
||||||
|
const harnessAgent = harnessAgents.find((entry) => entry.id === agentId)
|
||||||
|
const queue = harnessAgent?.queue ?? []
|
||||||
|
const activeTurnId = harnessAgent?.activeTurnId ?? null
|
||||||
|
|
||||||
|
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||||
|
runtime: 'agent-harness',
|
||||||
|
sessionKey: null,
|
||||||
|
history: chatHistory,
|
||||||
|
activeTurnId,
|
||||||
|
onComplete: () => {
|
||||||
|
void harnessHistoryQuery.refetch()
|
||||||
|
},
|
||||||
|
onSessionKeyChange: () => {},
|
||||||
|
})
|
||||||
|
const enqueueMessage = useEnqueueHarnessMessage()
|
||||||
|
const removeQueuedMessage = useRemoveHarnessQueuedMessage()
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
void cancelHarnessTurn(agentId, {
|
||||||
|
turnId: activeTurnId ?? undefined,
|
||||||
|
reason: 'user pressed stop',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const visibleTurns = useMemo(
|
||||||
|
() => filterTurnsPersistedInHistory(turns, historyMessages),
|
||||||
|
[historyMessages, turns],
|
||||||
|
)
|
||||||
|
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
||||||
|
|
||||||
|
const disabled = !agent
|
||||||
|
const historyReady =
|
||||||
|
harnessHistoryQuery.isFetched || harnessHistoryQuery.isError
|
||||||
|
const initialMessageKey = initialMessage
|
||||||
|
? `${agentId}:${initialMessage}`
|
||||||
|
: null
|
||||||
|
const error = harnessHistoryQuery.error ?? null
|
||||||
|
|
||||||
|
const sendRef = useRef(send)
|
||||||
|
sendRef.current = send
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldRedirectHome) return
|
if (disabled || !historyReady) return
|
||||||
|
|
||||||
const query = searchParams.get('q')
|
// Registry-first: when the user submitted at /home with
|
||||||
if (query && !initialQuerySent.current && !loading) {
|
// attachments, the rich payload is here. URL `?q=` may also be
|
||||||
initialQuerySent.current = true
|
// present and is the text-only fallback path; the registry wins
|
||||||
setSearchParams({}, { replace: true })
|
// when both exist because it carries the binary attachments
|
||||||
void send(query)
|
// alongside the text.
|
||||||
}
|
const pending = consumePendingInitialMessage(agentId)
|
||||||
}, [loading, searchParams, send, setSearchParams, shouldRedirectHome])
|
if (pending) {
|
||||||
|
// Mark the dedup ref so the text-only branch below doesn't
|
||||||
useEffect(() => {
|
// re-fire on the same render.
|
||||||
if (
|
if (initialMessageKey) {
|
||||||
shouldRedirectHome ||
|
initialMessageSentRef.current = initialMessageKey
|
||||||
(turns.length === 0 && lastTurnPartCount === 0 && !streaming)
|
}
|
||||||
) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollRef.current?.scrollTo({
|
const query = initialMessage?.trim()
|
||||||
top: scrollRef.current.scrollHeight,
|
if (!initialMessageKey) {
|
||||||
behavior: 'smooth',
|
// Reset is safe even on the post-registry-fire re-run: consume
|
||||||
})
|
// is destructive, so the registry is already drained — there's
|
||||||
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
|
// nothing left for a third run to re-send.
|
||||||
|
initialMessageSentRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldRedirectHome) {
|
if (!query || initialMessageSentRef.current === initialMessageKey) {
|
||||||
return <Navigate to="/home" replace />
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initialMessageSentRef.current = initialMessageKey
|
||||||
|
onInitialMessageConsumedRef.current()
|
||||||
|
void sendRef.current({ text: query })
|
||||||
|
}, [agentId, disabled, historyReady, initialMessage, initialMessageKey])
|
||||||
|
|
||||||
const handleSelectAgent = (entry: AgentEntry) => {
|
const handleSelectAgent = (entry: AgentEntry) => {
|
||||||
navigate(`/home/agents/${entry.agentId}`)
|
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusCopy = getConversationStatusCopy(status?.status, streaming)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
|
<ClawChat
|
||||||
<ConversationHeader
|
agentName={agentName}
|
||||||
agentName={agentName}
|
historyMessages={historyMessages}
|
||||||
status={statusCopy}
|
turns={visibleTurns}
|
||||||
onGoHome={() => navigate('/home')}
|
streaming={streaming}
|
||||||
onReset={resetConversation}
|
isInitialLoading={harnessHistoryQuery.isLoading}
|
||||||
/>
|
error={error}
|
||||||
|
hasNextPage={false}
|
||||||
|
isFetchingNextPage={false}
|
||||||
|
onFetchNextPage={() => {}}
|
||||||
|
onRetry={() => {
|
||||||
|
void harnessHistoryQuery.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<main
|
<div className="border-border/50 border-t bg-background/88 px-4 py-3 backdrop-blur-md">
|
||||||
ref={scrollRef}
|
<div className="mx-auto max-w-3xl space-y-3">
|
||||||
className={cn(
|
{queue.length > 0 ? (
|
||||||
'styled-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-[1.5rem] border border-border/50 bg-card/85 px-5 py-5 shadow-sm',
|
<QueuePanel
|
||||||
'[&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
queue={queue}
|
||||||
)}
|
onRemove={(messageId) =>
|
||||||
>
|
removeQueuedMessage.mutate({ agentId, messageId })
|
||||||
{loading ? (
|
}
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
/>
|
||||||
Loading conversation...
|
) : null}
|
||||||
</div>
|
|
||||||
) : turns.length === 0 ? (
|
|
||||||
<EmptyConversationState agentName={agentName} />
|
|
||||||
) : (
|
|
||||||
<div className="w-full space-y-4">
|
|
||||||
{turns.map((turn, index) => (
|
|
||||||
<ConversationMessage
|
|
||||||
key={turn.id}
|
|
||||||
turn={turn}
|
|
||||||
streaming={streaming && index === turns.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div className="w-full flex-shrink-0">
|
|
||||||
<ConversationInput
|
<ConversationInput
|
||||||
variant="conversation"
|
variant="conversation"
|
||||||
agents={agents}
|
agents={agents}
|
||||||
selectedAgentId={resolvedAgentId}
|
selectedAgentId={agentId}
|
||||||
onSelectAgent={handleSelectAgent}
|
onSelectAgent={handleSelectAgent}
|
||||||
onSend={(text) => {
|
onSend={(input) => {
|
||||||
void send(text)
|
const attachments = input.attachments.map((a) => a.payload)
|
||||||
|
const attachmentPreviews = input.attachments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
kind: a.kind,
|
||||||
|
mediaType: a.mediaType,
|
||||||
|
name: a.name,
|
||||||
|
dataUrl: a.dataUrl,
|
||||||
|
}))
|
||||||
|
// When the agent already has an in-flight turn, route
|
||||||
|
// the new message into the durable queue instead of
|
||||||
|
// starting a parallel turn. Drains automatically as
|
||||||
|
// soon as the active turn ends.
|
||||||
|
if (streaming || activeTurnId) {
|
||||||
|
enqueueMessage.mutate({
|
||||||
|
agentId,
|
||||||
|
message: input.text,
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void send({ text: input.text, attachments, attachmentPreviews })
|
||||||
}}
|
}}
|
||||||
onCreateAgent={() => navigate('/agents')}
|
onCreateAgent={() => navigate(createAgentPath)}
|
||||||
|
onStop={handleStop}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
disabled={status?.status !== 'running'}
|
disabled={disabled}
|
||||||
status={status?.status}
|
status="running"
|
||||||
placeholder={`Message ${agentName}...`}
|
attachmentsEnabled={true}
|
||||||
|
placeholder={
|
||||||
|
streaming
|
||||||
|
? `Type to queue another message for ${agentName}...`
|
||||||
|
: `Message ${agentName}...`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentCommandConversationProps {
|
||||||
|
variant?: 'command' | 'page'
|
||||||
|
backPath?: string
|
||||||
|
agentPathPrefix?: string
|
||||||
|
createAgentPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferAdapterFromEntry(
|
||||||
|
entry: AgentEntry | undefined,
|
||||||
|
): HarnessAgentAdapter | 'unknown' {
|
||||||
|
if (!entry) return 'unknown'
|
||||||
|
if (entry.source === 'agent-harness') {
|
||||||
|
// Harness entries don't carry the adapter on AgentEntry; the rail
|
||||||
|
// / header read the harness record directly. This branch only runs
|
||||||
|
// before the harness query resolves, so 'unknown' is correct — the
|
||||||
|
// tile's bot fallback renders until data arrives.
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
// OpenClaw-only entries (no harness shadow) are deprecated in
|
||||||
|
// practice but the rail still tolerates them.
|
||||||
|
return 'openclaw'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||||
|
variant = 'command',
|
||||||
|
backPath = '/home',
|
||||||
|
agentPathPrefix = '/home/agents',
|
||||||
|
createAgentPath = '/agents',
|
||||||
|
}) => {
|
||||||
|
const { agentId } = useParams<{ agentId: string }>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { agents } = useAgentCommandData()
|
||||||
|
const { harnessAgents } = useHarnessAgents()
|
||||||
|
const { adapters } = useAgentAdapters()
|
||||||
|
const updateAgent = useUpdateHarnessAgent()
|
||||||
|
|
||||||
|
const shouldRedirectHome = !agentId
|
||||||
|
const resolvedAgentId = agentId ?? ''
|
||||||
|
const harnessAgent = harnessAgents.find(
|
||||||
|
(entry) => entry.id === resolvedAgentId,
|
||||||
|
)
|
||||||
|
const entry = agents.find((item) => item.agentId === resolvedAgentId)
|
||||||
|
const fallbackName = entry?.name || resolvedAgentId || 'Agent'
|
||||||
|
const fallbackAdapter = inferAdapterFromEntry(entry)
|
||||||
|
const initialMessage = searchParams.get('q')
|
||||||
|
const isPageVariant = variant === 'page'
|
||||||
|
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||||
|
|
||||||
|
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
|
||||||
|
const adapterId = harnessAgent?.adapter
|
||||||
|
if (!adapterId) return null
|
||||||
|
const descriptor = adapters.find((item) => item.id === adapterId)
|
||||||
|
if (!descriptor?.health) return null
|
||||||
|
return {
|
||||||
|
healthy: descriptor.health.healthy,
|
||||||
|
reason: descriptor.health.reason,
|
||||||
|
}
|
||||||
|
}, [adapters, harnessAgent?.adapter])
|
||||||
|
|
||||||
|
if (shouldRedirectHome) {
|
||||||
|
return <Navigate to="/home" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectHarnessAgent = (target: HarnessAgent) => {
|
||||||
|
navigate(`${agentPathPrefix}/${target.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
|
||||||
|
if (!target) return
|
||||||
|
updateAgent.mutate({
|
||||||
|
agentId: target.id,
|
||||||
|
patch: { pinned: next },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
||||||
|
<div className="mx-auto flex h-full w-full max-w-[1480px] flex-col">
|
||||||
|
{/* Shared top band — the rail's "Agents" header and the chat
|
||||||
|
header live on one row so they're aligned by construction. */}
|
||||||
|
<div className="flex shrink-0 items-stretch border-border/50 border-b">
|
||||||
|
<div className="hidden min-h-[60px] w-[288px] shrink-0 items-center gap-3 border-border/50 border-r px-4 lg:flex">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(backPath)}
|
||||||
|
className="size-8 rounded-xl"
|
||||||
|
title="Back to home"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="truncate font-semibold text-[15px] leading-5">
|
||||||
|
Agents
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<ConversationHeader
|
||||||
|
agent={harnessAgent ?? null}
|
||||||
|
fallbackName={fallbackName}
|
||||||
|
fallbackAdapter={fallbackAdapter}
|
||||||
|
adapterHealth={adapterHealth}
|
||||||
|
backLabel={backLabel}
|
||||||
|
backTarget={isPageVariant ? 'page' : 'home'}
|
||||||
|
onGoHome={() => navigate(backPath)}
|
||||||
|
onPinToggle={(next) =>
|
||||||
|
handlePinToggle(harnessAgent ?? null, next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body grid: rail list + chat. Both columns share the same
|
||||||
|
top edge (the band above) so headers can never drift. */}
|
||||||
|
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] lg:grid-cols-[288px_minmax(0,1fr)]">
|
||||||
|
<AgentRail
|
||||||
|
agents={harnessAgents}
|
||||||
|
adapters={adapters}
|
||||||
|
activeAgentId={resolvedAgentId}
|
||||||
|
onSelectAgent={handleSelectHarnessAgent}
|
||||||
|
onPinToggle={(target, next) => handlePinToggle(target, next)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<AgentConversationController
|
||||||
|
key={resolvedAgentId}
|
||||||
|
agentId={resolvedAgentId}
|
||||||
|
agents={agents}
|
||||||
|
initialMessage={initialMessage}
|
||||||
|
onInitialMessageConsumed={() =>
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
}
|
||||||
|
agentPathPrefix={agentPathPrefix}
|
||||||
|
createAgentPath={createAgentPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,178 +1,210 @@
|
|||||||
import { ArrowRight } from 'lucide-react'
|
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 { useNavigate } from 'react-router'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
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 type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||||
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
||||||
import { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
|
|
||||||
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
|
|
||||||
import { ScheduleResults } from '@/entrypoints/newtab/index/ScheduleResults'
|
|
||||||
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
||||||
import { TopSites } from '@/entrypoints/newtab/index/TopSites'
|
|
||||||
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
||||||
import { AgentCardDock } from './AgentCardDock'
|
import { AgentCardDock } from './AgentCardDock'
|
||||||
import { useAgentCommandData } from './agent-command-layout'
|
import { useAgentCommandData } from './agent-command-layout'
|
||||||
import { ConversationInput } from './ConversationInput'
|
import {
|
||||||
import { useAgentCardData } from './useAgentCardData'
|
ConversationInput,
|
||||||
|
type ConversationInputSendInput,
|
||||||
function AgentCommandSetupState({
|
} from './ConversationInput'
|
||||||
onOpenAgents,
|
import { orderHomeAgents } from './home-agent-card.helpers'
|
||||||
}: {
|
import { setPendingInitialMessage } from './pending-initial-message'
|
||||||
onOpenAgents: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
|
||||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
|
||||||
<p className="max-w-xl text-muted-foreground text-sm">
|
|
||||||
Set up OpenClaw agents to turn your new tab into an agent command
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
<Button onClick={onOpenAgents} className="gap-2">
|
|
||||||
Open Agent Setup
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||||
<p className="max-w-xl text-muted-foreground text-sm">
|
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
OpenClaw is running, but you do not have any agents yet.
|
<Plus className="size-5" />
|
||||||
</p>
|
</div>
|
||||||
<Button variant="outline" onClick={onOpenAgents}>
|
<div className="space-y-2">
|
||||||
Create your first agent
|
<h2 className="font-semibold text-lg">No agents yet</h2>
|
||||||
|
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||||
|
Create an agent to start using BrowserOS as an agent-first new tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={onOpenAgents} className="rounded-xl">
|
||||||
|
Create agent
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpenClawUnavailableState({
|
function RecentThreads({
|
||||||
|
activeAgentId,
|
||||||
|
agents,
|
||||||
|
adapters,
|
||||||
onOpenAgents,
|
onOpenAgents,
|
||||||
|
onSelectAgent,
|
||||||
}: {
|
}: {
|
||||||
|
activeAgentId?: string | null
|
||||||
|
agents: HarnessAgent[]
|
||||||
|
adapters: HarnessAdapterDescriptor[]
|
||||||
onOpenAgents: () => void
|
onOpenAgents: () => void
|
||||||
|
onSelectAgent: (agentId: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
if (agents.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
<section className="space-y-4">
|
||||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<p className="max-w-xl text-muted-foreground text-sm">
|
<div>
|
||||||
OpenClaw is unavailable right now. Open the Agents page to restart the
|
<h2 className="font-semibold text-base">Recent agents</h2>
|
||||||
gateway or review setup.
|
<p className="text-muted-foreground text-sm">
|
||||||
</p>
|
Continue from where you left off.
|
||||||
<Button onClick={onOpenAgents} className="gap-2">
|
</p>
|
||||||
Open Agent Setup
|
</div>
|
||||||
<ArrowRight className="size-4" />
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onOpenAgents}
|
||||||
|
className="rounded-xl"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Manage agents
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<AgentCardDock
|
||||||
|
agents={agents}
|
||||||
|
adapters={adapters}
|
||||||
|
activeAgentId={activeAgentId ?? undefined}
|
||||||
|
onSelectAgent={onSelectAgent}
|
||||||
|
onCreateAgent={onOpenAgents}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgentCommandHome: FC = () => {
|
export const AgentCommandHome: FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeHint = useActiveHint()
|
const activeHint = useActiveHint()
|
||||||
const { status, agents } = useAgentCommandData()
|
// The conversation input still consumes the merged AgentEntry list
|
||||||
const [mounted, setMounted] = useState(false)
|
// 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 [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||||
const cardData = useAgentCardData(agents, status?.status)
|
|
||||||
|
const orderedAgents = useMemo(
|
||||||
|
() => orderHomeAgents(harnessAgents),
|
||||||
|
[harnessAgents],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
if (legacyAgents.length === 0) {
|
||||||
}, [])
|
if (selectedAgentId) setSelectedAgentId(null)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (agents.length === 0) {
|
|
||||||
if (selectedAgentId) {
|
|
||||||
setSelectedAgentId(null)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!selectedAgentId ||
|
!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 = (text: string) => {
|
const handleSend = (input: ConversationInputSendInput) => {
|
||||||
if (!selectedAgentId) return
|
if (!selectedAgentId) return
|
||||||
navigate(`/home/agents/${selectedAgentId}?q=${encodeURIComponent(text)}`)
|
// Stash text + attachments in the in-memory registry. Text also
|
||||||
|
// travels in `?q=` so a hard refresh / shareable URL still works
|
||||||
|
// for text-only prompts; attachments are registry-only because a
|
||||||
|
// multi-megabyte dataUrl can't ride a URL search param. The chat
|
||||||
|
// screen prefers the registry when both are present.
|
||||||
|
setPendingInitialMessage({
|
||||||
|
agentId: selectedAgentId,
|
||||||
|
text: input.text,
|
||||||
|
attachments: input.attachments,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
navigate(
|
||||||
|
`/home/agents/${selectedAgentId}?q=${encodeURIComponent(input.text)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectAgent = (agent: AgentEntry) => {
|
const handleSelectAgent = (agent: AgentEntry) => {
|
||||||
setSelectedAgentId(agent.agentId)
|
setSelectedAgentId(agent.agentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openClawStatus = status?.status
|
const selectedAgent = legacyAgents.find(
|
||||||
const isSetup = openClawStatus != null && openClawStatus !== 'uninitialized'
|
(agent) => agent.agentId === selectedAgentId,
|
||||||
const shouldShowUnavailableState =
|
)
|
||||||
openClawStatus != null &&
|
const selectedAgentReady = selectedAgent
|
||||||
openClawStatus !== 'running' &&
|
? selectedAgent.source === 'agent-harness' || status?.status === 'running'
|
||||||
openClawStatus !== 'uninitialized' &&
|
: false
|
||||||
cardData.length === 0
|
const selectedAgentStatus =
|
||||||
|
selectedAgent?.source === 'agent-harness' ? 'running' : status?.status
|
||||||
|
const selectedAgentName =
|
||||||
|
selectedAgent?.name ?? orderedAgents[0]?.name ?? 'your agent'
|
||||||
|
|
||||||
|
const hasAgents = legacyAgents.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-[max(25vh,16px)]">
|
<div className="min-h-full px-4 py-6">
|
||||||
<div className="relative w-full space-y-8 md:w-3xl">
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
|
||||||
<NewTabBranding />
|
{hasAgents ? (
|
||||||
|
<>
|
||||||
<ConversationInput
|
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||||
variant="home"
|
<div className="space-y-3">
|
||||||
agents={agents}
|
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||||
selectedAgentId={selectedAgentId}
|
What should your agent work on next?
|
||||||
onSelectAgent={handleSelectAgent}
|
</h1>
|
||||||
onSend={handleSend}
|
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||||
onCreateAgent={() => navigate('/agents')}
|
Start with a task, continue a thread, or switch to another
|
||||||
streaming={false}
|
agent without leaving the new tab.
|
||||||
disabled={status?.status !== 'running'}
|
</p>
|
||||||
status={status?.status}
|
|
||||||
placeholder={
|
|
||||||
status?.status === 'running'
|
|
||||||
? undefined
|
|
||||||
: 'OpenClaw is not running...'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{mounted ? <NewTabTip /> : null}
|
|
||||||
|
|
||||||
{isSetup ? (
|
|
||||||
shouldShowUnavailableState ? (
|
|
||||||
<OpenClawUnavailableState
|
|
||||||
onOpenAgents={() => navigate('/agents')}
|
|
||||||
/>
|
|
||||||
) : cardData.length > 0 ? (
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-base">Agents</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Pick up where your agents left off.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<AgentCardDock
|
|
||||||
agents={cardData}
|
|
||||||
activeAgentId={selectedAgentId ?? undefined}
|
|
||||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
|
||||||
onCreateAgent={() => navigate('/agents')}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mounted ? <TopSites /> : null}
|
<div className="w-full max-w-3xl">
|
||||||
{mounted ? <ScheduleResults /> : null}
|
<ConversationInput
|
||||||
|
variant="home"
|
||||||
|
agents={legacyAgents}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onSelectAgent={handleSelectAgent}
|
||||||
|
onSend={handleSend}
|
||||||
|
onCreateAgent={() => navigate('/agents')}
|
||||||
|
streaming={false}
|
||||||
|
disabled={!selectedAgentReady}
|
||||||
|
status={selectedAgentStatus}
|
||||||
|
attachmentsEnabled={true}
|
||||||
|
placeholder={
|
||||||
|
selectedAgentReady
|
||||||
|
? `Ask ${selectedAgentName} to handle a task...`
|
||||||
|
: 'Agent runtime is not running...'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<RecentThreads
|
||||||
|
activeAgentId={selectedAgentId}
|
||||||
|
agents={orderedAgents}
|
||||||
|
adapters={adapters}
|
||||||
|
onOpenAgents={() => navigate('/agents')}
|
||||||
|
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeHint === 'signin' ? <SignInHint /> : null}
|
{activeHint === 'signin' ? <SignInHint /> : null}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Bot, Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { type FC, useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Conversation,
|
||||||
|
ConversationContent,
|
||||||
|
ConversationScrollButton,
|
||||||
|
} from '@/components/ai-elements/conversation'
|
||||||
|
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ClawChatMessage } from './ClawChatMessage'
|
||||||
|
import { ConversationMessage } from './ConversationMessage'
|
||||||
|
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
|
||||||
|
|
||||||
|
interface ClawChatProps {
|
||||||
|
agentName: string
|
||||||
|
historyMessages: ClawChatMessageModel[]
|
||||||
|
turns: AgentConversationTurn[]
|
||||||
|
streaming: boolean
|
||||||
|
isInitialLoading: boolean
|
||||||
|
error: Error | null
|
||||||
|
hasNextPage: boolean
|
||||||
|
isFetchingNextPage: boolean
|
||||||
|
onFetchNextPage: () => void
|
||||||
|
onRetry: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyConversationState({ agentName }: { agentName: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center px-6 py-12">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<div className="mx-auto flex size-14 items-center justify-center rounded-3xl bg-muted text-muted-foreground">
|
||||||
|
<Bot className="size-6" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-5 font-semibold text-xl">{agentName}</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm leading-6">
|
||||||
|
Ask {agentName} to start a task.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingConversationState() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading conversation...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConversationErrorState({
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
message: string
|
||||||
|
onRetry: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center px-6 py-12">
|
||||||
|
<div className="max-w-md rounded-2xl border border-border/60 bg-card px-5 py-4 text-center shadow-sm">
|
||||||
|
<p className="text-sm">{message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-3 inline-flex items-center gap-2 rounded-lg border border-border/60 px-3 py-1.5 font-medium text-muted-foreground text-xs transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClawChat: FC<ClawChatProps> = ({
|
||||||
|
agentName,
|
||||||
|
historyMessages,
|
||||||
|
turns,
|
||||||
|
streaming,
|
||||||
|
isInitialLoading,
|
||||||
|
error,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onFetchNextPage,
|
||||||
|
onRetry,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const topSentinelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const onFetchNextPageRef = useRef(onFetchNextPage)
|
||||||
|
onFetchNextPageRef.current = onFetchNextPage
|
||||||
|
const hasMessages = historyMessages.length > 0 || turns.length > 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = topSentinelRef.current
|
||||||
|
if (!sentinel) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const [entry] = entries
|
||||||
|
if (!entry?.isIntersecting || !hasNextPage || isFetchingNextPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onFetchNextPageRef.current()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: '160px 0px 0px 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [hasNextPage, isFetchingNextPage])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex min-h-0 flex-1 flex-col overflow-hidden', className)}
|
||||||
|
>
|
||||||
|
<Conversation
|
||||||
|
className={cn(
|
||||||
|
'bg-background',
|
||||||
|
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ConversationContent className="min-h-full px-5 py-5">
|
||||||
|
{isInitialLoading ? (
|
||||||
|
<LoadingConversationState />
|
||||||
|
) : error && !hasMessages ? (
|
||||||
|
<ConversationErrorState message={error.message} onRetry={onRetry} />
|
||||||
|
) : !hasMessages ? (
|
||||||
|
<EmptyConversationState agentName={agentName} />
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
|
||||||
|
<div ref={topSentinelRef} aria-hidden="true" className="h-px" />
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<div className="flex justify-center py-2 text-muted-foreground text-xs">
|
||||||
|
<Loader2 className="mr-2 size-3.5 animate-spin" />
|
||||||
|
Loading older messages...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!hasNextPage && historyMessages.length > 0 ? (
|
||||||
|
<div className="py-1 text-center text-muted-foreground text-xs">
|
||||||
|
Start of conversation
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{historyMessages.map((message) => (
|
||||||
|
<ClawChatMessage key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
{turns.map((turn, index) => (
|
||||||
|
<ConversationMessage
|
||||||
|
key={turn.id}
|
||||||
|
turn={turn}
|
||||||
|
streaming={streaming && index === turns.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-xl border border-border/60 bg-card px-4 py-3 text-muted-foreground text-sm">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConversationContent>
|
||||||
|
<ConversationScrollButton />
|
||||||
|
</Conversation>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { CheckCircle2, Copy, Loader2, Wrench, XCircle } from 'lucide-react'
|
||||||
|
import { type FC, useCallback, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Message,
|
||||||
|
MessageAction,
|
||||||
|
MessageActions,
|
||||||
|
MessageAttachment,
|
||||||
|
MessageAttachments,
|
||||||
|
MessageContent,
|
||||||
|
MessageResponse,
|
||||||
|
MessageToolbar,
|
||||||
|
} from '@/components/ai-elements/message'
|
||||||
|
import {
|
||||||
|
Reasoning,
|
||||||
|
ReasoningContent,
|
||||||
|
ReasoningTrigger,
|
||||||
|
} from '@/components/ai-elements/reasoning'
|
||||||
|
import {
|
||||||
|
Task,
|
||||||
|
TaskContent,
|
||||||
|
TaskItem,
|
||||||
|
TaskTrigger,
|
||||||
|
} from '@/components/ai-elements/task'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type {
|
||||||
|
ClawChatMessagePart,
|
||||||
|
ClawChatMessage as ClawChatMessageType,
|
||||||
|
} from './claw-chat-types'
|
||||||
|
|
||||||
|
function formatCost(usd: number): string {
|
||||||
|
if (usd < 0.005) return `$${usd.toFixed(4)}`
|
||||||
|
return `$${usd.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCallPart = Extract<ClawChatMessagePart, { type: 'tool-call' }>
|
||||||
|
type AttachmentPart = Extract<ClawChatMessagePart, { type: 'attachment' }>
|
||||||
|
|
||||||
|
interface RenderEntry {
|
||||||
|
kind: 'text' | 'reasoning' | 'meta' | 'task' | 'attachments'
|
||||||
|
partIndex: number
|
||||||
|
part?: ClawChatMessagePart
|
||||||
|
tools?: ToolCallPart[]
|
||||||
|
attachments?: AttachmentPart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a render plan that groups all tool-call parts into a single Task
|
||||||
|
* collapsible and all attachment parts into a single attachment strip at
|
||||||
|
* their respective first-appearance positions. Other parts render in place.
|
||||||
|
*/
|
||||||
|
function buildRenderEntries(parts: ClawChatMessagePart[]): RenderEntry[] {
|
||||||
|
const entries: RenderEntry[] = []
|
||||||
|
const tools: ToolCallPart[] = []
|
||||||
|
const attachments: AttachmentPart[] = []
|
||||||
|
let taskInserted = false
|
||||||
|
let attachmentsInserted = false
|
||||||
|
|
||||||
|
parts.forEach((part, partIndex) => {
|
||||||
|
if (part.type === 'tool-call') {
|
||||||
|
tools.push(part)
|
||||||
|
if (!taskInserted) {
|
||||||
|
entries.push({ kind: 'task', partIndex, tools })
|
||||||
|
taskInserted = true
|
||||||
|
}
|
||||||
|
} else if (part.type === 'attachment') {
|
||||||
|
attachments.push(part)
|
||||||
|
if (!attachmentsInserted) {
|
||||||
|
entries.push({ kind: 'attachments', partIndex, attachments })
|
||||||
|
attachmentsInserted = true
|
||||||
|
}
|
||||||
|
} else if (part.type === 'text') {
|
||||||
|
entries.push({ kind: 'text', partIndex, part })
|
||||||
|
} else if (part.type === 'reasoning') {
|
||||||
|
entries.push({ kind: 'reasoning', partIndex, part })
|
||||||
|
} else if (part.type === 'meta') {
|
||||||
|
entries.push({ kind: 'meta', partIndex, part })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolStatusIcon({ status }: { status: ToolCallPart['status'] }) {
|
||||||
|
if (status === 'running' || status === 'pending') {
|
||||||
|
return (
|
||||||
|
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
return <CheckCircle2 className="size-3.5 shrink-0 text-green-500" />
|
||||||
|
}
|
||||||
|
return <XCircle className="size-3.5 shrink-0 text-destructive" />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClawChatMessageProps {
|
||||||
|
message: ClawChatMessageType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClawChatMessage: FC<ClawChatMessageProps> = ({ message }) => {
|
||||||
|
const messageText = message.parts
|
||||||
|
.filter((p) => p.type === 'text')
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
if (messageText) navigator.clipboard.writeText(messageText)
|
||||||
|
}, [messageText])
|
||||||
|
|
||||||
|
const entries = useMemo(
|
||||||
|
() => buildRenderEntries(message.parts),
|
||||||
|
[message.parts],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
from={message.role}
|
||||||
|
className="max-w-full group-[.is-user]:max-w-[80%]"
|
||||||
|
>
|
||||||
|
<MessageContent className="max-w-full overflow-hidden group-[.is-assistant]:w-full group-[.is-user]:max-w-full">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const key = `${message.id}-entry-${entry.partIndex}`
|
||||||
|
|
||||||
|
if (entry.kind === 'attachments' && entry.attachments) {
|
||||||
|
return (
|
||||||
|
<MessageAttachments key={key}>
|
||||||
|
{entry.attachments.map((attachment, idx) => (
|
||||||
|
<MessageAttachment
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: attachment order is stable within a finalized message
|
||||||
|
key={`${attachment.kind}-${idx}`}
|
||||||
|
data={{
|
||||||
|
type: 'file',
|
||||||
|
url: attachment.dataUrl ?? '',
|
||||||
|
mediaType: attachment.mediaType,
|
||||||
|
filename: attachment.name,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MessageAttachments>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === 'text' && entry.part?.type === 'text') {
|
||||||
|
return (
|
||||||
|
<MessageResponse
|
||||||
|
key={key}
|
||||||
|
// Historical messages are finalized — render immediately.
|
||||||
|
// Streamdown's default "streaming" mode uses an idle-callback
|
||||||
|
// debounce (300ms / 500ms idle) that paints empty content
|
||||||
|
// first, which made history flash blank tool collapsibles
|
||||||
|
// before text on every load.
|
||||||
|
mode="static"
|
||||||
|
parseIncompleteMarkdown={false}
|
||||||
|
className={cn(
|
||||||
|
'max-w-full overflow-hidden break-words',
|
||||||
|
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto',
|
||||||
|
'[&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||||
|
'[&_table]:w-max [&_table]:min-w-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.part.text}
|
||||||
|
</MessageResponse>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === 'reasoning' && entry.part?.type === 'reasoning') {
|
||||||
|
return (
|
||||||
|
<Reasoning
|
||||||
|
key={key}
|
||||||
|
className="w-full"
|
||||||
|
defaultOpen={false}
|
||||||
|
duration={entry.part.duration}
|
||||||
|
>
|
||||||
|
<ReasoningTrigger />
|
||||||
|
<ReasoningContent>{entry.part.text}</ReasoningContent>
|
||||||
|
</Reasoning>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === 'meta' && entry.part?.type === 'meta') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="text-muted-foreground text-xs">
|
||||||
|
{entry.part.label}: {entry.part.value}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === 'task' && entry.tools) {
|
||||||
|
const tools = entry.tools
|
||||||
|
const errorCount = tools.filter((t) => t.status === 'failed').length
|
||||||
|
const taskTitle = `Agent activity (${tools.length} ${tools.length === 1 ? 'action' : 'actions'}${errorCount > 0 ? `, ${errorCount} failed` : ''})`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Task key={key} defaultOpen={false}>
|
||||||
|
<TaskTrigger title={taskTitle} TriggerIcon={Wrench} />
|
||||||
|
<TaskContent>
|
||||||
|
{tools.map((tool, idx) => (
|
||||||
|
<TaskItem
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: tool order is stable within a finalized historical message
|
||||||
|
key={`${tool.name}-${tool.status}-${idx}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ToolStatusIcon status={tool.status} />
|
||||||
|
<span className="text-foreground text-xs">
|
||||||
|
{tool.label}
|
||||||
|
</span>
|
||||||
|
{tool.subject ? (
|
||||||
|
<span className="ml-1.5 truncate text-muted-foreground/70 text-xs">
|
||||||
|
· {tool.subject}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{tool.error ? (
|
||||||
|
<span className="ml-2 truncate text-destructive text-xs">
|
||||||
|
{tool.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{tool.durationMs != null ? (
|
||||||
|
<span className="ml-auto text-muted-foreground/60 text-xs tabular-nums">
|
||||||
|
{(tool.durationMs / 1000).toFixed(1)}s
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TaskItem>
|
||||||
|
))}
|
||||||
|
</TaskContent>
|
||||||
|
</Task>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
|
||||||
|
{message.role === 'assistant' && messageText ? (
|
||||||
|
<MessageToolbar>
|
||||||
|
<MessageActions>
|
||||||
|
<MessageAction tooltip="Copy" onClick={handleCopy}>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</MessageAction>
|
||||||
|
</MessageActions>
|
||||||
|
{message.costUsd ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground/50 tabular-nums">
|
||||||
|
{formatCost(message.costUsd)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</MessageToolbar>
|
||||||
|
) : null}
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { ArrowLeft, Home } from 'lucide-react'
|
||||||
|
import type { FC } 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' | 'unknown'
|
||||||
|
adapterHealth: AgentAdapterHealth | null
|
||||||
|
backLabel: string
|
||||||
|
backTarget: 'home' | 'page'
|
||||||
|
onGoHome: () => void
|
||||||
|
onPinToggle: (next: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}) => {
|
||||||
|
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 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>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,20 +2,33 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
Mic,
|
Mic,
|
||||||
|
Paperclip,
|
||||||
Square,
|
Square,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { type FC, type ReactNode, useEffect, useState } from 'react'
|
import {
|
||||||
|
type DragEvent,
|
||||||
|
type FC,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { AppSelector } from '@/components/elements/AppSelector'
|
import { AppSelector } from '@/components/elements/AppSelector'
|
||||||
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
|
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
|
||||||
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||||
|
import { type StagedAttachment, stageAttachments } from '@/lib/attachments'
|
||||||
import { Feature } from '@/lib/browseros/capabilities'
|
import { Feature } from '@/lib/browseros/capabilities'
|
||||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||||
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||||
@@ -24,36 +37,57 @@ import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
|||||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||||
import { AgentSelector } from './AgentSelector'
|
import { AgentSelector } from './AgentSelector'
|
||||||
|
|
||||||
|
export interface ConversationInputSendInput {
|
||||||
|
text: string
|
||||||
|
attachments: StagedAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
interface ConversationInputProps {
|
interface ConversationInputProps {
|
||||||
agents: AgentEntry[]
|
agents: AgentEntry[]
|
||||||
selectedAgentId: string | null
|
selectedAgentId: string | null
|
||||||
onSelectAgent: (agent: AgentEntry) => void
|
onSelectAgent: (agent: AgentEntry) => void
|
||||||
onSend: (text: string) => void
|
onSend: (input: ConversationInputSendInput) => void
|
||||||
onCreateAgent?: () => void
|
onCreateAgent?: () => void
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
status?: string
|
status?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
attachmentsEnabled?: boolean
|
||||||
variant?: 'home' | 'conversation'
|
variant?: 'home' | 'conversation'
|
||||||
|
/**
|
||||||
|
* When set, a Stop button surfaces to the left of the voice mic
|
||||||
|
* while `streaming === true`. Click cancels the active turn
|
||||||
|
* server-side via the chat-cancel endpoint. Absent → no Stop
|
||||||
|
* button (legacy behaviour for the home composer).
|
||||||
|
*/
|
||||||
|
onStop?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputActionButton({
|
function InputActionButton({
|
||||||
disabled,
|
disabled,
|
||||||
onClick,
|
onClick,
|
||||||
streaming,
|
streaming,
|
||||||
|
hasContent,
|
||||||
}: {
|
}: {
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
streaming: boolean
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={disabled}
|
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"
|
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" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowRight className="h-5 w-5" />
|
<ArrowRight className="h-5 w-5" />
|
||||||
@@ -62,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({
|
function VoiceButton({
|
||||||
isRecording,
|
isRecording,
|
||||||
isTranscribing,
|
isTranscribing,
|
||||||
@@ -123,6 +173,9 @@ function ContextControls({
|
|||||||
onToggleTab,
|
onToggleTab,
|
||||||
showAgentSelector,
|
showAgentSelector,
|
||||||
status,
|
status,
|
||||||
|
onAttachClick,
|
||||||
|
attachDisabled,
|
||||||
|
attachmentsEnabled,
|
||||||
}: {
|
}: {
|
||||||
agents: AgentEntry[]
|
agents: AgentEntry[]
|
||||||
onCreateAgent?: () => void
|
onCreateAgent?: () => void
|
||||||
@@ -132,6 +185,9 @@ function ContextControls({
|
|||||||
onToggleTab: (tab: chrome.tabs.Tab) => void
|
onToggleTab: (tab: chrome.tabs.Tab) => void
|
||||||
showAgentSelector: boolean
|
showAgentSelector: boolean
|
||||||
status?: string
|
status?: string
|
||||||
|
onAttachClick: () => void
|
||||||
|
attachDisabled: boolean
|
||||||
|
attachmentsEnabled: boolean
|
||||||
}) {
|
}) {
|
||||||
const { supports } = useCapabilities()
|
const { supports } = useCapabilities()
|
||||||
const { selectedFolder } = useWorkspace()
|
const { selectedFolder } = useWorkspace()
|
||||||
@@ -146,7 +202,7 @@ function ContextControls({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{showAgentSelector ? (
|
{showAgentSelector ? (
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
@@ -191,6 +247,20 @@ function ContextControls({
|
|||||||
<span>Tabs</span>
|
<span>Tabs</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TabPickerPopover>
|
</TabPickerPopover>
|
||||||
|
<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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
<span>Attach</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
|
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
|
||||||
@@ -234,7 +304,7 @@ function ContextControls({
|
|||||||
|
|
||||||
function HomeShell({ children }: { children: ReactNode }) {
|
function HomeShell({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -242,7 +312,7 @@ function HomeShell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
function ConversationShell({ children }: { children: ReactNode }) {
|
function ConversationShell({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
<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">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -258,14 +328,64 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
status,
|
status,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
attachmentsEnabled = true,
|
||||||
variant = 'conversation',
|
variant = 'conversation',
|
||||||
|
onStop,
|
||||||
}) => {
|
}) => {
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||||
|
const [isExpandedDraft, setIsExpandedDraft] = useState(false)
|
||||||
|
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
|
||||||
|
const [attachmentError, setAttachmentError] = useState<string | null>(null)
|
||||||
|
const [isStaging, setIsStaging] = useState(false)
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const voice = useVoiceInput()
|
const voice = useVoiceInput()
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const selectedAgent = agents.find(
|
const selectedAgent = agents.find(
|
||||||
(agent) => agent.agentId === selectedAgentId,
|
(agent) => agent.agentId === selectedAgentId,
|
||||||
)
|
)
|
||||||
|
const isConversation = variant === 'conversation'
|
||||||
|
|
||||||
|
const stageFiles = async (files: File[]) => {
|
||||||
|
if (files.length === 0) return
|
||||||
|
if (!attachmentsEnabled) {
|
||||||
|
setAttachmentError('Attachments are not supported for this agent yet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsStaging(true)
|
||||||
|
setAttachmentError(null)
|
||||||
|
try {
|
||||||
|
const result = await stageAttachments(files, attachments.length)
|
||||||
|
if (result.staged.length > 0) {
|
||||||
|
setAttachments((prev) => [...prev, ...result.staged])
|
||||||
|
}
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
setAttachmentError(result.errors.map((e) => e.message).join(' \u2022 '))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsStaging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||||
|
setAttachmentError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = textareaRef.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const maxHeight = isConversation ? 176 : 100
|
||||||
|
const collapsedHeight = isConversation ? 56 : 72
|
||||||
|
element.style.height = '0px'
|
||||||
|
const nextHeight = Math.min(element.scrollHeight, maxHeight)
|
||||||
|
element.style.height = `${nextHeight}px`
|
||||||
|
element.style.overflowY =
|
||||||
|
element.scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||||||
|
setIsExpandedDraft(nextHeight > collapsedHeight)
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (voice.transcript && !voice.isTranscribing) {
|
if (voice.transcript && !voice.isTranscribing) {
|
||||||
@@ -274,6 +394,12 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
|||||||
}
|
}
|
||||||
}, [voice.transcript, voice.isTranscribing, voice])
|
}, [voice.transcript, voice.isTranscribing, voice])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (attachmentsEnabled) return
|
||||||
|
setAttachments([])
|
||||||
|
setAttachmentError(null)
|
||||||
|
}, [attachmentsEnabled])
|
||||||
|
|
||||||
const toggleTab = (tab: chrome.tabs.Tab) => {
|
const toggleTab = (tab: chrome.tabs.Tab) => {
|
||||||
setSelectedTabs((prev) => {
|
setSelectedTabs((prev) => {
|
||||||
const isSelected = prev.some((selected) => selected.id === tab.id)
|
const isSelected = prev.some((selected) => selected.id === tab.id)
|
||||||
@@ -284,11 +410,77 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasContent = input.trim().length > 0 || attachments.length > 0
|
||||||
|
// Queue-aware composers (the conversation panel passes `onStop`)
|
||||||
|
// accept input while streaming — the parent decides whether the
|
||||||
|
// submission opens a new turn or enqueues onto the active one.
|
||||||
|
// Surfaces without a Stop hook (home) keep the legacy behaviour
|
||||||
|
// and block input until the current turn finishes.
|
||||||
|
const queueAware = Boolean(onStop)
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if (!text || streaming || disabled) return
|
if (disabled || isStaging) return
|
||||||
onSend(text)
|
if (streaming && !queueAware) return
|
||||||
|
if (!text && attachments.length === 0) return
|
||||||
|
onSend({ text, attachments })
|
||||||
setInput('')
|
setInput('')
|
||||||
|
setAttachments([])
|
||||||
|
setAttachmentError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
const files: File[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) files.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length > 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
void stageFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
const files = Array.from(event.dataTransfer?.files ?? [])
|
||||||
|
if (files.length > 0) {
|
||||||
|
void stageFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!event.dataTransfer?.types.includes('Files')) return
|
||||||
|
event.preventDefault()
|
||||||
|
setIsDragOver(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsDragOver(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFilePicker = () => {
|
||||||
|
if (!attachmentsEnabled) {
|
||||||
|
setAttachmentError('Attachments are not supported for this agent yet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileInputChange = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const files = Array.from(event.target.files ?? [])
|
||||||
|
event.target.value = ''
|
||||||
|
if (files.length > 0) void stageFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shell = variant === 'home' ? HomeShell : ConversationShell
|
const shell = variant === 'home' ? HomeShell : ConversationShell
|
||||||
@@ -296,73 +488,203 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="flex items-center gap-3 px-5 py-4">
|
<section
|
||||||
<BotInputIcon variant={variant} />
|
// Drag/drop on a region isn't a click affordance — wrap the
|
||||||
|
// composer in a labeled <section> so the a11y rule is satisfied
|
||||||
|
// without misrepresenting the surface as interactive.
|
||||||
|
aria-label="Message composer"
|
||||||
|
className={cn('relative', isDragOver && 'ring-2 ring-primary/60')}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
ref={fileInputRef}
|
||||||
value={input}
|
type="file"
|
||||||
onChange={(event) => setInput(event.currentTarget.value)}
|
multiple
|
||||||
onKeyDown={(event) => {
|
accept="image/png,image/jpeg,image/webp,image/gif,text/*,application/json"
|
||||||
if (event.key === 'Enter') {
|
className="hidden"
|
||||||
event.preventDefault()
|
onChange={handleFileInputChange}
|
||||||
handleSend()
|
/>
|
||||||
|
{attachments.length > 0 || attachmentError ? (
|
||||||
|
<AttachmentStrip
|
||||||
|
attachments={attachments}
|
||||||
|
onRemove={removeAttachment}
|
||||||
|
error={attachmentError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-3',
|
||||||
|
variant === 'home' ? 'px-4 py-3' : 'px-4 py-3',
|
||||||
|
isExpandedDraft ? 'items-end' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BotInputIcon variant={variant} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
rows={1}
|
||||||
|
placeholder={
|
||||||
|
voice.isTranscribing
|
||||||
|
? 'Transcribing...'
|
||||||
|
: (placeholder ??
|
||||||
|
`Message ${selectedAgent?.name ?? 'agent'}...`)
|
||||||
|
}
|
||||||
|
disabled={disabled || voice.isTranscribing}
|
||||||
|
className={cn(
|
||||||
|
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
|
||||||
|
'[field-sizing:fixed]',
|
||||||
|
variant === 'home'
|
||||||
|
? 'min-h-[40px] py-2 leading-6'
|
||||||
|
: 'min-h-[40px] py-2 leading-6',
|
||||||
|
'placeholder:text-muted-foreground/80',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{streaming && onStop ? <StopButton onStop={onStop} /> : null}
|
||||||
|
<VoiceButton
|
||||||
|
isRecording={voice.isRecording}
|
||||||
|
isTranscribing={voice.isTranscribing}
|
||||||
|
onStart={() => {
|
||||||
|
void voice.startRecording()
|
||||||
|
}}
|
||||||
|
onStop={() => {
|
||||||
|
void voice.stopRecording()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputActionButton
|
||||||
|
disabled={
|
||||||
|
!hasContent ||
|
||||||
|
isStaging ||
|
||||||
|
!!disabled ||
|
||||||
|
voice.isRecording ||
|
||||||
|
voice.isTranscribing ||
|
||||||
|
(streaming && !queueAware)
|
||||||
}
|
}
|
||||||
}}
|
onClick={handleSend}
|
||||||
placeholder={
|
// Spinner stays the user-facing "agent is busy" hint; with the
|
||||||
voice.isTranscribing
|
// queue active we still spin while a turn is in flight.
|
||||||
? 'Transcribing...'
|
streaming={streaming}
|
||||||
: (placeholder ?? `Message ${selectedAgent?.name ?? 'agent'}...`)
|
hasContent={hasContent}
|
||||||
}
|
/>
|
||||||
disabled={disabled || voice.isTranscribing}
|
</div>
|
||||||
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
|
{voice.error ? (
|
||||||
|
<div className="px-5 pb-2 text-destructive text-xs">
|
||||||
|
{voice.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ContextControls
|
||||||
|
agents={agents}
|
||||||
|
onCreateAgent={onCreateAgent}
|
||||||
|
onSelectAgent={onSelectAgent}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
selectedTabs={selectedTabs}
|
||||||
|
onToggleTab={toggleTab}
|
||||||
|
showAgentSelector={variant === 'home'}
|
||||||
|
status={status}
|
||||||
|
onAttachClick={openFilePicker}
|
||||||
|
attachDisabled={attachments.length >= 10 || isStaging || !!disabled}
|
||||||
|
attachmentsEnabled={attachmentsEnabled}
|
||||||
/>
|
/>
|
||||||
<VoiceButton
|
{isDragOver ? (
|
||||||
isRecording={voice.isRecording}
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[inherit] bg-background/80 font-medium text-foreground text-sm backdrop-blur-sm">
|
||||||
isTranscribing={voice.isTranscribing}
|
Drop files to attach
|
||||||
onStart={() => {
|
</div>
|
||||||
void voice.startRecording()
|
) : null}
|
||||||
}}
|
</section>
|
||||||
onStop={() => {
|
|
||||||
void voice.stopRecording()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<InputActionButton
|
|
||||||
disabled={
|
|
||||||
!input.trim() ||
|
|
||||||
streaming ||
|
|
||||||
!!disabled ||
|
|
||||||
voice.isRecording ||
|
|
||||||
voice.isTranscribing
|
|
||||||
}
|
|
||||||
onClick={handleSend}
|
|
||||||
streaming={streaming}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{voice.error ? (
|
|
||||||
<div className="px-5 pb-2 text-destructive text-xs">{voice.error}</div>
|
|
||||||
) : null}
|
|
||||||
<ContextControls
|
|
||||||
agents={agents}
|
|
||||||
onCreateAgent={onCreateAgent}
|
|
||||||
onSelectAgent={onSelectAgent}
|
|
||||||
selectedAgentId={selectedAgentId}
|
|
||||||
selectedTabs={selectedTabs}
|
|
||||||
onToggleTab={toggleTab}
|
|
||||||
showAgentSelector={variant === 'home'}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
</Shell>
|
</Shell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AttachmentStrip({
|
||||||
|
attachments,
|
||||||
|
onRemove,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
attachments: StagedAttachment[]
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
error: string | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-border/40 border-b px-4 pt-3 pb-2">
|
||||||
|
{attachments.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<AttachmentChip
|
||||||
|
key={attachment.id}
|
||||||
|
attachment={attachment}
|
||||||
|
onRemove={() => onRemove(attachment.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-2 text-destructive text-xs">{error}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentChip({
|
||||||
|
attachment,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
attachment: StagedAttachment
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
if (attachment.kind === 'image' && attachment.dataUrl) {
|
||||||
|
return (
|
||||||
|
<div className="group relative size-16 overflow-hidden rounded-md border border-border/60">
|
||||||
|
<img
|
||||||
|
src={attachment.dataUrl}
|
||||||
|
alt={attachment.name}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="absolute top-1 right-1 inline-flex size-5 items-center justify-center rounded-full bg-background/80 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||||
|
aria-label={`Remove ${attachment.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="group flex max-w-[220px] items-center gap-2 rounded-md border border-border/60 bg-background/60 px-2 py-1.5">
|
||||||
|
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-xs">{attachment.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="ml-1 inline-flex size-4 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Remove ${attachment.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
|
function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center text-[var(--accent-orange)]',
|
'flex items-center justify-center text-[var(--accent-orange)]',
|
||||||
variant === 'home'
|
variant === 'home'
|
||||||
? 'h-10 w-10 rounded-xl bg-[var(--accent-orange)]/10'
|
? 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10'
|
||||||
: 'h-9 w-9 rounded-xl bg-[var(--accent-orange)]/12',
|
: 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'
|
import { Bot, CheckCircle2, Loader2, Wrench, XCircle } from 'lucide-react'
|
||||||
import type { FC } from 'react'
|
import { type FC, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
|
MessageAttachment,
|
||||||
|
MessageAttachments,
|
||||||
MessageContent,
|
MessageContent,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from '@/components/ai-elements/message'
|
} from '@/components/ai-elements/message'
|
||||||
@@ -10,96 +12,191 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from '@/components/ai-elements/reasoning'
|
} from '@/components/ai-elements/reasoning'
|
||||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
import {
|
||||||
|
Task,
|
||||||
|
TaskContent,
|
||||||
|
TaskItem,
|
||||||
|
TaskTrigger,
|
||||||
|
} from '@/components/ai-elements/task'
|
||||||
|
import type {
|
||||||
|
AgentConversationTurn,
|
||||||
|
ToolEntry,
|
||||||
|
} from '@/lib/agent-conversations/types'
|
||||||
|
|
||||||
interface ConversationMessageProps {
|
interface ConversationMessageProps {
|
||||||
turn: AgentConversationTurn
|
turn: AgentConversationTurn
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RenderEntry {
|
||||||
|
kind: 'thinking' | 'text' | 'task'
|
||||||
|
partIndex: number
|
||||||
|
text?: string
|
||||||
|
done?: boolean
|
||||||
|
tools?: ToolEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the render plan for an assistant turn:
|
||||||
|
* - thinking and text parts render in place
|
||||||
|
* - all tool-batch parts collapse into a single Task entry at their first
|
||||||
|
* appearance position, with tools listed in arrival order
|
||||||
|
*/
|
||||||
|
function buildRenderEntries(turn: AgentConversationTurn): RenderEntry[] {
|
||||||
|
const entries: RenderEntry[] = []
|
||||||
|
const aggregatedTools: ToolEntry[] = []
|
||||||
|
let taskInserted = false
|
||||||
|
|
||||||
|
turn.parts.forEach((part, partIndex) => {
|
||||||
|
if (part.kind === 'thinking') {
|
||||||
|
entries.push({
|
||||||
|
kind: 'thinking',
|
||||||
|
partIndex,
|
||||||
|
text: part.text,
|
||||||
|
done: part.done,
|
||||||
|
})
|
||||||
|
} else if (part.kind === 'text') {
|
||||||
|
entries.push({ kind: 'text', partIndex, text: part.text })
|
||||||
|
} else if (part.kind === 'tool-batch') {
|
||||||
|
aggregatedTools.push(...part.tools)
|
||||||
|
if (!taskInserted) {
|
||||||
|
entries.push({
|
||||||
|
kind: 'task',
|
||||||
|
partIndex,
|
||||||
|
tools: aggregatedTools,
|
||||||
|
})
|
||||||
|
taskInserted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolStatusIcon({ status }: { status: ToolEntry['status'] }) {
|
||||||
|
if (status === 'running') {
|
||||||
|
return (
|
||||||
|
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
return <CheckCircle2 className="size-3.5 shrink-0 text-green-500" />
|
||||||
|
}
|
||||||
|
return <XCircle className="size-3.5 shrink-0 text-destructive" />
|
||||||
|
}
|
||||||
|
|
||||||
export const ConversationMessage: FC<ConversationMessageProps> = ({
|
export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||||
turn,
|
turn,
|
||||||
streaming,
|
streaming,
|
||||||
}) => (
|
}) => {
|
||||||
<div className="space-y-3">
|
const entries = useMemo(() => buildRenderEntries(turn), [turn])
|
||||||
<Message from="user">
|
|
||||||
<MessageContent>
|
|
||||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
|
||||||
{turn.userText}
|
|
||||||
</pre>
|
|
||||||
</MessageContent>
|
|
||||||
</Message>
|
|
||||||
|
|
||||||
{turn.parts.length > 0 && (
|
return (
|
||||||
<Message from="assistant">
|
<div className="space-y-3">
|
||||||
|
<Message from="user">
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
{turn.parts.map((part, i) => {
|
{turn.userAttachments && turn.userAttachments.length > 0 && (
|
||||||
const key = `${turn.id}-part-${i}`
|
<MessageAttachments>
|
||||||
|
{turn.userAttachments.map((attachment) => (
|
||||||
|
<MessageAttachment
|
||||||
|
key={attachment.id}
|
||||||
|
data={{
|
||||||
|
type: 'file',
|
||||||
|
url: attachment.dataUrl ?? '',
|
||||||
|
mediaType: attachment.mediaType,
|
||||||
|
filename: attachment.name,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MessageAttachments>
|
||||||
|
)}
|
||||||
|
{turn.userText && (
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||||
|
{turn.userText}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
|
||||||
switch (part.kind) {
|
{entries.length > 0 && (
|
||||||
case 'thinking':
|
<Message from="assistant">
|
||||||
|
<MessageContent>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const key = `${turn.id}-entry-${entry.partIndex}`
|
||||||
|
|
||||||
|
if (entry.kind === 'thinking') {
|
||||||
return (
|
return (
|
||||||
<Reasoning
|
<Reasoning
|
||||||
key={key}
|
key={key}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isStreaming={!part.done}
|
isStreaming={!entry.done}
|
||||||
defaultOpen={!part.done}
|
defaultOpen={!entry.done}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>{part.text}</ReasoningContent>
|
<ReasoningContent>{entry.text ?? ''}</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'tool-batch':
|
if (entry.kind === 'text') {
|
||||||
return (
|
return (
|
||||||
<div key={key} className="w-full space-y-1">
|
<MessageResponse key={key}>
|
||||||
{part.tools.map((tool) => (
|
{entry.text ?? ''}
|
||||||
<div
|
</MessageResponse>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = entry.tools ?? []
|
||||||
|
const allDone = tools.every((t) => t.status !== 'running')
|
||||||
|
const taskTitle = allDone
|
||||||
|
? `Agent activity (${tools.length} ${tools.length === 1 ? 'action' : 'actions'})`
|
||||||
|
: `Working… (${tools.length} ${tools.length === 1 ? 'action' : 'actions'})`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Task key={key} defaultOpen={!turn.done}>
|
||||||
|
<TaskTrigger title={taskTitle} TriggerIcon={Wrench} />
|
||||||
|
<TaskContent>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<TaskItem
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{tool.status === 'running' && (
|
<ToolStatusIcon status={tool.status} />
|
||||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
<span className="text-foreground text-xs">
|
||||||
)}
|
{tool.label}
|
||||||
{tool.status === 'completed' && (
|
</span>
|
||||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
{tool.subject ? (
|
||||||
)}
|
<span className="ml-1.5 truncate text-muted-foreground/70 text-xs">
|
||||||
{tool.status === 'error' && (
|
· {tool.subject}
|
||||||
<XCircle className="size-3.5 text-destructive" />
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<span className="font-mono text-xs">{tool.name}</span>
|
|
||||||
{tool.durationMs != null && (
|
{tool.durationMs != null && (
|
||||||
<span className="ml-auto text-muted-foreground text-xs">
|
<span className="ml-auto text-muted-foreground/60 text-xs tabular-nums">
|
||||||
{(tool.durationMs / 1000).toFixed(1)}s
|
{(tool.durationMs / 1000).toFixed(1)}s
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TaskItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TaskContent>
|
||||||
)
|
</Task>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
|
|
||||||
case 'text':
|
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||||
return <MessageResponse key={key}>{part.text}</MessageResponse>
|
<div className="flex gap-2">
|
||||||
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||||
default:
|
<Bot className="size-3.5" />
|
||||||
return null
|
</div>
|
||||||
}
|
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||||
})}
|
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||||
</MessageContent>
|
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||||
</Message>
|
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
|
||||||
<Bot className="size-3.5" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
)}
|
||||||
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
</div>
|
||||||
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
)
|
||||||
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { Outlet, useOutletContext } from 'react-router'
|
import { Outlet, useOutletContext } from 'react-router'
|
||||||
|
import { useHarnessAgents } from '@/entrypoints/app/agents/useAgents'
|
||||||
|
import type {
|
||||||
|
AgentEntry,
|
||||||
|
OpenClawStatus,
|
||||||
|
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||||
import {
|
import {
|
||||||
type AgentEntry,
|
|
||||||
type OpenClawStatus,
|
|
||||||
useOpenClawAgents,
|
useOpenClawAgents,
|
||||||
useOpenClawStatus,
|
useOpenClawStatus,
|
||||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||||
@@ -16,16 +19,32 @@ interface AgentCommandContextValue {
|
|||||||
|
|
||||||
export const AgentCommandLayout: FC = () => {
|
export const AgentCommandLayout: FC = () => {
|
||||||
const { status, loading: statusLoading } = useOpenClawStatus(5000)
|
const { status, loading: statusLoading } = useOpenClawStatus(5000)
|
||||||
const { agents, loading: agentsLoading } = useOpenClawAgents(
|
const openClawEnabled =
|
||||||
status?.status === 'running' && status.controlPlaneStatus === 'connected',
|
status?.status === 'running' && status.controlPlaneStatus === 'connected'
|
||||||
|
const { agents: openClawAgents, loading: openClawAgentsLoading } =
|
||||||
|
useOpenClawAgents(openClawEnabled)
|
||||||
|
const { agents: harnessAgents, loading: harnessAgentsLoading } =
|
||||||
|
useHarnessAgents()
|
||||||
|
const visibleOpenClawAgents = openClawEnabled ? openClawAgents : []
|
||||||
|
// Dual-created OpenClaw agents appear in both `/claw/agents` (gateway
|
||||||
|
// record) and `/agents` (harness record) under the same id. Prefer the
|
||||||
|
// harness entry so the chat panel can route through the harness path
|
||||||
|
// and the rail doesn't show duplicates.
|
||||||
|
const harnessAgentIds = new Set(harnessAgents.map((entry) => entry.agentId))
|
||||||
|
const dedupedOpenClawAgents = visibleOpenClawAgents.filter(
|
||||||
|
(entry) => !harnessAgentIds.has(entry.agentId),
|
||||||
)
|
)
|
||||||
|
const agents = [...dedupedOpenClawAgents, ...harnessAgents]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Outlet
|
<Outlet
|
||||||
context={
|
context={
|
||||||
{
|
{
|
||||||
agents,
|
agents,
|
||||||
agentsLoading,
|
agentsLoading:
|
||||||
|
harnessAgentsLoading ||
|
||||||
|
statusLoading ||
|
||||||
|
(openClawEnabled && openClawAgentsLoading),
|
||||||
status,
|
status,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
} satisfies AgentCommandContextValue
|
} satisfies AgentCommandContextValue
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { mapAgentHarnessToolStatus } from './agent-stream-events'
|
||||||
|
|
||||||
|
describe('mapAgentHarnessToolStatus', () => {
|
||||||
|
it('normalizes ACP tool statuses for the chat renderer', () => {
|
||||||
|
expect(mapAgentHarnessToolStatus('running')).toBe('running')
|
||||||
|
expect(mapAgentHarnessToolStatus('completed')).toBe('completed')
|
||||||
|
expect(mapAgentHarnessToolStatus('failed')).toBe('error')
|
||||||
|
expect(mapAgentHarnessToolStatus('incomplete')).toBe('running')
|
||||||
|
expect(mapAgentHarnessToolStatus(undefined)).toBe('running')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ToolEntry } from '@/lib/agent-conversations/types'
|
||||||
|
|
||||||
|
export function mapAgentHarnessToolStatus(
|
||||||
|
status: string | undefined,
|
||||||
|
): ToolEntry['status'] {
|
||||||
|
if (!status) return 'running'
|
||||||
|
const normalized = status.toLowerCase()
|
||||||
|
if (['error', 'failed', 'failure', 'denied'].includes(normalized)) {
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
['complete', 'completed', 'done', 'success', 'succeeded'].includes(
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'completed'
|
||||||
|
}
|
||||||
|
return 'running'
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||||
|
import {
|
||||||
|
type AgentHistoryPageResponse,
|
||||||
|
type BrowserOSChatHistoryItem,
|
||||||
|
buildChatHistoryFromClawMessages,
|
||||||
|
filterTurnsPersistedInHistory,
|
||||||
|
flattenHistoryPages,
|
||||||
|
mapHistoryItemToClawMessage,
|
||||||
|
} from './claw-chat-types'
|
||||||
|
|
||||||
|
function historyItem(
|
||||||
|
overrides: Partial<BrowserOSChatHistoryItem>,
|
||||||
|
): BrowserOSChatHistoryItem {
|
||||||
|
return {
|
||||||
|
id: 'session-1:0',
|
||||||
|
role: 'user',
|
||||||
|
text: 'Hello',
|
||||||
|
timestamp: 1000,
|
||||||
|
messageSeq: 0,
|
||||||
|
sessionKey: 'session-1',
|
||||||
|
source: 'user-chat',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(items: BrowserOSChatHistoryItem[]): AgentHistoryPageResponse {
|
||||||
|
return {
|
||||||
|
agentId: 'main',
|
||||||
|
sessionKey: 'session-1',
|
||||||
|
session: null,
|
||||||
|
items,
|
||||||
|
page: {
|
||||||
|
hasMore: false,
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('claw-chat-types', () => {
|
||||||
|
it('maps backend history items into text-first ClawChat messages', () => {
|
||||||
|
const message = mapHistoryItemToClawMessage(
|
||||||
|
historyItem({
|
||||||
|
id: 'session-1:1',
|
||||||
|
role: 'assistant',
|
||||||
|
text: 'Hi there',
|
||||||
|
messageSeq: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(message).toEqual({
|
||||||
|
id: 'session-1:1',
|
||||||
|
role: 'assistant',
|
||||||
|
sessionKey: 'session-1',
|
||||||
|
timestamp: 1000,
|
||||||
|
source: 'user-chat',
|
||||||
|
messageSeq: 1,
|
||||||
|
status: 'historical',
|
||||||
|
parts: [{ type: 'text', text: 'Hi there' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flattens paginated history into oldest-to-newest render order', () => {
|
||||||
|
const messages = flattenHistoryPages([
|
||||||
|
page([
|
||||||
|
historyItem({
|
||||||
|
id: 'session-1:2',
|
||||||
|
role: 'user',
|
||||||
|
text: 'newer',
|
||||||
|
timestamp: 3000,
|
||||||
|
messageSeq: 2,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
page([
|
||||||
|
historyItem({
|
||||||
|
id: 'session-1:0',
|
||||||
|
role: 'user',
|
||||||
|
text: 'older',
|
||||||
|
timestamp: 1000,
|
||||||
|
messageSeq: 0,
|
||||||
|
}),
|
||||||
|
historyItem({
|
||||||
|
id: 'session-1:1',
|
||||||
|
role: 'assistant',
|
||||||
|
text: 'middle',
|
||||||
|
timestamp: 2000,
|
||||||
|
messageSeq: 1,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(messages.map((message) => message.id)).toEqual([
|
||||||
|
'session-1:0',
|
||||||
|
'session-1:1',
|
||||||
|
'session-1:2',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds OpenClaw chat history from text message parts only', () => {
|
||||||
|
const history = buildChatHistoryFromClawMessages([
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'user',
|
||||||
|
sessionKey: 'session-1',
|
||||||
|
parts: [{ type: 'text', text: ' User request ' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'assistant-1',
|
||||||
|
role: 'assistant',
|
||||||
|
sessionKey: 'session-1',
|
||||||
|
parts: [
|
||||||
|
{ type: 'reasoning', text: 'private reasoning' },
|
||||||
|
{ type: 'text', text: 'Assistant answer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(history).toEqual([
|
||||||
|
{ role: 'user', content: 'User request' },
|
||||||
|
{ role: 'assistant', content: 'Assistant answer' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides completed live turns once harness history contains the same turn', () => {
|
||||||
|
const turn: AgentConversationTurn = {
|
||||||
|
id: 'live-turn',
|
||||||
|
userText: 'hello',
|
||||||
|
parts: [{ kind: 'text', text: 'hi there' }],
|
||||||
|
done: true,
|
||||||
|
timestamp: 1_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = filterTurnsPersistedInHistory(
|
||||||
|
[turn],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'history-user',
|
||||||
|
role: 'user',
|
||||||
|
sessionKey: 'main',
|
||||||
|
timestamp: 1_050,
|
||||||
|
status: 'historical',
|
||||||
|
parts: [{ type: 'text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'history-assistant',
|
||||||
|
role: 'assistant',
|
||||||
|
sessionKey: 'main',
|
||||||
|
timestamp: 1_100,
|
||||||
|
status: 'historical',
|
||||||
|
parts: [{ type: 'text', text: 'hi there' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(visible).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps completed live turns until matching assistant history arrives', () => {
|
||||||
|
const turn: AgentConversationTurn = {
|
||||||
|
id: 'live-turn',
|
||||||
|
userText: 'hello',
|
||||||
|
parts: [{ kind: 'text', text: 'hi there' }],
|
||||||
|
done: true,
|
||||||
|
timestamp: 1_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = filterTurnsPersistedInHistory(
|
||||||
|
[turn],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'history-user',
|
||||||
|
role: 'user',
|
||||||
|
sessionKey: 'main',
|
||||||
|
timestamp: 1_050,
|
||||||
|
status: 'historical',
|
||||||
|
parts: [{ type: 'text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(visible).toEqual([turn])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||||
|
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||||
|
|
||||||
|
export type ClawChatRole = 'user' | 'assistant'
|
||||||
|
|
||||||
|
export type ClawChatSource = 'user-chat' | 'cron' | 'hook' | 'channel' | 'other'
|
||||||
|
|
||||||
|
export interface BrowserOSOpenClawSession {
|
||||||
|
key: string
|
||||||
|
updatedAt: number
|
||||||
|
sessionId: string
|
||||||
|
agentId: string
|
||||||
|
kind: string
|
||||||
|
source: ClawChatSource
|
||||||
|
status?: string
|
||||||
|
totalTokens?: number
|
||||||
|
model?: string
|
||||||
|
modelProvider?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserOSChatHistoryToolCall {
|
||||||
|
toolCallId?: string
|
||||||
|
toolName: string
|
||||||
|
label: string
|
||||||
|
subject?: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
input?: unknown
|
||||||
|
output?: unknown
|
||||||
|
error?: string
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserOSChatHistoryReasoning {
|
||||||
|
text: string
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserOSChatHistoryAttachment {
|
||||||
|
kind: 'image' | 'file'
|
||||||
|
mediaType: string
|
||||||
|
// Images carry a `data:` URL so we can render directly without any
|
||||||
|
// additional fetch; files (text/PDF) currently round-trip via inline
|
||||||
|
// text in the message body and do not populate this field in v1.
|
||||||
|
dataUrl?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserOSChatHistoryItem {
|
||||||
|
id: string
|
||||||
|
role: ClawChatRole
|
||||||
|
text: string
|
||||||
|
timestamp?: number
|
||||||
|
messageSeq: number
|
||||||
|
sessionKey: string
|
||||||
|
source: ClawChatSource
|
||||||
|
costUsd?: number
|
||||||
|
tokensIn?: number
|
||||||
|
tokensOut?: number
|
||||||
|
toolCalls?: BrowserOSChatHistoryToolCall[]
|
||||||
|
reasoning?: BrowserOSChatHistoryReasoning
|
||||||
|
attachments?: BrowserOSChatHistoryAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentHistoryPageResponse {
|
||||||
|
agentId: string
|
||||||
|
sessionKey: string | null
|
||||||
|
session: BrowserOSOpenClawSession | null
|
||||||
|
items: BrowserOSChatHistoryItem[]
|
||||||
|
page: {
|
||||||
|
cursor?: string
|
||||||
|
hasMore: boolean
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClawChatMessageStatus =
|
||||||
|
| 'historical'
|
||||||
|
| 'sending'
|
||||||
|
| 'streaming'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
export type ClawChatMessagePart =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'reasoning'; text: string; duration?: number }
|
||||||
|
| {
|
||||||
|
type: 'tool-call'
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
subject?: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
input?: unknown
|
||||||
|
output?: unknown
|
||||||
|
error?: string
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'attachment'
|
||||||
|
kind: 'image' | 'file'
|
||||||
|
mediaType: string
|
||||||
|
dataUrl?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
| { type: 'meta'; label: string; value: string }
|
||||||
|
|
||||||
|
export interface ClawChatMessage {
|
||||||
|
id: string
|
||||||
|
role: ClawChatRole
|
||||||
|
sessionKey: string
|
||||||
|
timestamp?: number
|
||||||
|
source?: ClawChatSource
|
||||||
|
messageSeq?: number
|
||||||
|
status?: ClawChatMessageStatus
|
||||||
|
parts: ClawChatMessagePart[]
|
||||||
|
costUsd?: number
|
||||||
|
tokensIn?: number
|
||||||
|
tokensOut?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapHistoryItemToClawMessage(
|
||||||
|
item: BrowserOSChatHistoryItem,
|
||||||
|
): ClawChatMessage {
|
||||||
|
const parts: ClawChatMessagePart[] = []
|
||||||
|
|
||||||
|
// Attachments first — they belong above the text in user messages and
|
||||||
|
// never appear on assistant messages today (assistant images come back
|
||||||
|
// through tool results, which render via the Task collapsible).
|
||||||
|
if (item.attachments && item.attachments.length > 0) {
|
||||||
|
for (const attachment of item.attachments) {
|
||||||
|
parts.push({
|
||||||
|
type: 'attachment',
|
||||||
|
kind: attachment.kind,
|
||||||
|
mediaType: attachment.mediaType,
|
||||||
|
dataUrl: attachment.dataUrl,
|
||||||
|
name: attachment.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reasoning, then tool calls, then text — the chronological order the
|
||||||
|
// agent produced them (think → act → answer).
|
||||||
|
if (item.reasoning && item.reasoning.text.trim().length > 0) {
|
||||||
|
// 0ms means thinking and the final answer were emitted in the same JSONL
|
||||||
|
// line (no tool calls between them) — there's no real elapsed wall-clock,
|
||||||
|
// so fall through to the "Thinking" trigger instead of "Thought for 0
|
||||||
|
// seconds" / streaming shimmer. Real multi-line turns floor at 1s.
|
||||||
|
const durationMs = item.reasoning.durationMs ?? 0
|
||||||
|
const duration =
|
||||||
|
durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : undefined
|
||||||
|
parts.push({
|
||||||
|
type: 'reasoning',
|
||||||
|
text: item.reasoning.text,
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.toolCalls && item.toolCalls.length > 0) {
|
||||||
|
for (const tc of item.toolCalls) {
|
||||||
|
parts.push({
|
||||||
|
type: 'tool-call',
|
||||||
|
name: tc.toolName,
|
||||||
|
label: tc.label,
|
||||||
|
subject: tc.subject,
|
||||||
|
status: tc.status,
|
||||||
|
input: tc.input,
|
||||||
|
output: tc.output,
|
||||||
|
error: tc.error,
|
||||||
|
durationMs: tc.durationMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only emit a text part when there's actual content. User messages with
|
||||||
|
// only attachments and no caption shouldn't render an empty bubble.
|
||||||
|
if (item.text.trim().length > 0) {
|
||||||
|
parts.push({ type: 'text', text: item.text })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
role: item.role,
|
||||||
|
sessionKey: item.sessionKey,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
source: item.source,
|
||||||
|
messageSeq: item.messageSeq,
|
||||||
|
status: 'historical',
|
||||||
|
parts,
|
||||||
|
costUsd: item.costUsd,
|
||||||
|
tokensIn: item.tokensIn,
|
||||||
|
tokensOut: item.tokensOut,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenHistoryPages(
|
||||||
|
pages: AgentHistoryPageResponse[],
|
||||||
|
): ClawChatMessage[] {
|
||||||
|
return pages
|
||||||
|
.flatMap((page) => page.items)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.timestamp != null && b.timestamp != null) {
|
||||||
|
return a.timestamp - b.timestamp
|
||||||
|
}
|
||||||
|
return a.messageSeq - b.messageSeq
|
||||||
|
})
|
||||||
|
.map(mapHistoryItemToClawMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChatHistoryFromClawMessages(
|
||||||
|
messages: ClawChatMessage[],
|
||||||
|
): OpenClawChatHistoryMessage[] {
|
||||||
|
return messages
|
||||||
|
.map((message) => {
|
||||||
|
const content = message.parts
|
||||||
|
.filter((part): part is { type: 'text'; text: string } => {
|
||||||
|
return part.type === 'text' && part.text.trim().length > 0
|
||||||
|
})
|
||||||
|
.map((part) => part.text.trim())
|
||||||
|
.join('\n\n')
|
||||||
|
|
||||||
|
return content ? { role: message.role, content } : null
|
||||||
|
})
|
||||||
|
.filter((message): message is OpenClawChatHistoryMessage =>
|
||||||
|
Boolean(message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TURN_HISTORY_MATCH_WINDOW_MS = 5_000
|
||||||
|
|
||||||
|
export function filterTurnsPersistedInHistory(
|
||||||
|
turns: AgentConversationTurn[],
|
||||||
|
historyMessages: ClawChatMessage[],
|
||||||
|
): AgentConversationTurn[] {
|
||||||
|
return turns.filter(
|
||||||
|
(turn) => !isTurnPersistedInHistory(turn, historyMessages),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTurnPersistedInHistory(
|
||||||
|
turn: AgentConversationTurn,
|
||||||
|
historyMessages: ClawChatMessage[],
|
||||||
|
): boolean {
|
||||||
|
if (!turn.done) return false
|
||||||
|
|
||||||
|
const assistantText = getTurnAssistantText(turn)
|
||||||
|
if (!assistantText) return false
|
||||||
|
|
||||||
|
const minTimestamp = turn.timestamp - TURN_HISTORY_MATCH_WINDOW_MS
|
||||||
|
const userText = turn.userText.trim()
|
||||||
|
const userPersisted =
|
||||||
|
!userText ||
|
||||||
|
historyMessages.some(
|
||||||
|
(message) =>
|
||||||
|
message.role === 'user' &&
|
||||||
|
isHistoryMessageAfter(message, minTimestamp) &&
|
||||||
|
getClawMessageText(message) === userText,
|
||||||
|
)
|
||||||
|
const assistantPersisted = historyMessages.some(
|
||||||
|
(message) =>
|
||||||
|
message.role === 'assistant' &&
|
||||||
|
isHistoryMessageAfter(message, minTimestamp) &&
|
||||||
|
getClawMessageText(message) === assistantText,
|
||||||
|
)
|
||||||
|
|
||||||
|
return userPersisted && assistantPersisted
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHistoryMessageAfter(
|
||||||
|
message: ClawChatMessage,
|
||||||
|
minTimestamp: number,
|
||||||
|
): boolean {
|
||||||
|
return message.timestamp == null || message.timestamp >= minTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTurnAssistantText(turn: AgentConversationTurn): string {
|
||||||
|
return turn.parts
|
||||||
|
.filter((part) => part.kind === 'text')
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClawMessageText(message: ClawChatMessage): string {
|
||||||
|
return message.parts
|
||||||
|
.filter((part) => part.type === 'text')
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -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,69 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import {
|
|
||||||
type AgentEntry,
|
|
||||||
getModelDisplayName,
|
|
||||||
type OpenClawStatus,
|
|
||||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
|
||||||
import { getLatestConversation } from '@/lib/agent-conversations/storage'
|
|
||||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
|
||||||
|
|
||||||
function getAgentStatusTone(
|
|
||||||
status: OpenClawStatus['status'] | undefined,
|
|
||||||
): AgentCardData['status'] {
|
|
||||||
if (status === 'error') return 'error'
|
|
||||||
if (status === 'starting') return 'working'
|
|
||||||
return 'idle'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAgentCardData(
|
|
||||||
agent: AgentEntry,
|
|
||||||
status: OpenClawStatus['status'] | undefined,
|
|
||||||
): Promise<AgentCardData> {
|
|
||||||
const conversation = await getLatestConversation(agent.agentId)
|
|
||||||
const lastTurn = conversation?.turns[conversation.turns.length - 1]
|
|
||||||
const lastTextPart = lastTurn?.parts.findLast((part) => part.kind === 'text')
|
|
||||||
|
|
||||||
return {
|
|
||||||
agentId: agent.agentId,
|
|
||||||
name: agent.name,
|
|
||||||
model: getModelDisplayName(agent.model),
|
|
||||||
status: getAgentStatusTone(status),
|
|
||||||
lastMessage:
|
|
||||||
lastTextPart?.kind === 'text'
|
|
||||||
? lastTextPart.text.slice(0, 120)
|
|
||||||
: undefined,
|
|
||||||
lastMessageTimestamp: lastTurn?.timestamp,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentCardData(
|
|
||||||
agents: AgentEntry[],
|
|
||||||
status: OpenClawStatus['status'] | undefined,
|
|
||||||
) {
|
|
||||||
const [cardData, setCardData] = useState<AgentCardData[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
|
|
||||||
const loadCardData = async () => {
|
|
||||||
const nextCardData = await Promise.all(
|
|
||||||
agents.map((agent) => getAgentCardData(agent, status)),
|
|
||||||
)
|
|
||||||
if (active) {
|
|
||||||
setCardData(nextCardData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agents.length > 0) {
|
|
||||||
void loadCardData()
|
|
||||||
} else {
|
|
||||||
setCardData([])
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [agents, status])
|
|
||||||
|
|
||||||
return cardData
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,86 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
buildChatHistoryFromTurns,
|
type AgentHarnessStreamEvent,
|
||||||
chatWithAgent,
|
attachToHarnessTurn,
|
||||||
type OpenClawStreamEvent,
|
cancelHarnessTurn,
|
||||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
chatWithHarnessAgent,
|
||||||
import {
|
fetchActiveHarnessTurn,
|
||||||
getLatestConversation,
|
} from '@/entrypoints/app/agents/useAgents'
|
||||||
saveConversation,
|
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||||
} from '@/lib/agent-conversations/storage'
|
|
||||||
import type {
|
import type {
|
||||||
AgentConversation,
|
|
||||||
AgentConversationTurn,
|
AgentConversationTurn,
|
||||||
AssistantPart,
|
AssistantPart,
|
||||||
|
ToolEntry,
|
||||||
|
UserAttachmentPreview,
|
||||||
} from '@/lib/agent-conversations/types'
|
} from '@/lib/agent-conversations/types'
|
||||||
|
import type { ServerAttachmentPayload } from '@/lib/attachments'
|
||||||
import { consumeSSEStream } from '@/lib/sse'
|
import { consumeSSEStream } from '@/lib/sse'
|
||||||
|
import { buildToolLabel } from '@/lib/tool-labels'
|
||||||
|
import { mapAgentHarnessToolStatus } from './agent-stream-events'
|
||||||
|
|
||||||
export function useAgentConversation(agentId: string, agentName: string) {
|
export interface SendInput {
|
||||||
|
text: string
|
||||||
|
attachments?: ServerAttachmentPayload[]
|
||||||
|
// Optional preview metadata used to render the optimistic user turn.
|
||||||
|
// Built by the composer at staging time; the server only sees the
|
||||||
|
// payload array.
|
||||||
|
attachmentPreviews?: UserAttachmentPreview[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAgentConversationOptions {
|
||||||
|
// The hook always speaks to the harness chat path now; the OpenClaw
|
||||||
|
// legacy /claw/agents/:id/chat surface was removed in Step 12. The
|
||||||
|
// option remains for forward-compatibility.
|
||||||
|
runtime?: 'agent-harness'
|
||||||
|
sessionKey?: string | null
|
||||||
|
history?: OpenClawChatHistoryMessage[]
|
||||||
|
onComplete?: () => void
|
||||||
|
onSessionKeyChange?: (sessionKey: string) => void
|
||||||
|
/**
|
||||||
|
* Server-side active turn id, surfaced via the listing query. When
|
||||||
|
* this changes from null/<id> to a different non-null id while we
|
||||||
|
* aren't already streaming (e.g. the server just popped a queued
|
||||||
|
* message and started a new turn), the hook reattaches via
|
||||||
|
* /chat/active so the chat panel picks up the live stream without
|
||||||
|
* waiting for a remount.
|
||||||
|
*/
|
||||||
|
activeTurnId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentConversation(
|
||||||
|
agentId: string,
|
||||||
|
options: UseAgentConversationOptions = {},
|
||||||
|
) {
|
||||||
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
||||||
const [streaming, setStreaming] = useState(false)
|
const [streaming, setStreaming] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const sessionKeyRef = useRef(options.sessionKey ?? '')
|
||||||
const sessionKeyRef = useRef('')
|
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
|
||||||
const textAccRef = useRef('')
|
const textAccRef = useRef('')
|
||||||
const thinkAccRef = useRef('')
|
const thinkAccRef = useRef('')
|
||||||
const streamAbortRef = useRef<AbortController | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true
|
sessionKeyRef.current = options.sessionKey ?? ''
|
||||||
getLatestConversation(agentId)
|
}, [options.sessionKey])
|
||||||
.then((conv) => {
|
|
||||||
if (!active) return
|
useEffect(() => {
|
||||||
if (conv) {
|
historyRef.current = options.history ?? []
|
||||||
setTurns(conv.turns)
|
}, [options.history])
|
||||||
sessionKeyRef.current = conv.sessionKey
|
|
||||||
} else {
|
useEffect(() => {
|
||||||
sessionKeyRef.current = crypto.randomUUID()
|
onCompleteRef.current = options.onComplete
|
||||||
}
|
}, [options.onComplete])
|
||||||
setLoading(false)
|
|
||||||
})
|
useEffect(() => {
|
||||||
.catch(() => {
|
onSessionKeyChangeRef.current = options.onSessionKeyChange
|
||||||
if (active) {
|
}, [options.onSessionKeyChange])
|
||||||
sessionKeyRef.current = crypto.randomUUID()
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [agentId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -54,17 +88,11 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const persistTurns = (updatedTurns: AgentConversationTurn[]) => {
|
// Indirection for the resume effect below: lets it call the latest
|
||||||
const conv: AgentConversation = {
|
// event handler without re-subscribing on every render.
|
||||||
agentId,
|
const processEventRef = useRef<(event: AgentHarnessStreamEvent) => void>(
|
||||||
agentName,
|
() => {},
|
||||||
sessionKey: sessionKeyRef.current,
|
)
|
||||||
turns: updatedTurns,
|
|
||||||
createdAt: updatedTurns[0]?.timestamp ?? Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
}
|
|
||||||
saveConversation(conv).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCurrentTurnParts = (
|
const updateCurrentTurnParts = (
|
||||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||||
@@ -76,123 +104,236 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
const appendTextDelta = (delta: string) => {
|
||||||
switch (event.type) {
|
textAccRef.current += delta
|
||||||
case 'text-delta': {
|
const text = textAccRef.current
|
||||||
const delta = (event.data.text as string) ?? ''
|
updateCurrentTurnParts((parts) => {
|
||||||
textAccRef.current += delta
|
const last = parts[parts.length - 1]
|
||||||
const text = textAccRef.current
|
if (last?.kind === 'text') {
|
||||||
updateCurrentTurnParts((parts) => {
|
return [...parts.slice(0, -1), { ...last, text }]
|
||||||
const last = parts[parts.length - 1]
|
|
||||||
if (last?.kind === 'text') {
|
|
||||||
return [...parts.slice(0, -1), { ...last, text }]
|
|
||||||
}
|
|
||||||
return [...parts, { kind: 'text', text }]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
return [...parts, { kind: 'text', text }]
|
||||||
case 'thinking': {
|
})
|
||||||
const delta = (event.data.text as string) ?? ''
|
|
||||||
thinkAccRef.current += delta
|
|
||||||
const text = thinkAccRef.current
|
|
||||||
updateCurrentTurnParts((parts) => {
|
|
||||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
|
||||||
if (idx >= 0) {
|
|
||||||
return [
|
|
||||||
...parts.slice(0, idx),
|
|
||||||
{ ...parts[idx], text, done: false },
|
|
||||||
...parts.slice(idx + 1),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return [...parts, { kind: 'thinking', text, done: false }]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool-start': {
|
|
||||||
const tool = {
|
|
||||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
|
||||||
name: (event.data.toolName as string) ?? 'unknown',
|
|
||||||
status: 'running' as const,
|
|
||||||
}
|
|
||||||
updateCurrentTurnParts((parts) => {
|
|
||||||
const last = parts[parts.length - 1]
|
|
||||||
if (last?.kind === 'tool-batch') {
|
|
||||||
return [
|
|
||||||
...parts.slice(0, -1),
|
|
||||||
{ ...last, tools: [...last.tools, tool] },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool-end': {
|
|
||||||
const toolId = event.data.toolCallId as string
|
|
||||||
const toolStatus: 'completed' | 'error' =
|
|
||||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
|
||||||
const durationMs = event.data.durationMs as number | undefined
|
|
||||||
updateCurrentTurnParts((parts) => {
|
|
||||||
for (let i = parts.length - 1; i >= 0; i--) {
|
|
||||||
const part = parts[i]
|
|
||||||
if (
|
|
||||||
part.kind === 'tool-batch' &&
|
|
||||||
part.tools.some((t) => t.id === toolId)
|
|
||||||
) {
|
|
||||||
const updatedTools = part.tools.map((t) =>
|
|
||||||
t.id === toolId ? { ...t, status: toolStatus, durationMs } : t,
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
...parts.slice(0, i),
|
|
||||||
{ ...part, tools: updatedTools },
|
|
||||||
...parts.slice(i + 1),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'done': {
|
|
||||||
updateCurrentTurnParts((parts) =>
|
|
||||||
parts.map((part) =>
|
|
||||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
setTurns((prev) => {
|
|
||||||
const last = prev[prev.length - 1]
|
|
||||||
if (!last) return prev
|
|
||||||
const updated = [...prev.slice(0, -1), { ...last, done: true }]
|
|
||||||
persistTurns(updated)
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'error': {
|
|
||||||
const msg =
|
|
||||||
(event.data.message as string) ??
|
|
||||||
(event.data.error as string) ??
|
|
||||||
'Unknown error'
|
|
||||||
updateCurrentTurnParts((parts) => [
|
|
||||||
...parts,
|
|
||||||
{ kind: 'text', text: `Error: ${msg}` },
|
|
||||||
])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = async (text: string) => {
|
const appendThinkingDelta = (delta: string) => {
|
||||||
if (!text.trim() || streaming) return
|
thinkAccRef.current += delta
|
||||||
const history = buildChatHistoryFromTurns(turns)
|
const text = thinkAccRef.current
|
||||||
|
updateCurrentTurnParts((parts) => {
|
||||||
|
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||||
|
if (idx >= 0) {
|
||||||
|
return [
|
||||||
|
...parts.slice(0, idx),
|
||||||
|
{ ...parts[idx], text, done: false },
|
||||||
|
...parts.slice(idx + 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [...parts, { kind: 'thinking', text, done: false }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendErrorText = (message: string) => {
|
||||||
|
updateCurrentTurnParts((parts) => [
|
||||||
|
...parts,
|
||||||
|
{ kind: 'text', text: `Error: ${message}` },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const markCurrentTurnDone = () => {
|
||||||
|
updateCurrentTurnParts((parts) =>
|
||||||
|
parts.map((part) =>
|
||||||
|
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setTurns((prev) => {
|
||||||
|
const last = prev[prev.length - 1]
|
||||||
|
if (!last) return prev
|
||||||
|
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
|
||||||
|
if (event.type !== 'tool_call') return
|
||||||
|
const rawName = event.title || event.rawType || 'tool call'
|
||||||
|
const { label, subject } = buildToolLabel(
|
||||||
|
rawName,
|
||||||
|
event.text ? { description: event.text } : undefined,
|
||||||
|
)
|
||||||
|
const tool: ToolEntry = {
|
||||||
|
id: event.id ?? crypto.randomUUID(),
|
||||||
|
name: rawName,
|
||||||
|
label,
|
||||||
|
subject,
|
||||||
|
status: mapAgentHarnessToolStatus(event.status),
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCurrentTurnParts((parts) => {
|
||||||
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
const part = parts[i]
|
||||||
|
if (
|
||||||
|
part.kind === 'tool-batch' &&
|
||||||
|
part.tools.some((existing) => existing.id === tool.id)
|
||||||
|
) {
|
||||||
|
const tools = part.tools.map((existing) =>
|
||||||
|
existing.id === tool.id ? { ...existing, ...tool } : existing,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
...parts.slice(0, i),
|
||||||
|
{ ...part, tools },
|
||||||
|
...parts.slice(i + 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = parts[parts.length - 1]
|
||||||
|
if (last?.kind === 'tool-batch') {
|
||||||
|
return [
|
||||||
|
...parts.slice(0, -1),
|
||||||
|
{ ...last, tools: [...last.tools, tool] },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const processAgentHarnessStreamEvent = (event: AgentHarnessStreamEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'text_delta':
|
||||||
|
if (event.stream === 'thought') {
|
||||||
|
appendThinkingDelta(event.text)
|
||||||
|
} else {
|
||||||
|
appendTextDelta(event.text)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'tool_call':
|
||||||
|
upsertAgentHarnessTool(event)
|
||||||
|
break
|
||||||
|
case 'done':
|
||||||
|
markCurrentTurnDone()
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
appendErrorText(event.message)
|
||||||
|
break
|
||||||
|
case 'status':
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processEventRef.current = processAgentHarnessStreamEvent
|
||||||
|
|
||||||
|
const activeTurnIdDep = options.activeTurnId ?? null
|
||||||
|
|
||||||
|
// On mount, on agent change, and whenever the listing reports a
|
||||||
|
// *new* active turn id, check whether the server has an in-flight
|
||||||
|
// turn for this agent and reattach to it. This catches three
|
||||||
|
// cases at once: the chat resilience flow (tab close/reopen),
|
||||||
|
// navigation between agents, AND queue drain (the server starts a
|
||||||
|
// new turn from a queued message → activeTurnId flips → attach).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const abortController = new AbortController()
|
||||||
|
// Reference the dep inside the body so biome's exhaustive-deps
|
||||||
|
// rule sees it consumed; the value is just an "any non-null
|
||||||
|
// active turn id" trigger — the actual id we attach to comes
|
||||||
|
// from the fresh fetchActiveHarnessTurn call below.
|
||||||
|
void activeTurnIdDep
|
||||||
|
|
||||||
|
const attemptResume = async () => {
|
||||||
|
// Track whether *we* started a stream in this run. When the
|
||||||
|
// early-return paths fire (no active turn, or a `send()` /
|
||||||
|
// earlier resume already owns `streamAbortRef`), the finally
|
||||||
|
// block must NOT touch streaming/turnIdRef/lastSeqRef —
|
||||||
|
// otherwise we clobber the in-flight stream's state and the
|
||||||
|
// Stop button drops out mid-turn while events keep arriving.
|
||||||
|
let weStartedStream = false
|
||||||
|
try {
|
||||||
|
const active = await fetchActiveHarnessTurn(agentId)
|
||||||
|
if (cancelled || !active || active.status !== 'running') return
|
||||||
|
if (streamAbortRef.current) return // someone else already owns the stream
|
||||||
|
|
||||||
|
// Stage a placeholder turn so the streamed events have a row
|
||||||
|
// to render into. The server now persists the kicking-off
|
||||||
|
// prompt on the active turn, so we render it as the user
|
||||||
|
// bubble immediately — no empty-bubble flicker when a queued
|
||||||
|
// message starts running.
|
||||||
|
setTurns((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
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) {
|
||||||
|
turnIdRef.current = null
|
||||||
|
lastSeqRef.current = null
|
||||||
|
setStreaming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void attemptResume()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}, [agentId, activeTurnIdDep])
|
||||||
|
|
||||||
|
const send = async (input: string | SendInput) => {
|
||||||
|
const normalized: SendInput =
|
||||||
|
typeof input === 'string' ? { text: input } : input
|
||||||
|
const trimmed = normalized.text.trim()
|
||||||
|
const attachments = normalized.attachments ?? []
|
||||||
|
if (streaming) return
|
||||||
|
if (!trimmed && attachments.length === 0) return
|
||||||
|
|
||||||
const turn: AgentConversationTurn = {
|
const turn: AgentConversationTurn = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userText: text.trim(),
|
userText: trimmed,
|
||||||
|
userAttachments:
|
||||||
|
normalized.attachmentPreviews &&
|
||||||
|
normalized.attachmentPreviews.length > 0
|
||||||
|
? normalized.attachmentPreviews
|
||||||
|
: undefined,
|
||||||
parts: [],
|
parts: [],
|
||||||
done: false,
|
done: false,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -205,13 +346,37 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
|||||||
streamAbortRef.current = abortController
|
streamAbortRef.current = abortController
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await chatWithAgent(
|
let response = await chatWithHarnessAgent(
|
||||||
agentId,
|
agentId,
|
||||||
text.trim(),
|
trimmed,
|
||||||
sessionKeyRef.current,
|
|
||||||
history,
|
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
|
attachments,
|
||||||
)
|
)
|
||||||
|
// 409 means the server already has an active turn for this
|
||||||
|
// agent (e.g. a previous tab kicked one off and we're a fresh
|
||||||
|
// mount that missed the resume window). Attach to it instead of
|
||||||
|
// double-sending.
|
||||||
|
if (response.status === 409) {
|
||||||
|
const body = (await response.json()) as { turnId?: string }
|
||||||
|
if (body.turnId) {
|
||||||
|
response = await attachToHarnessTurn(agentId, {
|
||||||
|
turnId: body.turnId,
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
turnIdRef.current = responseTurnId
|
||||||
|
lastSeqRef.current = null
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.text()
|
const err = await response.text()
|
||||||
updateCurrentTurnParts((parts) => [
|
updateCurrentTurnParts((parts) => [
|
||||||
@@ -220,9 +385,12 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
|||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await consumeSSEStream(
|
await consumeSSEStream<AgentHarnessStreamEvent>(
|
||||||
response,
|
response,
|
||||||
processStreamEvent,
|
(event, meta) => {
|
||||||
|
if (typeof meta.seq === 'number') lastSeqRef.current = meta.seq
|
||||||
|
processAgentHarnessStreamEvent(event)
|
||||||
|
},
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -236,24 +404,45 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
|||||||
if (streamAbortRef.current === abortController) {
|
if (streamAbortRef.current === abortController) {
|
||||||
streamAbortRef.current = null
|
streamAbortRef.current = null
|
||||||
}
|
}
|
||||||
|
turnIdRef.current = null
|
||||||
|
lastSeqRef.current = null
|
||||||
|
onCompleteRef.current?.()
|
||||||
setStreaming(false)
|
setStreaming(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetConversation = () => {
|
/**
|
||||||
|
* 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?.abort()
|
||||||
streamAbortRef.current = null
|
streamAbortRef.current = null
|
||||||
|
try {
|
||||||
|
await cancelHarnessTurn(agentId, {
|
||||||
|
turnId,
|
||||||
|
reason: 'user pressed stop',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Best-effort — UI already aborted.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetConversation = () => {
|
||||||
|
void stop()
|
||||||
setTurns([])
|
setTurns([])
|
||||||
setStreaming(false)
|
setStreaming(false)
|
||||||
sessionKeyRef.current = crypto.randomUUID()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turns,
|
turns,
|
||||||
streaming,
|
streaming,
|
||||||
loading,
|
|
||||||
sessionKey: sessionKeyRef.current,
|
sessionKey: sessionKeyRef.current,
|
||||||
send,
|
send,
|
||||||
|
stop,
|
||||||
resetConversation,
|
resetConversation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchHarnessAgentHistory } from '@/entrypoints/app/agents/useAgents'
|
||||||
|
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||||
|
import type { AgentHistoryPageResponse } from './claw-chat-types'
|
||||||
|
import { mapHarnessHistoryPage } from './harness-history-mapper'
|
||||||
|
|
||||||
|
const HISTORY_QUERY_KEY = 'harness-agent-history'
|
||||||
|
|
||||||
|
export function useHarnessChatHistory(agentId: string, enabled = true) {
|
||||||
|
const {
|
||||||
|
baseUrl,
|
||||||
|
isLoading: urlLoading,
|
||||||
|
error: urlError,
|
||||||
|
} = useAgentServerUrl()
|
||||||
|
|
||||||
|
const query = useQuery<AgentHistoryPageResponse, Error>({
|
||||||
|
queryKey: [HISTORY_QUERY_KEY, baseUrl, agentId, 'main'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return mapHarnessHistoryPage(await fetchHarnessAgentHistory(agentId))
|
||||||
|
},
|
||||||
|
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
error: query.error ?? urlError,
|
||||||
|
isLoading: query.isLoading || urlLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Bot, Cpu, Sparkles } 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" />
|
||||||
|
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'
|
||||||
|
default:
|
||||||
|
return 'Agent'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Bot,
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
Send,
|
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { type FC, useEffect, useRef, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Message,
|
|
||||||
MessageContent,
|
|
||||||
MessageResponse,
|
|
||||||
} from '@/components/ai-elements/message'
|
|
||||||
import {
|
|
||||||
Reasoning,
|
|
||||||
ReasoningContent,
|
|
||||||
ReasoningTrigger,
|
|
||||||
} from '@/components/ai-elements/reasoning'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { consumeSSEStream } from '@/lib/sse'
|
|
||||||
import {
|
|
||||||
buildChatHistoryFromTurns,
|
|
||||||
chatWithAgent,
|
|
||||||
type OpenClawStreamEvent,
|
|
||||||
} from './useOpenClaw'
|
|
||||||
|
|
||||||
interface ToolEntry {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
status: 'running' | 'completed' | 'error'
|
|
||||||
durationMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type AssistantPart =
|
|
||||||
| { kind: 'thinking'; text: string; done: boolean }
|
|
||||||
| { kind: 'tool-batch'; tools: ToolEntry[] }
|
|
||||||
| { kind: 'text'; text: string }
|
|
||||||
|
|
||||||
interface ChatTurn {
|
|
||||||
id: string
|
|
||||||
userText: string
|
|
||||||
parts: AssistantPart[]
|
|
||||||
done: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentChatProps {
|
|
||||||
agentId: string
|
|
||||||
agentName: string
|
|
||||||
onBack: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentChat: FC<AgentChatProps> = ({
|
|
||||||
agentId,
|
|
||||||
agentName,
|
|
||||||
onBack,
|
|
||||||
}) => {
|
|
||||||
const [turns, setTurns] = useState<ChatTurn[]>([])
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const [streaming, setStreaming] = useState(false)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const sessionKeyRef = useRef(crypto.randomUUID())
|
|
||||||
const streamAbortRef = useRef<AbortController | null>(null)
|
|
||||||
|
|
||||||
const textAccRef = useRef('')
|
|
||||||
const thinkAccRef = useRef('')
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom()
|
|
||||||
}, [turns])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
streamAbortRef.current?.abort()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateCurrentTurnParts = (
|
|
||||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
|
||||||
) => {
|
|
||||||
setTurns((prev) => {
|
|
||||||
const last = prev[prev.length - 1]
|
|
||||||
if (!last) return prev
|
|
||||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
|
||||||
switch (event.type) {
|
|
||||||
case 'text-delta': {
|
|
||||||
const delta = (event.data.text as string) ?? ''
|
|
||||||
textAccRef.current += delta
|
|
||||||
const text = textAccRef.current
|
|
||||||
updateCurrentTurnParts((parts) => {
|
|
||||||
const last = parts[parts.length - 1]
|
|
||||||
if (last?.kind === 'text') {
|
|
||||||
return [...parts.slice(0, -1), { ...last, text }]
|
|
||||||
}
|
|
||||||
return [...parts, { kind: 'text', text }]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'thinking': {
|
|
||||||
const delta = (event.data.text as string) ?? ''
|
|
||||||
thinkAccRef.current += delta
|
|
||||||
const text = thinkAccRef.current
|
|
||||||
updateCurrentTurnParts((parts) => {
|
|
||||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
|
||||||
if (idx >= 0) {
|
|
||||||
return [
|
|
||||||
...parts.slice(0, idx),
|
|
||||||
{ ...parts[idx], text, done: false },
|
|
||||||
...parts.slice(idx + 1),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return [...parts, { kind: 'thinking', text, done: false }]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool-start': {
|
|
||||||
const tool: ToolEntry = {
|
|
||||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
|
||||||
name: (event.data.toolName as string) ?? 'unknown',
|
|
||||||
status: 'running',
|
|
||||||
}
|
|
||||||
updateCurrentTurnParts((parts) => {
|
|
||||||
const last = parts[parts.length - 1]
|
|
||||||
if (last?.kind === 'tool-batch') {
|
|
||||||
return [
|
|
||||||
...parts.slice(0, -1),
|
|
||||||
{ ...last, tools: [...last.tools, tool] },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool-end': {
|
|
||||||
const toolId = event.data.toolCallId as string
|
|
||||||
const status =
|
|
||||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
|
||||||
const durationMs = event.data.durationMs as number | undefined
|
|
||||||
updateCurrentTurnParts((parts) => {
|
|
||||||
for (let i = parts.length - 1; i >= 0; i--) {
|
|
||||||
const part = parts[i]
|
|
||||||
if (
|
|
||||||
part.kind === 'tool-batch' &&
|
|
||||||
part.tools.some((t) => t.id === toolId)
|
|
||||||
) {
|
|
||||||
const updatedTools = part.tools.map((t) =>
|
|
||||||
t.id === toolId
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
status: status as ToolEntry['status'],
|
|
||||||
durationMs,
|
|
||||||
}
|
|
||||||
: t,
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
...parts.slice(0, i),
|
|
||||||
{ ...part, tools: updatedTools },
|
|
||||||
...parts.slice(i + 1),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'done': {
|
|
||||||
updateCurrentTurnParts((parts) =>
|
|
||||||
parts.map((part) =>
|
|
||||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
setTurns((prev) => {
|
|
||||||
const last = prev[prev.length - 1]
|
|
||||||
if (!last) return prev
|
|
||||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'error': {
|
|
||||||
const msg =
|
|
||||||
(event.data.message as string) ??
|
|
||||||
(event.data.error as string) ??
|
|
||||||
'Unknown error'
|
|
||||||
updateCurrentTurnParts((parts) => [
|
|
||||||
...parts,
|
|
||||||
{ kind: 'text', text: `Error: ${msg}` },
|
|
||||||
])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
const text = input.trim()
|
|
||||||
if (!text || streaming) return
|
|
||||||
const history = buildChatHistoryFromTurns(turns)
|
|
||||||
|
|
||||||
const turn: ChatTurn = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
userText: text,
|
|
||||||
parts: [],
|
|
||||||
done: false,
|
|
||||||
}
|
|
||||||
setTurns((prev) => [...prev, turn])
|
|
||||||
setInput('')
|
|
||||||
setStreaming(true)
|
|
||||||
|
|
||||||
textAccRef.current = ''
|
|
||||||
thinkAccRef.current = ''
|
|
||||||
const abortController = new AbortController()
|
|
||||||
streamAbortRef.current = abortController
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await chatWithAgent(
|
|
||||||
agentId,
|
|
||||||
text,
|
|
||||||
sessionKeyRef.current,
|
|
||||||
history,
|
|
||||||
abortController.signal,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.text()
|
|
||||||
updateCurrentTurnParts((parts) => [
|
|
||||||
...parts,
|
|
||||||
{ kind: 'text', text: `Error: ${err}` },
|
|
||||||
])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await consumeSSEStream(
|
|
||||||
response,
|
|
||||||
processStreamEvent,
|
|
||||||
abortController.signal,
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
if (abortController.signal.aborted) return
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
updateCurrentTurnParts((parts) => [
|
|
||||||
...parts,
|
|
||||||
{ kind: 'text', text: `Error: ${msg}` },
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
if (streamAbortRef.current === abortController) {
|
|
||||||
streamAbortRef.current = null
|
|
||||||
}
|
|
||||||
setStreaming(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
|
||||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<h2 className="font-semibold text-lg">{agentName}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
||||||
{turns.map((turn) => (
|
|
||||||
<div key={turn.id} className="space-y-3">
|
|
||||||
{/* User message */}
|
|
||||||
<Message from="user">
|
|
||||||
<MessageContent>
|
|
||||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
|
||||||
{turn.userText}
|
|
||||||
</pre>
|
|
||||||
</MessageContent>
|
|
||||||
</Message>
|
|
||||||
|
|
||||||
{/* Assistant response — all parts grouped */}
|
|
||||||
{turn.parts.length > 0 && (
|
|
||||||
<Message from="assistant">
|
|
||||||
<MessageContent>
|
|
||||||
{turn.parts.map((part, i) => {
|
|
||||||
const key = `${turn.id}-part-${i}`
|
|
||||||
|
|
||||||
switch (part.kind) {
|
|
||||||
case 'thinking':
|
|
||||||
return (
|
|
||||||
<Reasoning
|
|
||||||
key={key}
|
|
||||||
className="w-full"
|
|
||||||
isStreaming={!part.done}
|
|
||||||
defaultOpen={!part.done}
|
|
||||||
>
|
|
||||||
<ReasoningTrigger />
|
|
||||||
<ReasoningContent>{part.text}</ReasoningContent>
|
|
||||||
</Reasoning>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'tool-batch':
|
|
||||||
return (
|
|
||||||
<div key={key} className="w-full space-y-1">
|
|
||||||
{part.tools.map((tool) => (
|
|
||||||
<div
|
|
||||||
key={tool.id}
|
|
||||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
{tool.status === 'running' && (
|
|
||||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{tool.status === 'completed' && (
|
|
||||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
|
||||||
)}
|
|
||||||
{tool.status === 'error' && (
|
|
||||||
<XCircle className="size-3.5 text-destructive" />
|
|
||||||
)}
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{tool.name}
|
|
||||||
</span>
|
|
||||||
{tool.durationMs != null && (
|
|
||||||
<span className="ml-auto text-muted-foreground text-xs">
|
|
||||||
{(tool.durationMs / 1000).toFixed(1)}s
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'text':
|
|
||||||
return (
|
|
||||||
<MessageResponse key={key}>
|
|
||||||
{part.text}
|
|
||||||
</MessageResponse>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</MessageContent>
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Streaming indicator when waiting for first part */}
|
|
||||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
|
||||||
<Bot className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t p-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Textarea
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSend()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Send a message..."
|
|
||||||
className="min-h-[44px] resize-none"
|
|
||||||
rows={1}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!input.trim() || streaming}
|
|
||||||
size="icon"
|
|
||||||
>
|
|
||||||
{streaming ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { type FC, useMemo } from 'react'
|
||||||
|
import { AgentRowCard } from './AgentRowCard'
|
||||||
|
import { AgentsEmptyState } from './AgentsEmptyState'
|
||||||
|
import type {
|
||||||
|
HarnessAdapterDescriptor,
|
||||||
|
HarnessAgent,
|
||||||
|
HarnessAgentAdapter,
|
||||||
|
} from './agent-harness-types'
|
||||||
|
import type {
|
||||||
|
AgentAdapterHealth,
|
||||||
|
AgentRowData,
|
||||||
|
} from './agent-row/agent-row.types'
|
||||||
|
import { compareAgentsByPinThenRecency } from './agents-list-order'
|
||||||
|
import type { AgentListItem } from './agents-page-types'
|
||||||
|
import type { AgentLiveness } from './LivenessDot'
|
||||||
|
|
||||||
|
interface AgentListProps {
|
||||||
|
agents: AgentListItem[]
|
||||||
|
/** Optional per-agent activity metadata, keyed by `agentId`. */
|
||||||
|
activity?: Record<
|
||||||
|
string,
|
||||||
|
{ status: AgentLiveness; lastUsedAt: number | null }
|
||||||
|
>
|
||||||
|
/** Lookup table from harness id → enriched agent record. */
|
||||||
|
harnessAgentLookup?: Map<string, HarnessAgent>
|
||||||
|
/** Adapter catalog (carries per-adapter health). */
|
||||||
|
adapters: HarnessAdapterDescriptor[]
|
||||||
|
loading: boolean
|
||||||
|
deletingAgentKey: string | null
|
||||||
|
onCreateAgent: () => void
|
||||||
|
onDeleteAgent: (agent: AgentListItem) => void
|
||||||
|
onPinToggle: (agent: AgentListItem, next: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentList: FC<AgentListProps> = ({
|
||||||
|
agents,
|
||||||
|
activity,
|
||||||
|
harnessAgentLookup,
|
||||||
|
adapters,
|
||||||
|
loading,
|
||||||
|
deletingAgentKey,
|
||||||
|
onCreateAgent,
|
||||||
|
onDeleteAgent,
|
||||||
|
onPinToggle,
|
||||||
|
}) => {
|
||||||
|
const adapterHealth = useMemo(() => {
|
||||||
|
const map = new Map<HarnessAgentAdapter, AgentAdapterHealth>()
|
||||||
|
for (const adapter of adapters) {
|
||||||
|
if (adapter.health) {
|
||||||
|
map.set(adapter.id, {
|
||||||
|
healthy: adapter.health.healthy,
|
||||||
|
reason: adapter.health.reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [adapters])
|
||||||
|
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const withMeta = agents.map((agent) => {
|
||||||
|
const harness = harnessAgentLookup?.get(agent.agentId)
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
id: agent.agentId,
|
||||||
|
pinned: harness?.pinned ?? false,
|
||||||
|
lastUsedAt: activity?.[agent.agentId]?.lastUsedAt ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return withMeta
|
||||||
|
.sort(compareAgentsByPinThenRecency)
|
||||||
|
.map((entry) => entry.agent)
|
||||||
|
}, [activity, agents, harnessAgentLookup])
|
||||||
|
|
||||||
|
if (loading && agents.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-36 items-center justify-center rounded-xl border border-border border-dashed bg-card/50">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agents.length === 0) {
|
||||||
|
return <AgentsEmptyState onCreateAgent={onCreateAgent} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{ordered.map((agent) => {
|
||||||
|
const harness = harnessAgentLookup?.get(agent.agentId)
|
||||||
|
const adapter: HarnessAgentAdapter | 'unknown' =
|
||||||
|
harness?.adapter ?? inferAdapterFromLabel(agent.runtimeLabel)
|
||||||
|
const data = buildRowData({
|
||||||
|
agent,
|
||||||
|
adapter,
|
||||||
|
harness,
|
||||||
|
activity: activity?.[agent.agentId],
|
||||||
|
adapterHealth:
|
||||||
|
adapterHealth.get(adapter as HarnessAgentAdapter) ?? null,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<AgentRowCard
|
||||||
|
key={agent.key}
|
||||||
|
data={data}
|
||||||
|
deleting={deletingAgentKey === agent.key}
|
||||||
|
onDelete={onDeleteAgent}
|
||||||
|
onPinToggle={onPinToggle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
|
||||||
|
const lower = label?.toLowerCase()
|
||||||
|
if (lower === 'claude code') return 'claude'
|
||||||
|
if (lower === 'codex') return 'codex'
|
||||||
|
if (lower === 'openclaw') return 'openclaw'
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,14 +5,16 @@ import {
|
|||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft, Check, Copy } from 'lucide-react'
|
||||||
import { type FC, useEffect, useRef } from 'react'
|
import { type FC, useEffect, useRef, useState } from 'react'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||||
|
|
||||||
interface AgentTerminalProps {
|
interface AgentTerminalProps {
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
initialCommand?: string
|
||||||
|
onSessionExit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalServerMessage =
|
type TerminalServerMessage =
|
||||||
@@ -36,26 +38,22 @@ function resolveCssColor(variableName: string): string {
|
|||||||
return color
|
return color
|
||||||
}
|
}
|
||||||
|
|
||||||
function withAlpha(color: string, alpha: number): string {
|
|
||||||
const channels = color.match(/[\d.]+/g)
|
|
||||||
if (!channels || channels.length < 3) return color
|
|
||||||
const [red, green, blue] = channels
|
|
||||||
return `rgb(${red} ${green} ${blue} / ${alpha})`
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTerminalTheme() {
|
function createTerminalTheme() {
|
||||||
const isDark = document.documentElement.classList.contains('dark')
|
const isDark = document.documentElement.classList.contains('dark')
|
||||||
const background = resolveCssColor('--background')
|
const background = resolveCssColor('--background')
|
||||||
const foreground = resolveCssColor('--foreground')
|
const foreground = resolveCssColor('--foreground')
|
||||||
const muted = resolveCssColor('--muted-foreground')
|
const muted = resolveCssColor('--muted-foreground')
|
||||||
const accent = resolveCssColor('--accent-orange')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
background,
|
background,
|
||||||
foreground,
|
foreground,
|
||||||
cursor: foreground,
|
cursor: foreground,
|
||||||
cursorAccent: background,
|
cursorAccent: background,
|
||||||
selectionBackground: withAlpha(accent, isDark ? 0.3 : 0.2),
|
// Solid terminal-standard selection colors. Deriving from a CSS var
|
||||||
|
// with alpha composed against the background produced near-white
|
||||||
|
// rectangles on light mode, making selection invisible.
|
||||||
|
selectionBackground: isDark ? '#3a4463' : '#b4d4f4',
|
||||||
|
selectionInactiveBackground: isDark ? '#2b3348' : '#d9e5f3',
|
||||||
selectionForeground: foreground,
|
selectionForeground: foreground,
|
||||||
black: isDark ? '#16131a' : '#1f1b22',
|
black: isDark ? '#16131a' : '#1f1b22',
|
||||||
red: isDark ? '#ef8c7c' : '#c25544',
|
red: isDark ? '#ef8c7c' : '#c25544',
|
||||||
@@ -118,8 +116,38 @@ function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||||
|
onBack,
|
||||||
|
initialCommand,
|
||||||
|
onSessionExit,
|
||||||
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const terminalRef = useRef<Terminal | null>(null)
|
||||||
|
// Refs keep the mount-once effect from tearing down the PTY when the
|
||||||
|
// parent re-renders with new inline callbacks.
|
||||||
|
const initialCommandRef = useRef(initialCommand)
|
||||||
|
const onSessionExitRef = useRef(onSessionExit)
|
||||||
|
initialCommandRef.current = initialCommand
|
||||||
|
onSessionExitRef.current = onSessionExit
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
// Copy the current xterm selection to the browser clipboard. No-op
|
||||||
|
// if nothing is selected — users who want the whole buffer can
|
||||||
|
// Cmd+A first. Uses the browser clipboard, not the container's, so
|
||||||
|
// it works even when the running TUI has mouse tracking enabled
|
||||||
|
// (Opt+drag forces a selection regardless, see terminal config).
|
||||||
|
const handleCopy = async (): Promise<void> => {
|
||||||
|
const text = terminalRef.current?.getSelection()
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {
|
||||||
|
// clipboard permission denied or unavailable — swallow, user will retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return
|
if (!containerRef.current) return
|
||||||
@@ -132,6 +160,34 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
|||||||
lineHeight: 1.25,
|
lineHeight: 1.25,
|
||||||
scrollback: 8000,
|
scrollback: 8000,
|
||||||
theme: createTerminalTheme(),
|
theme: createTerminalTheme(),
|
||||||
|
// Opt+click+drag forces a native text selection even when the
|
||||||
|
// running TUI has mouse-tracking enabled (xterm would otherwise
|
||||||
|
// forward every click to the app and selection wouldn't work).
|
||||||
|
macOptionClickForcesSelection: true,
|
||||||
|
})
|
||||||
|
terminalRef.current = terminal
|
||||||
|
|
||||||
|
// Cmd+A → select all, Cmd+C → copy selection via the browser
|
||||||
|
// clipboard. Return false so xterm doesn't also forward the keys
|
||||||
|
// to the running program.
|
||||||
|
terminal.attachCustomKeyEventHandler((event) => {
|
||||||
|
if (event.type !== 'keydown') return true
|
||||||
|
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||||
|
const mod = isMac ? event.metaKey : event.ctrlKey
|
||||||
|
if (!mod) return true
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
if (key === 'a') {
|
||||||
|
terminal.selectAll()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (key === 'c') {
|
||||||
|
const sel = terminal.getSelection()
|
||||||
|
if (sel) {
|
||||||
|
void navigator.clipboard.writeText(sel)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
@@ -139,6 +195,12 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
|||||||
terminal.loadAddon(new WebLinksAddon())
|
terminal.loadAddon(new WebLinksAddon())
|
||||||
terminal.open(containerRef.current)
|
terminal.open(containerRef.current)
|
||||||
|
|
||||||
|
// React 18 StrictMode double-invokes effects in dev. Everything
|
||||||
|
// async inside this effect is scoped to an AbortController; the
|
||||||
|
// cleanup aborts it and any pending awaits bail out, so we never
|
||||||
|
// leak a second live WebSocket or duplicate xterm listeners.
|
||||||
|
const ac = new AbortController()
|
||||||
|
const cleanups: Array<() => void> = []
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let sawExit = false
|
let sawExit = false
|
||||||
|
|
||||||
@@ -159,17 +221,28 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
|||||||
sendMessage({ type: 'resize', cols, rows })
|
sendMessage({ type: 'resize', cols, rows })
|
||||||
}
|
}
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async (): Promise<void> => {
|
||||||
const baseUrl = await getAgentServerUrl()
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
if (ac.signal.aborted) return
|
||||||
const wsUrl = new URL('/terminal/ws', baseUrl)
|
const wsUrl = new URL('/terminal/ws', baseUrl)
|
||||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl)
|
ws = new WebSocket(wsUrl)
|
||||||
|
// If the effect was cleaned up between the await above and now,
|
||||||
|
// close the socket we just opened and bail.
|
||||||
|
if (ac.signal.aborted) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanups.push(() => ws?.close())
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
terminal.focus()
|
terminal.focus()
|
||||||
sendResize()
|
sendResize()
|
||||||
|
const cmd = initialCommandRef.current
|
||||||
|
if (cmd) sendMessage({ type: 'input', data: `${cmd}\n` })
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -185,6 +258,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
|||||||
terminal.write(
|
terminal.write(
|
||||||
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
|
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
|
||||||
)
|
)
|
||||||
|
onSessionExitRef.current?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,49 +274,41 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
|||||||
const inputDisposable = terminal.onData((data) => {
|
const inputDisposable = terminal.onData((data) => {
|
||||||
sendMessage({ type: 'input', data })
|
sendMessage({ type: 'input', data })
|
||||||
})
|
})
|
||||||
|
|
||||||
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
||||||
sendResize(cols, rows)
|
sendResize(cols, rows)
|
||||||
})
|
})
|
||||||
|
cleanups.push(() => inputDisposable.dispose())
|
||||||
return () => {
|
cleanups.push(() => resizeDisposable.dispose())
|
||||||
inputDisposable.dispose()
|
|
||||||
resizeDisposable.dispose()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let disposeSocketBindings: (() => void) | undefined
|
void connect()
|
||||||
void connect().then((disposeBindings) => {
|
|
||||||
disposeSocketBindings = disposeBindings
|
|
||||||
})
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
sendResize()
|
sendResize()
|
||||||
})
|
})
|
||||||
resizeObserver.observe(containerRef.current)
|
resizeObserver.observe(containerRef.current)
|
||||||
|
cleanups.push(() => resizeObserver.disconnect())
|
||||||
|
|
||||||
const themeObserver = new MutationObserver(() => {
|
const themeObserver = new MutationObserver(() => applyTheme())
|
||||||
applyTheme()
|
|
||||||
})
|
|
||||||
themeObserver.observe(document.documentElement, {
|
themeObserver.observe(document.documentElement, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['class'],
|
attributeFilter: ['class'],
|
||||||
})
|
})
|
||||||
|
cleanups.push(() => themeObserver.disconnect())
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect()
|
ac.abort()
|
||||||
themeObserver.disconnect()
|
for (const dispose of cleanups) dispose()
|
||||||
disposeSocketBindings?.()
|
|
||||||
ws?.close()
|
|
||||||
terminal.dispose()
|
terminal.dispose()
|
||||||
|
terminalRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
|
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
||||||
<div className="flex items-center gap-3 border-border border-b px-4 py-3 sm:px-6">
|
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-3 sm:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
@@ -256,6 +322,14 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="mr-1 size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="mr-1 size-3.5" />
|
||||||
|
)}
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 p-4 sm:p-6">
|
<div className="min-h-0 flex-1 p-4 sm:p-6">
|
||||||
@@ -269,7 +343,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 px-4 py-4 sm:px-5 sm:py-5">
|
<div className="min-h-0 flex-1 cursor-text px-4 py-4 sm:px-5 sm:py-5">
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import type {
|
||||||
|
HarnessAdapterDescriptor,
|
||||||
|
HarnessAgentAdapter,
|
||||||
|
} from './agent-harness-types'
|
||||||
|
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
|
||||||
|
import { ProviderSelector } from './OpenClawControls'
|
||||||
|
import {
|
||||||
|
type OpenClawCliProvider,
|
||||||
|
type OpenClawCliProviderAuthStatus,
|
||||||
|
OpenClawCliProviderStatusPanel,
|
||||||
|
} from './openclaw-cli-providers'
|
||||||
|
|
||||||
|
interface NewAgentDialogProps {
|
||||||
|
adapters: HarnessAdapterDescriptor[]
|
||||||
|
canManageOpenClaw: boolean
|
||||||
|
createError: string | null
|
||||||
|
createRuntime: CreateAgentRuntime
|
||||||
|
creating: boolean
|
||||||
|
defaultProviderId: string
|
||||||
|
harnessAdapterId: HarnessAgentAdapter
|
||||||
|
harnessModelId: string
|
||||||
|
harnessReasoningEffort: string
|
||||||
|
name: string
|
||||||
|
open: boolean
|
||||||
|
providers: ProviderOption[]
|
||||||
|
selectedCliProvider: OpenClawCliProvider | undefined
|
||||||
|
selectedProviderId: string
|
||||||
|
cliAuthError: Error | null
|
||||||
|
cliAuthLoading: boolean
|
||||||
|
cliAuthStatus: OpenClawCliProviderAuthStatus | undefined
|
||||||
|
onConnectCliProvider: () => void
|
||||||
|
onCreate: () => void
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onRuntimeChange: (runtime: CreateAgentRuntime) => void
|
||||||
|
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
|
||||||
|
onHarnessModelChange: (modelId: string) => void
|
||||||
|
onHarnessReasoningChange: (reasoningEffort: string) => void
|
||||||
|
onNameChange: (name: string) => void
|
||||||
|
onProviderChange: (providerId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||||
|
adapters,
|
||||||
|
canManageOpenClaw,
|
||||||
|
createError,
|
||||||
|
createRuntime,
|
||||||
|
creating,
|
||||||
|
defaultProviderId,
|
||||||
|
harnessAdapterId,
|
||||||
|
harnessModelId,
|
||||||
|
harnessReasoningEffort,
|
||||||
|
name,
|
||||||
|
open,
|
||||||
|
providers,
|
||||||
|
selectedCliProvider,
|
||||||
|
selectedProviderId,
|
||||||
|
cliAuthError,
|
||||||
|
cliAuthLoading,
|
||||||
|
cliAuthStatus,
|
||||||
|
onConnectCliProvider,
|
||||||
|
onCreate,
|
||||||
|
onOpenChange,
|
||||||
|
onRuntimeChange,
|
||||||
|
onHarnessAdapterChange,
|
||||||
|
onHarnessModelChange,
|
||||||
|
onHarnessReasoningChange,
|
||||||
|
onNameChange,
|
||||||
|
onProviderChange,
|
||||||
|
}) => {
|
||||||
|
const selectedHarnessAdapter =
|
||||||
|
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
|
||||||
|
const isHarnessRuntime = createRuntime !== 'openclaw'
|
||||||
|
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
|
||||||
|
const cliBlocked =
|
||||||
|
createRuntime === 'openclaw' &&
|
||||||
|
!!selectedCliProvider &&
|
||||||
|
!cliAuthStatus?.loggedIn
|
||||||
|
const canCreate =
|
||||||
|
Boolean(name.trim()) &&
|
||||||
|
!creating &&
|
||||||
|
!openClawBlocked &&
|
||||||
|
!cliBlocked &&
|
||||||
|
(createRuntime === 'openclaw'
|
||||||
|
? providers.length > 0
|
||||||
|
: Boolean(selectedHarnessAdapter))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Agent</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
{createError ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>Create failed</AlertTitle>
|
||||||
|
<AlertDescription>{createError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="agent-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => onNameChange(event.target.value)}
|
||||||
|
placeholder={
|
||||||
|
createRuntime === 'openclaw' ? 'research-agent' : 'Review bot'
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && canCreate) onCreate()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="agent-runtime">Adapter</Label>
|
||||||
|
<Select
|
||||||
|
value={createRuntime}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (
|
||||||
|
value === 'openclaw' ||
|
||||||
|
value === 'claude' ||
|
||||||
|
value === 'codex'
|
||||||
|
) {
|
||||||
|
onRuntimeChange(value)
|
||||||
|
if (value !== 'openclaw') onHarnessAdapterChange(value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="agent-runtime">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{adapters.map((adapter) => (
|
||||||
|
<SelectItem key={adapter.id} value={adapter.id}>
|
||||||
|
{adapter.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createRuntime === 'openclaw' ? (
|
||||||
|
<>
|
||||||
|
{openClawBlocked ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>OpenClaw is not ready</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Start or set up the OpenClaw gateway before creating an
|
||||||
|
OpenClaw agent.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ProviderSelector
|
||||||
|
providers={providers}
|
||||||
|
defaultProviderId={defaultProviderId}
|
||||||
|
selectedId={selectedProviderId}
|
||||||
|
onSelect={onProviderChange}
|
||||||
|
hideApiKeyHint={!!selectedCliProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedCliProvider ? (
|
||||||
|
<OpenClawCliProviderStatusPanel
|
||||||
|
provider={selectedCliProvider}
|
||||||
|
status={cliAuthStatus}
|
||||||
|
loading={cliAuthLoading}
|
||||||
|
fetchError={cliAuthError}
|
||||||
|
onConnect={onConnectCliProvider}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isHarnessRuntime ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="harness-model">Model</Label>
|
||||||
|
<Select
|
||||||
|
value={harnessModelId}
|
||||||
|
onValueChange={onHarnessModelChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="harness-model">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(selectedHarnessAdapter?.models ?? []).map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="harness-effort">Reasoning</Label>
|
||||||
|
<Select
|
||||||
|
value={harnessReasoningEffort}
|
||||||
|
onValueChange={onHarnessReasoningChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="harness-effort">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(selectedHarnessAdapter?.reasoningEfforts ?? []).map(
|
||||||
|
(effort) => (
|
||||||
|
<SelectItem key={effort.id} value={effort.id}>
|
||||||
|
{effort.label}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!canCreate} onClick={onCreate}>
|
||||||
|
{creating ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Cpu,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldAlert,
|
||||||
|
Square,
|
||||||
|
TerminalSquare,
|
||||||
|
WifiOff,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import type { ProviderOption } from './agents-page-types'
|
||||||
|
import {
|
||||||
|
CONTROL_PLANE_COPY,
|
||||||
|
FALLBACK_CONTROL_PLANE_COPY,
|
||||||
|
} from './agents-page-types'
|
||||||
|
import type { getControlPlaneCopy } from './agents-page-utils'
|
||||||
|
import type { OpenClawStatus } from './useOpenClaw'
|
||||||
|
|
||||||
|
const StatusBadge: FC<{ status: OpenClawStatus['status'] }> = ({ status }) => {
|
||||||
|
const variants: Record<
|
||||||
|
OpenClawStatus['status'],
|
||||||
|
{
|
||||||
|
variant: 'default' | 'secondary' | 'outline' | 'destructive'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
running: { variant: 'default', label: 'Running' },
|
||||||
|
starting: { variant: 'secondary', label: 'Starting...' },
|
||||||
|
stopped: { variant: 'outline', label: 'Stopped' },
|
||||||
|
error: { variant: 'destructive', label: 'Error' },
|
||||||
|
uninitialized: { variant: 'outline', label: 'Not Set Up' },
|
||||||
|
}
|
||||||
|
const current = variants[status] ?? {
|
||||||
|
variant: 'outline' as const,
|
||||||
|
label: 'Unknown',
|
||||||
|
}
|
||||||
|
return <Badge variant={current.variant}>{current.label}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlPlaneBadge: FC<{
|
||||||
|
status: OpenClawStatus['controlPlaneStatus']
|
||||||
|
}> = ({ status }) => {
|
||||||
|
const current = CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
|
||||||
|
return <Badge variant={current.badgeVariant}>{current.badgeLabel}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderSelectorProps {
|
||||||
|
providers: ProviderOption[]
|
||||||
|
defaultProviderId: string
|
||||||
|
selectedId: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
hideApiKeyHint?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||||
|
providers,
|
||||||
|
defaultProviderId,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
hideApiKeyHint,
|
||||||
|
}) => {
|
||||||
|
if (providers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium text-sm">LLM Provider</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No compatible LLM providers configured.{' '}
|
||||||
|
<a href="#/settings/ai" className="underline">
|
||||||
|
Add one in AI settings
|
||||||
|
</a>{' '}
|
||||||
|
first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-select">LLM Provider</Label>
|
||||||
|
<Select value={selectedId} onValueChange={onSelect}>
|
||||||
|
<SelectTrigger id="provider-select">
|
||||||
|
<SelectValue placeholder="Select a provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<SelectItem key={provider.id} value={provider.id}>
|
||||||
|
{provider.name} - {provider.modelId}
|
||||||
|
{provider.id === defaultProviderId ? ' (default)' : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!hideApiKeyHint && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Uses your existing API key from BrowserOS settings. The key is passed
|
||||||
|
to the container and never leaves your machine.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentsPageHeaderProps {
|
||||||
|
actionInProgress: boolean
|
||||||
|
controlPlaneBusy: boolean
|
||||||
|
reconnecting: boolean
|
||||||
|
status: OpenClawStatus | null
|
||||||
|
onCreateAgent: () => void
|
||||||
|
onOpenTerminal: () => void
|
||||||
|
onReconnect: () => void
|
||||||
|
onRefresh: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
onStop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentsPageHeader: FC<AgentsPageHeaderProps> = ({
|
||||||
|
actionInProgress,
|
||||||
|
controlPlaneBusy,
|
||||||
|
reconnecting,
|
||||||
|
status,
|
||||||
|
onCreateAgent,
|
||||||
|
onOpenTerminal,
|
||||||
|
onReconnect,
|
||||||
|
onRefresh,
|
||||||
|
onRestart,
|
||||||
|
onStop,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-semibold text-2xl tracking-normal">Agents</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
OpenClaw, Claude Code, and Codex agents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{status ? (
|
||||||
|
<>
|
||||||
|
<StatusBadge status={status.status} />
|
||||||
|
{status.status !== 'uninitialized' && (
|
||||||
|
<ControlPlaneBadge status={status.controlPlaneStatus} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status?.status === 'running' &&
|
||||||
|
status.controlPlaneStatus !== 'connected' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onReconnect}
|
||||||
|
disabled={actionInProgress || controlPlaneBusy}
|
||||||
|
>
|
||||||
|
{reconnecting ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Retry Connection
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status?.status === 'running' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRestart}
|
||||||
|
disabled={actionInProgress}
|
||||||
|
title="Restart gateway"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onStop}
|
||||||
|
disabled={actionInProgress}
|
||||||
|
title="Stop gateway"
|
||||||
|
>
|
||||||
|
<Square className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={onOpenTerminal}>
|
||||||
|
<TerminalSquare className="mr-2 size-4" />
|
||||||
|
Terminal
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" onClick={onRefresh} title="Refresh">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onCreateAgent}>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
New Agent
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export function LifecycleAlert({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<AlertTitle>{message}</AlertTitle>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineErrorAlert({
|
||||||
|
message,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
message: string
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>Agent action failed</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{message}</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onDismiss}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlPlaneAlertProps {
|
||||||
|
actionInProgress: boolean
|
||||||
|
controlPlaneBusy: boolean
|
||||||
|
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
|
||||||
|
reconnecting: boolean
|
||||||
|
recoveryDetail: string | null
|
||||||
|
status: OpenClawStatus
|
||||||
|
onReconnect: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
|
||||||
|
actionInProgress,
|
||||||
|
controlPlaneBusy,
|
||||||
|
controlPlaneCopy,
|
||||||
|
reconnecting,
|
||||||
|
recoveryDetail,
|
||||||
|
status,
|
||||||
|
onReconnect,
|
||||||
|
onRestart,
|
||||||
|
}) => (
|
||||||
|
<Alert
|
||||||
|
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
|
||||||
|
>
|
||||||
|
{status.controlPlaneStatus === 'failed' ? (
|
||||||
|
<ShieldAlert className="size-4" />
|
||||||
|
) : status.controlPlaneStatus === 'recovering' ? (
|
||||||
|
<Wrench className="size-4" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="size-4" />
|
||||||
|
)}
|
||||||
|
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{controlPlaneCopy.description}</p>
|
||||||
|
{recoveryDetail ? <p>{recoveryDetail}</p> : null}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReconnect}
|
||||||
|
disabled={actionInProgress || controlPlaneBusy}
|
||||||
|
>
|
||||||
|
{reconnecting ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Retry Connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRestart}
|
||||||
|
disabled={actionInProgress}
|
||||||
|
>
|
||||||
|
Restart Gateway
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface GatewayStateCardsProps {
|
||||||
|
actionInProgress: boolean
|
||||||
|
status: OpenClawStatus | null
|
||||||
|
onOpenSetup: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
onStart: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GatewayStateCards: FC<GatewayStateCardsProps> = ({
|
||||||
|
actionInProgress,
|
||||||
|
status,
|
||||||
|
onOpenSetup,
|
||||||
|
onRestart,
|
||||||
|
onStart,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{status?.status === 'uninitialized' ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||||
|
<Cpu className="size-12 text-muted-foreground" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{status.podmanAvailable
|
||||||
|
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
|
||||||
|
: 'BrowserOS VM runtime is unavailable on this system.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{status.podmanAvailable ? (
|
||||||
|
<Button onClick={onOpenSetup}>Set Up Now</Button>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status?.status === 'stopped' ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||||
|
<Cpu className="size-12 text-muted-foreground" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The OpenClaw gateway is not running.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onStart} disabled={actionInProgress}>
|
||||||
|
Start Gateway
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status?.status === 'error' ? (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||||
|
<AlertCircle className="size-12 text-destructive" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="font-semibold text-lg">Gateway Error</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{status.error ?? status.lastGatewayError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={onStart} disabled={actionInProgress}>
|
||||||
|
Start Gateway
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRestart}
|
||||||
|
disabled={actionInProgress}
|
||||||
|
>
|
||||||
|
Restart Gateway
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import type { ProviderOption } from './agents-page-types'
|
||||||
|
import { ProviderSelector } from './OpenClawControls'
|
||||||
|
import type { OpenClawCliProvider } from './openclaw-cli-providers'
|
||||||
|
|
||||||
|
interface SetupOpenClawDialogProps {
|
||||||
|
defaultProviderId: string
|
||||||
|
open: boolean
|
||||||
|
providers: ProviderOption[]
|
||||||
|
selectedProviderId: string
|
||||||
|
selectedCliProvider: OpenClawCliProvider | undefined
|
||||||
|
settingUp: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onProviderChange: (providerId: string) => void
|
||||||
|
onSetup: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SetupOpenClawDialog: FC<SetupOpenClawDialogProps> = ({
|
||||||
|
defaultProviderId,
|
||||||
|
open,
|
||||||
|
providers,
|
||||||
|
selectedProviderId,
|
||||||
|
selectedCliProvider,
|
||||||
|
settingUp,
|
||||||
|
onOpenChange,
|
||||||
|
onProviderChange,
|
||||||
|
onSetup,
|
||||||
|
}) => (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set Up OpenClaw</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<ProviderSelector
|
||||||
|
providers={providers}
|
||||||
|
defaultProviderId={defaultProviderId}
|
||||||
|
selectedId={selectedProviderId}
|
||||||
|
onSelect={onProviderChange}
|
||||||
|
hideApiKeyHint={!!selectedCliProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedCliProvider ? (
|
||||||
|
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 text-muted-foreground text-xs">
|
||||||
|
{selectedCliProvider.description}. Clicking{' '}
|
||||||
|
<span className="font-medium">Set Up & Start</span> starts the
|
||||||
|
gateway and opens a terminal to sign in.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onSetup}
|
||||||
|
disabled={settingUp || providers.length === 0}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{settingUp ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
Setting up...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Set Up & Start'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function buildAgentApiUrl(baseUrl: string, path: string): string {
|
||||||
|
const normalizedPath = path === '/' ? '' : path
|
||||||
|
return `${baseUrl}/agents${normalizedPath}`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { AgentEntry } from './useOpenClaw'
|
||||||
|
|
||||||
|
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
|
||||||
|
|
||||||
|
export type AgentHarnessStreamEvent =
|
||||||
|
| {
|
||||||
|
type: 'text_delta'
|
||||||
|
text: string
|
||||||
|
stream: 'output' | 'thought'
|
||||||
|
rawType?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tool_call'
|
||||||
|
text: string
|
||||||
|
title: string
|
||||||
|
id?: string
|
||||||
|
status?: string
|
||||||
|
rawType?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'status'
|
||||||
|
text: string
|
||||||
|
rawType?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'done'
|
||||||
|
text?: string
|
||||||
|
stopReason?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'error'
|
||||||
|
message: string
|
||||||
|
code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessAgentLiveness = 'working' | 'idle' | 'asleep' | 'error'
|
||||||
|
|
||||||
|
export interface HarnessAgent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
adapter: HarnessAgentAdapter
|
||||||
|
modelId?: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
permissionMode: 'approve-all'
|
||||||
|
sessionKey: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
/**
|
||||||
|
* Server-derived liveness state. When the listing endpoint hasn't
|
||||||
|
* been enriched yet (older deployments) this is undefined and the UI
|
||||||
|
* falls back to `unknown`.
|
||||||
|
*/
|
||||||
|
status?: HarnessAgentLiveness
|
||||||
|
/**
|
||||||
|
* Wall-clock ms of the last persisted turn. `null` for never-used
|
||||||
|
* agents. Drives the recency sort and the "Last used X min ago" copy.
|
||||||
|
*/
|
||||||
|
lastUsedAt?: number | null
|
||||||
|
/** Pinned agents float to the top of the list. Defaults to `false`. */
|
||||||
|
pinned?: boolean
|
||||||
|
/** First non-blank line of the most recent user message; null if none. */
|
||||||
|
lastUserMessage?: string | null
|
||||||
|
/** Working directory the agent runs in; null when no session record yet. */
|
||||||
|
cwd?: string | null
|
||||||
|
/** Cumulative + 7-day rolling token usage; null when no record. */
|
||||||
|
tokens?: {
|
||||||
|
last7d: { input: number; output: number; requestCount: number }
|
||||||
|
cumulative: { input: number; output: number }
|
||||||
|
} | null
|
||||||
|
turnsByDay?: number[]
|
||||||
|
failedByDay?: number[]
|
||||||
|
lastError?: string | null
|
||||||
|
lastErrorAt?: number | null
|
||||||
|
/** When non-null, an in-flight turn this row can be resumed from. */
|
||||||
|
activeTurnId?: string | null
|
||||||
|
/** Persistent FIFO queue of messages waiting for this agent. */
|
||||||
|
queue?: HarnessQueuedMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessQueuedMessageAttachment {
|
||||||
|
mediaType: string
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessQueuedMessage {
|
||||||
|
id: string
|
||||||
|
createdAt: number
|
||||||
|
message: string
|
||||||
|
attachments?: ReadonlyArray<HarnessQueuedMessageAttachment>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessAdapterHealth {
|
||||||
|
healthy: boolean
|
||||||
|
reason?: string
|
||||||
|
checkedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessAdapterDescriptor {
|
||||||
|
id: HarnessAgentAdapter
|
||||||
|
name: string
|
||||||
|
defaultModelId: string
|
||||||
|
defaultReasoningEffort: string
|
||||||
|
modelControl: 'runtime-supported' | 'best-effort'
|
||||||
|
models: Array<{ id: string; label: string; recommended?: boolean }>
|
||||||
|
reasoningEfforts: Array<{ id: string; label: string; recommended?: boolean }>
|
||||||
|
health?: HarnessAdapterHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHarnessAgentInput {
|
||||||
|
name: string
|
||||||
|
adapter: HarnessAgentAdapter
|
||||||
|
modelId?: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessHistoryReasoning {
|
||||||
|
text: string
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessHistoryToolCall {
|
||||||
|
toolCallId?: string
|
||||||
|
toolName: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
input?: unknown
|
||||||
|
output?: unknown
|
||||||
|
error?: string
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessHistoryEntry {
|
||||||
|
id: string
|
||||||
|
agentId: string
|
||||||
|
sessionId: 'main'
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
text: string
|
||||||
|
createdAt: number
|
||||||
|
reasoning?: HarnessHistoryReasoning
|
||||||
|
toolCalls?: HarnessHistoryToolCall[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessAgentHistoryPage {
|
||||||
|
agentId: string
|
||||||
|
sessionId: 'main'
|
||||||
|
items: HarnessHistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapHarnessAgentToEntry(agent: HarnessAgent): AgentEntry {
|
||||||
|
return {
|
||||||
|
agentId: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
workspace: `${agent.adapter}:main`,
|
||||||
|
model: agent.modelId,
|
||||||
|
source: 'agent-harness',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { NavigateFunction } from 'react-router'
|
||||||
|
import {
|
||||||
|
AGENT_CREATED_EVENT,
|
||||||
|
AGENT_DELETED_EVENT,
|
||||||
|
} from '@/lib/constants/analyticsEvents'
|
||||||
|
import { track } from '@/lib/metrics/track'
|
||||||
|
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
|
||||||
|
import type {
|
||||||
|
AgentListItem,
|
||||||
|
CreateAgentRuntime,
|
||||||
|
ProviderOption,
|
||||||
|
} from './agents-page-types'
|
||||||
|
import { findOpenClawCliProviderById } from './openclaw-cli-providers'
|
||||||
|
import type {
|
||||||
|
AgentEntry,
|
||||||
|
OpenClawAgentMutationInput,
|
||||||
|
OpenClawSetupInput,
|
||||||
|
} from './useOpenClaw'
|
||||||
|
|
||||||
|
export interface AgentPageActionInput {
|
||||||
|
createProviderId: string
|
||||||
|
createRuntime: CreateAgentRuntime
|
||||||
|
harnessModelId: string
|
||||||
|
harnessReasoningEffort: string
|
||||||
|
navigate: NavigateFunction
|
||||||
|
newName: string
|
||||||
|
selectableOpenClawProviders: ProviderOption[]
|
||||||
|
setupProviderId: string
|
||||||
|
createHarnessAgent: (input: {
|
||||||
|
name: string
|
||||||
|
adapter: HarnessAgentAdapter
|
||||||
|
modelId?: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
}) => Promise<HarnessAgent>
|
||||||
|
createOpenClawAgent: (
|
||||||
|
input: OpenClawAgentMutationInput,
|
||||||
|
) => Promise<{ agent: AgentEntry }>
|
||||||
|
deleteHarnessAgent: (agentId: string) => Promise<unknown>
|
||||||
|
deleteOpenClawAgent: (agentId: string) => Promise<unknown>
|
||||||
|
setCliAuthModalOpen: (open: boolean) => void
|
||||||
|
setCreateError: (error: string | null) => void
|
||||||
|
setCreateOpen: (open: boolean) => void
|
||||||
|
setDeletingAgentKey: (key: string | null) => void
|
||||||
|
setNewName: (name: string) => void
|
||||||
|
setPageError: (error: string | null) => void
|
||||||
|
setSetupOpen: (open: boolean) => void
|
||||||
|
setupOpenClaw: (input: OpenClawSetupInput) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAgentPageActions(input: AgentPageActionInput) {
|
||||||
|
const runWithPageErrorHandling = async (fn: () => Promise<unknown>) => {
|
||||||
|
input.setPageError(null)
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} catch (err) {
|
||||||
|
input.setPageError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetup = async () => {
|
||||||
|
const option = input.selectableOpenClawProviders.find(
|
||||||
|
(item) => item.id === input.setupProviderId,
|
||||||
|
)
|
||||||
|
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
|
||||||
|
const llmOption = !isCli && option ? option : undefined
|
||||||
|
|
||||||
|
await runWithPageErrorHandling(async () => {
|
||||||
|
await input.setupOpenClaw({
|
||||||
|
providerType: option?.type,
|
||||||
|
providerName: isCli ? undefined : option?.name,
|
||||||
|
baseUrl: llmOption?.baseUrl,
|
||||||
|
apiKey: llmOption?.apiKey,
|
||||||
|
modelId: option?.modelId,
|
||||||
|
})
|
||||||
|
input.setSetupOpen(false)
|
||||||
|
if (isCli) input.setCliAuthModalOpen(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenClawCreate = async () => {
|
||||||
|
if (!input.newName.trim()) return
|
||||||
|
const option = input.selectableOpenClawProviders.find(
|
||||||
|
(item) => item.id === input.createProviderId,
|
||||||
|
)
|
||||||
|
const normalizedName = input.newName
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
|
||||||
|
const llmOption = !isCli && option ? option : undefined
|
||||||
|
|
||||||
|
input.setCreateError(null)
|
||||||
|
try {
|
||||||
|
const result = await input.createOpenClawAgent({
|
||||||
|
name: normalizedName,
|
||||||
|
providerType: option?.type,
|
||||||
|
providerName: isCli ? undefined : option?.name,
|
||||||
|
baseUrl: llmOption?.baseUrl,
|
||||||
|
apiKey: llmOption?.apiKey,
|
||||||
|
modelId: option?.modelId,
|
||||||
|
})
|
||||||
|
input.setCreateOpen(false)
|
||||||
|
input.setNewName('')
|
||||||
|
track(AGENT_CREATED_EVENT, {
|
||||||
|
runtime: 'openclaw',
|
||||||
|
provider_type: option?.type,
|
||||||
|
})
|
||||||
|
input.navigate(`/agents/${result.agent.agentId}`)
|
||||||
|
} catch (err) {
|
||||||
|
input.setCreateError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHarnessCreate = async () => {
|
||||||
|
if (!input.newName.trim()) return
|
||||||
|
|
||||||
|
input.setCreateError(null)
|
||||||
|
try {
|
||||||
|
const agent = await input.createHarnessAgent({
|
||||||
|
name: input.newName.trim(),
|
||||||
|
adapter: input.createRuntime as HarnessAgentAdapter,
|
||||||
|
modelId: input.harnessModelId || undefined,
|
||||||
|
reasoningEffort: input.harnessReasoningEffort || undefined,
|
||||||
|
})
|
||||||
|
input.setCreateOpen(false)
|
||||||
|
input.setNewName('')
|
||||||
|
track(AGENT_CREATED_EVENT, {
|
||||||
|
runtime: input.createRuntime,
|
||||||
|
model_id: input.harnessModelId || undefined,
|
||||||
|
reasoning_effort: input.harnessReasoningEffort || undefined,
|
||||||
|
})
|
||||||
|
input.navigate(`/agents/${agent.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
input.setCreateError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
const createByRuntime: Record<CreateAgentRuntime, () => Promise<void>> = {
|
||||||
|
openclaw: handleOpenClawCreate,
|
||||||
|
claude: handleHarnessCreate,
|
||||||
|
codex: handleHarnessCreate,
|
||||||
|
}
|
||||||
|
void createByRuntime[input.createRuntime]()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (agent: AgentListItem) => {
|
||||||
|
input.setDeletingAgentKey(agent.key)
|
||||||
|
await runWithPageErrorHandling(async () => {
|
||||||
|
const deleteBySource: Record<
|
||||||
|
AgentListItem['source'],
|
||||||
|
(agentId: string) => Promise<unknown>
|
||||||
|
> = {
|
||||||
|
openclaw: (agentId) => input.deleteOpenClawAgent(agentId),
|
||||||
|
'agent-harness': (agentId) => input.deleteHarnessAgent(agentId),
|
||||||
|
}
|
||||||
|
await deleteBySource[agent.source](agent.agentId)
|
||||||
|
track(AGENT_DELETED_EVENT, {
|
||||||
|
runtime: agent.source,
|
||||||
|
agent_id: agent.agentId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
input.setDeletingAgentKey(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleCreate,
|
||||||
|
handleDelete,
|
||||||
|
handleSetup,
|
||||||
|
runWithPageErrorHandling,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { type Dispatch, type SetStateAction, useEffect, useMemo } from 'react'
|
||||||
|
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||||
|
import type {
|
||||||
|
HarnessAdapterDescriptor,
|
||||||
|
HarnessAgentAdapter,
|
||||||
|
} from './agent-harness-types'
|
||||||
|
import type { CreateAgentRuntime } from './agents-page-types'
|
||||||
|
import { toProviderOptions } from './agents-page-utils'
|
||||||
|
import {
|
||||||
|
buildOpenClawCliProviderOptions,
|
||||||
|
findOpenClawCliProviderById,
|
||||||
|
useOpenClawCliProviderAuthStatus,
|
||||||
|
} from './openclaw-cli-providers'
|
||||||
|
|
||||||
|
export function useDefaultAgentName(
|
||||||
|
createOpen: boolean,
|
||||||
|
setNewName: Dispatch<SetStateAction<string>>,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createOpen) return
|
||||||
|
setNewName((current) => current || 'agent')
|
||||||
|
}, [createOpen, setNewName])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHarnessAgentDefaults(input: {
|
||||||
|
adapters: HarnessAdapterDescriptor[]
|
||||||
|
createOpen: boolean
|
||||||
|
harnessAdapterId: HarnessAgentAdapter
|
||||||
|
setHarnessAdapterId: Dispatch<SetStateAction<HarnessAgentAdapter>>
|
||||||
|
setHarnessModelId: Dispatch<SetStateAction<string>>
|
||||||
|
setHarnessReasoningEffort: Dispatch<SetStateAction<string>>
|
||||||
|
}): void {
|
||||||
|
const {
|
||||||
|
adapters,
|
||||||
|
createOpen,
|
||||||
|
harnessAdapterId,
|
||||||
|
setHarnessAdapterId,
|
||||||
|
setHarnessModelId,
|
||||||
|
setHarnessReasoningEffort,
|
||||||
|
} = input
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createOpen) return
|
||||||
|
const adapter =
|
||||||
|
adapters.find((entry) => entry.id === harnessAdapterId) ?? adapters[0]
|
||||||
|
if (!adapter) return
|
||||||
|
setHarnessAdapterId(adapter.id)
|
||||||
|
setHarnessModelId((current) => current || adapter.defaultModelId)
|
||||||
|
setHarnessReasoningEffort(
|
||||||
|
(current) => current || adapter.defaultReasoningEffort,
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
adapters,
|
||||||
|
createOpen,
|
||||||
|
harnessAdapterId,
|
||||||
|
setHarnessAdapterId,
|
||||||
|
setHarnessModelId,
|
||||||
|
setHarnessReasoningEffort,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenClawProviderSelection(input: {
|
||||||
|
providers: LlmProviderConfig[]
|
||||||
|
defaultProviderId: string
|
||||||
|
createOpen: boolean
|
||||||
|
createRuntime: CreateAgentRuntime
|
||||||
|
createProviderId: string
|
||||||
|
setCreateProviderId: Dispatch<SetStateAction<string>>
|
||||||
|
setupOpen: boolean
|
||||||
|
setupProviderId: string
|
||||||
|
setSetupProviderId: Dispatch<SetStateAction<string>>
|
||||||
|
cliAuthModalOpen: boolean
|
||||||
|
setCliAuthModalOpen: Dispatch<SetStateAction<boolean>>
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
providers,
|
||||||
|
defaultProviderId,
|
||||||
|
createOpen,
|
||||||
|
createRuntime,
|
||||||
|
createProviderId,
|
||||||
|
setCreateProviderId,
|
||||||
|
setupOpen,
|
||||||
|
setupProviderId,
|
||||||
|
setSetupProviderId,
|
||||||
|
cliAuthModalOpen,
|
||||||
|
setCliAuthModalOpen,
|
||||||
|
} = input
|
||||||
|
const cliProviderOptions = useMemo(
|
||||||
|
() => buildOpenClawCliProviderOptions(),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const selectableOpenClawProviders = useMemo(
|
||||||
|
() => toProviderOptions(providers, cliProviderOptions),
|
||||||
|
[providers, cliProviderOptions],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectableOpenClawProviders.length === 0) return
|
||||||
|
const fallbackId =
|
||||||
|
selectableOpenClawProviders.find(
|
||||||
|
(provider) => provider.id === defaultProviderId,
|
||||||
|
)?.id ?? selectableOpenClawProviders[0].id
|
||||||
|
|
||||||
|
if (createOpen && !createProviderId) {
|
||||||
|
setCreateProviderId(fallbackId)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
createOpen,
|
||||||
|
createProviderId,
|
||||||
|
defaultProviderId,
|
||||||
|
selectableOpenClawProviders,
|
||||||
|
setCreateProviderId,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectableOpenClawProviders.length === 0) return
|
||||||
|
const fallbackId =
|
||||||
|
selectableOpenClawProviders.find(
|
||||||
|
(provider) => provider.id === defaultProviderId,
|
||||||
|
)?.id ?? selectableOpenClawProviders[0].id
|
||||||
|
|
||||||
|
if (setupOpen && !setupProviderId) {
|
||||||
|
setSetupProviderId(fallbackId)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
defaultProviderId,
|
||||||
|
selectableOpenClawProviders,
|
||||||
|
setSetupProviderId,
|
||||||
|
setupOpen,
|
||||||
|
setupProviderId,
|
||||||
|
])
|
||||||
|
|
||||||
|
const selectedCreateOption = selectableOpenClawProviders.find(
|
||||||
|
(provider) => provider.id === createProviderId,
|
||||||
|
)
|
||||||
|
const selectedCliProvider = selectedCreateOption
|
||||||
|
? findOpenClawCliProviderById(selectedCreateOption.type)
|
||||||
|
: undefined
|
||||||
|
const selectedSetupOption = selectableOpenClawProviders.find(
|
||||||
|
(provider) => provider.id === setupProviderId,
|
||||||
|
)
|
||||||
|
const selectedSetupCliProvider = selectedSetupOption
|
||||||
|
? findOpenClawCliProviderById(selectedSetupOption.type)
|
||||||
|
: undefined
|
||||||
|
const activeCliProvider =
|
||||||
|
(setupOpen && selectedSetupCliProvider) ||
|
||||||
|
(createOpen && createRuntime === 'openclaw' && selectedCliProvider) ||
|
||||||
|
undefined
|
||||||
|
const {
|
||||||
|
data: cliAuthStatus,
|
||||||
|
isLoading: cliAuthLoading,
|
||||||
|
error: cliAuthError,
|
||||||
|
} = useOpenClawCliProviderAuthStatus(
|
||||||
|
activeCliProvider?.id ?? '',
|
||||||
|
!!activeCliProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cliAuthModalOpen && cliAuthStatus?.loggedIn) {
|
||||||
|
setCliAuthModalOpen(false)
|
||||||
|
}
|
||||||
|
}, [cliAuthModalOpen, cliAuthStatus?.loggedIn, setCliAuthModalOpen])
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectableOpenClawProviders,
|
||||||
|
selectedCliProvider,
|
||||||
|
selectedSetupCliProvider,
|
||||||
|
authTerminalProvider: selectedSetupCliProvider ?? selectedCliProvider,
|
||||||
|
cliAuthStatus,
|
||||||
|
cliAuthLoading,
|
||||||
|
cliAuthError,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { HarnessAgentAdapter } from './agent-harness-types'
|
||||||
|
import type { GatewayLifecycleAction, OpenClawStatus } from './useOpenClaw'
|
||||||
|
|
||||||
|
export type CreateAgentRuntime = 'openclaw' | HarnessAgentAdapter
|
||||||
|
|
||||||
|
export interface ProviderOption {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
modelId: string
|
||||||
|
baseUrl?: string
|
||||||
|
apiKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentListItem {
|
||||||
|
key: string
|
||||||
|
agentId: string
|
||||||
|
name: string
|
||||||
|
source: 'openclaw' | 'agent-harness'
|
||||||
|
runtimeLabel: string
|
||||||
|
modelLabel: string
|
||||||
|
detail: string
|
||||||
|
canChat: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayUiState {
|
||||||
|
canManageAgents: boolean
|
||||||
|
controlPlaneDegraded: boolean
|
||||||
|
controlPlaneBusy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HARNESS_ADAPTER: HarnessAgentAdapter = 'claude'
|
||||||
|
export const DEFAULT_CREATE_RUNTIME: CreateAgentRuntime = 'openclaw'
|
||||||
|
|
||||||
|
export const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
|
||||||
|
setup: 'Setting up OpenClaw...',
|
||||||
|
start: 'Starting gateway...',
|
||||||
|
stop: 'Stopping gateway...',
|
||||||
|
restart: 'Restarting gateway...',
|
||||||
|
reconnect: 'Restoring gateway connection...',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTROL_PLANE_COPY: Record<
|
||||||
|
OpenClawStatus['controlPlaneStatus'],
|
||||||
|
{
|
||||||
|
badgeVariant: 'default' | 'secondary' | 'outline' | 'destructive'
|
||||||
|
badgeLabel: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
connected: {
|
||||||
|
badgeVariant: 'default',
|
||||||
|
badgeLabel: 'Control Plane Ready',
|
||||||
|
title: 'Gateway Connected',
|
||||||
|
description: 'OpenClaw can create, manage, and chat with agents normally.',
|
||||||
|
},
|
||||||
|
connecting: {
|
||||||
|
badgeVariant: 'secondary',
|
||||||
|
badgeLabel: 'Connecting',
|
||||||
|
title: 'Connecting to Gateway',
|
||||||
|
description:
|
||||||
|
'BrowserOS is establishing the OpenClaw control channel for agent operations.',
|
||||||
|
},
|
||||||
|
reconnecting: {
|
||||||
|
badgeVariant: 'secondary',
|
||||||
|
badgeLabel: 'Reconnecting',
|
||||||
|
title: 'Reconnecting Control Plane',
|
||||||
|
description:
|
||||||
|
'The gateway process is up, but BrowserOS is restoring the control channel.',
|
||||||
|
},
|
||||||
|
recovering: {
|
||||||
|
badgeVariant: 'secondary',
|
||||||
|
badgeLabel: 'Recovering',
|
||||||
|
title: 'Recovering Gateway Connection',
|
||||||
|
description:
|
||||||
|
'BrowserOS detected a control-plane fault and is trying a safe recovery path.',
|
||||||
|
},
|
||||||
|
disconnected: {
|
||||||
|
badgeVariant: 'outline',
|
||||||
|
badgeLabel: 'Disconnected',
|
||||||
|
title: 'Gateway Disconnected',
|
||||||
|
description: 'The gateway process is not available to BrowserOS right now.',
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
badgeVariant: 'destructive',
|
||||||
|
badgeLabel: 'Needs Attention',
|
||||||
|
title: 'Gateway Recovery Failed',
|
||||||
|
description:
|
||||||
|
'BrowserOS could not restore the OpenClaw control channel automatically.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FALLBACK_CONTROL_PLANE_COPY = {
|
||||||
|
badgeVariant: 'outline' as const,
|
||||||
|
badgeLabel: 'Unknown',
|
||||||
|
title: 'Gateway State Unknown',
|
||||||
|
description:
|
||||||
|
'BrowserOS received a gateway status it does not recognize yet. Refreshing or reconnecting should restore a known state.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECOVERY_REASON_COPY: Record<
|
||||||
|
NonNullable<OpenClawStatus['lastRecoveryReason']>,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
transient_disconnect:
|
||||||
|
'The control channel dropped briefly and BrowserOS is retrying it.',
|
||||||
|
signature_expired:
|
||||||
|
'The gateway rejected the signed device handshake because its clock drifted.',
|
||||||
|
pairing_required:
|
||||||
|
'The gateway asked BrowserOS to approve its local device identity again.',
|
||||||
|
token_mismatch:
|
||||||
|
'BrowserOS had to reload the gateway token before reconnecting.',
|
||||||
|
container_not_ready:
|
||||||
|
'The OpenClaw gateway process is not ready yet, so control-plane recovery cannot start.',
|
||||||
|
unknown:
|
||||||
|
'BrowserOS hit an unexpected gateway error and could not classify it cleanly.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||||
|
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
|
||||||
|
import {
|
||||||
|
type AgentListItem,
|
||||||
|
CONTROL_PLANE_COPY,
|
||||||
|
FALLBACK_CONTROL_PLANE_COPY,
|
||||||
|
type GatewayUiState,
|
||||||
|
LIFECYCLE_BANNER_COPY,
|
||||||
|
type ProviderOption,
|
||||||
|
RECOVERY_REASON_COPY,
|
||||||
|
} from './agents-page-types'
|
||||||
|
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
|
||||||
|
import {
|
||||||
|
type AgentEntry,
|
||||||
|
type GatewayLifecycleAction,
|
||||||
|
getModelDisplayName,
|
||||||
|
type OpenClawStatus,
|
||||||
|
} from './useOpenClaw'
|
||||||
|
|
||||||
|
export function getControlPlaneCopy(
|
||||||
|
status: OpenClawStatus['controlPlaneStatus'],
|
||||||
|
) {
|
||||||
|
return CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecoveryDetail(status: OpenClawStatus): string | null {
|
||||||
|
if (!status.lastRecoveryReason && !status.lastGatewayError) return null
|
||||||
|
|
||||||
|
const detail = status.lastRecoveryReason
|
||||||
|
? RECOVERY_REASON_COPY[status.lastRecoveryReason]
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (status.lastGatewayError && detail) {
|
||||||
|
return `${detail} Latest gateway error: ${status.lastGatewayError}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.lastGatewayError ?? detail
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHarnessAdapter(adapter: HarnessAgentAdapter): string {
|
||||||
|
return adapter === 'claude' ? 'Claude Code' : 'Codex'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toProviderOptions(
|
||||||
|
providers: LlmProviderConfig[],
|
||||||
|
cliProviders: ProviderOption[],
|
||||||
|
): ProviderOption[] {
|
||||||
|
return [...getOpenClawSupportedProviders(providers), ...cliProviders]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOpenClawListItem(
|
||||||
|
agent: AgentEntry,
|
||||||
|
canManageAgents: boolean,
|
||||||
|
): AgentListItem {
|
||||||
|
return {
|
||||||
|
key: `openclaw:${agent.agentId}`,
|
||||||
|
agentId: agent.agentId,
|
||||||
|
name: agent.name,
|
||||||
|
source: 'openclaw',
|
||||||
|
runtimeLabel: 'OpenClaw',
|
||||||
|
modelLabel: getModelDisplayName(agent.model) ?? 'default',
|
||||||
|
detail: agent.workspace,
|
||||||
|
canChat: canManageAgents,
|
||||||
|
canDelete: canManageAgents && agent.agentId !== 'main',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHarnessListItem(agent: HarnessAgent): AgentListItem {
|
||||||
|
return {
|
||||||
|
key: `agent-harness:${agent.id}`,
|
||||||
|
agentId: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
source: 'agent-harness',
|
||||||
|
runtimeLabel: formatHarnessAdapter(agent.adapter),
|
||||||
|
modelLabel: agent.modelId ?? 'default',
|
||||||
|
detail: `${agent.adapter}:main`,
|
||||||
|
canChat: true,
|
||||||
|
canDelete: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGatewayUiState(
|
||||||
|
status: OpenClawStatus | null,
|
||||||
|
): GatewayUiState {
|
||||||
|
if (!status) {
|
||||||
|
return {
|
||||||
|
canManageAgents: false,
|
||||||
|
controlPlaneDegraded: false,
|
||||||
|
controlPlaneBusy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlPlaneBusy =
|
||||||
|
status.controlPlaneStatus === 'connecting' ||
|
||||||
|
status.controlPlaneStatus === 'reconnecting' ||
|
||||||
|
status.controlPlaneStatus === 'recovering'
|
||||||
|
|
||||||
|
return {
|
||||||
|
canManageAgents:
|
||||||
|
status.status === 'running' && status.controlPlaneStatus === 'connected',
|
||||||
|
controlPlaneBusy,
|
||||||
|
controlPlaneDegraded:
|
||||||
|
status.status === 'running' && status.controlPlaneStatus !== 'connected',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLifecycleBanner(
|
||||||
|
action: GatewayLifecycleAction | null,
|
||||||
|
): string | null {
|
||||||
|
return action ? LIFECYCLE_BANNER_COPY[action] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageOpenClawAgents(
|
||||||
|
state: GatewayUiState,
|
||||||
|
lifecyclePending: boolean,
|
||||||
|
): boolean {
|
||||||
|
return state.canManageAgents && !lifecyclePending
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowControlPlaneDegraded(
|
||||||
|
state: GatewayUiState,
|
||||||
|
lifecyclePending: boolean,
|
||||||
|
): boolean {
|
||||||
|
return state.controlPlaneDegraded && !lifecyclePending
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getControlPlaneCopyForStatus(status: OpenClawStatus | null) {
|
||||||
|
return status
|
||||||
|
? getControlPlaneCopy(status.controlPlaneStatus)
|
||||||
|
: FALLBACK_CONTROL_PLANE_COPY
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVisibleOpenClawAgents(
|
||||||
|
enabled: boolean,
|
||||||
|
agents: AgentEntry[],
|
||||||
|
): AgentEntry[] {
|
||||||
|
return enabled ? agents : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentsLoading(input: {
|
||||||
|
adaptersLoading: boolean
|
||||||
|
harnessAgentsLoading: boolean
|
||||||
|
openClawAgentsLoading: boolean
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
input.adaptersLoading ||
|
||||||
|
input.harnessAgentsLoading ||
|
||||||
|
input.openClawAgentsLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInlineError(input: {
|
||||||
|
lifecyclePending: boolean
|
||||||
|
pageError: string | null
|
||||||
|
openClawAgentsError: Error | null
|
||||||
|
adaptersError: Error | null
|
||||||
|
harnessAgentsError: Error | null
|
||||||
|
}): string | null {
|
||||||
|
if (input.lifecyclePending) return null
|
||||||
|
return (
|
||||||
|
input.pageError ??
|
||||||
|
input.openClawAgentsError?.message ??
|
||||||
|
input.adaptersError?.message ??
|
||||||
|
input.harnessAgentsError?.message ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { CheckCircle2, Loader2, Terminal, TriangleAlert } from 'lucide-react'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||||
|
|
||||||
|
export interface OpenClawCliProvider {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
description: string
|
||||||
|
models: readonly string[]
|
||||||
|
authLoginCommand: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenClawCliProviderAuthStatus {
|
||||||
|
installed: boolean
|
||||||
|
loggedIn: boolean
|
||||||
|
accountLabel?: string
|
||||||
|
subscriptionLabel?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenClawCliProviderOption {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
modelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_CLI_PROVIDER: OpenClawCliProvider = {
|
||||||
|
id: 'claude-cli',
|
||||||
|
displayName: 'Anthropic Claude CLI',
|
||||||
|
description: 'Uses your Claude.ai subscription via the Claude Code CLI',
|
||||||
|
models: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'],
|
||||||
|
authLoginCommand: 'claude /login',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OPENCLAW_CLI_PROVIDERS: readonly OpenClawCliProvider[] = [
|
||||||
|
CLAUDE_CLI_PROVIDER,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function findOpenClawCliProviderById(
|
||||||
|
id: string,
|
||||||
|
): OpenClawCliProvider | undefined {
|
||||||
|
return OPENCLAW_CLI_PROVIDERS.find((provider) => provider.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenClawCliProviderOptions(): OpenClawCliProviderOption[] {
|
||||||
|
return OPENCLAW_CLI_PROVIDERS.flatMap((provider) =>
|
||||||
|
provider.models.map((modelId) => ({
|
||||||
|
id: `${provider.id}/${modelId}`,
|
||||||
|
type: provider.id,
|
||||||
|
name: provider.displayName,
|
||||||
|
modelId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCliProviderAuthStatus(
|
||||||
|
baseUrl: string,
|
||||||
|
providerId: string,
|
||||||
|
): Promise<OpenClawCliProviderAuthStatus> {
|
||||||
|
const res = await fetch(`${baseUrl}/claw/providers/${providerId}/auth-status`)
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `Auth status request failed (${res.status})`
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string }
|
||||||
|
if (body.error) message = body.error
|
||||||
|
} catch {}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<OpenClawCliProviderAuthStatus>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenClawCliProviderAuthStatus(
|
||||||
|
providerId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||||
|
return useQuery<OpenClawCliProviderAuthStatus, Error>({
|
||||||
|
queryKey: ['openclaw-cli-auth', baseUrl, providerId],
|
||||||
|
queryFn: () => fetchCliProviderAuthStatus(baseUrl as string, providerId),
|
||||||
|
enabled: !!baseUrl && !urlLoading && enabled,
|
||||||
|
refetchInterval: enabled ? 2000 : false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenClawCliProviderStatusPanelProps {
|
||||||
|
provider: OpenClawCliProvider
|
||||||
|
status: OpenClawCliProviderAuthStatus | undefined
|
||||||
|
loading: boolean
|
||||||
|
fetchError: Error | null
|
||||||
|
onConnect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenClawCliProviderStatusPanel: FC<
|
||||||
|
OpenClawCliProviderStatusPanelProps
|
||||||
|
> = ({ provider, status, loading, fetchError, onConnect }) => {
|
||||||
|
// Initial fetch (no data yet).
|
||||||
|
if (loading && !status) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Checking {provider.displayName} status…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-destructive">
|
||||||
|
Could not read {provider.displayName} status
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{fetchError.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) return null
|
||||||
|
|
||||||
|
// Install failed or binary missing.
|
||||||
|
if (!status.installed) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{provider.displayName} not installed
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
The gateway will try to install it on the next restart. If this
|
||||||
|
persists, check your network and the gateway logs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Happy path.
|
||||||
|
if (status.loggedIn) {
|
||||||
|
const identityBits = [
|
||||||
|
status.accountLabel,
|
||||||
|
status.subscriptionLabel ? `(${status.subscriptionLabel})` : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
const identity = identityBits.length > 0 ? identityBits.join(' ') : 'Ready'
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/5 px-3 py-2 text-sm">
|
||||||
|
<CheckCircle2 className="size-4 text-emerald-600" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium">Connected to {provider.displayName}</div>
|
||||||
|
<div className="truncate text-muted-foreground text-xs">
|
||||||
|
{identity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installed but not logged in.
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 px-3 py-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{provider.displayName} not set up</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{provider.description}
|
||||||
|
</div>
|
||||||
|
{status.error && (
|
||||||
|
<div className="mt-1 text-destructive text-xs">{status.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={onConnect} className="w-fit">
|
||||||
|
<Terminal className="mr-1 size-4" />
|
||||||
|
Connect {provider.displayName}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { buildAgentApiUrl } from './agent-api-url'
|
||||||
|
import { mapHarnessAgentToEntry } from './agent-harness-types'
|
||||||
|
|
||||||
|
describe('mapHarnessAgentToEntry', () => {
|
||||||
|
it('maps created harness agents into chat-compatible entries', () => {
|
||||||
|
expect(
|
||||||
|
mapHarnessAgentToEntry({
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'Review bot',
|
||||||
|
adapter: 'codex',
|
||||||
|
modelId: 'gpt-5.5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
permissionMode: 'approve-all',
|
||||||
|
sessionKey: 'agent:agent-1:main',
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
name: 'Review bot',
|
||||||
|
workspace: 'codex:main',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
source: 'agent-harness',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildAgentApiUrl', () => {
|
||||||
|
it('does not add a trailing slash for the harness root route', () => {
|
||||||
|
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/')).toBe(
|
||||||
|
'http://127.0.0.1:9105/agents',
|
||||||
|
)
|
||||||
|
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/adapters')).toBe(
|
||||||
|
'http://127.0.0.1:9105/agents/adapters',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||||
|
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||||
|
import { buildAgentApiUrl } from './agent-api-url'
|
||||||
|
import {
|
||||||
|
type AgentHarnessStreamEvent,
|
||||||
|
type CreateHarnessAgentInput,
|
||||||
|
type HarnessAdapterDescriptor,
|
||||||
|
type HarnessAgent,
|
||||||
|
type HarnessAgentHistoryPage,
|
||||||
|
type HarnessQueuedMessage,
|
||||||
|
mapHarnessAgentToEntry,
|
||||||
|
} from './agent-harness-types'
|
||||||
|
import type { OpenClawStatus } from './useOpenClaw'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined response shape of `GET /agents`. The page polls this once
|
||||||
|
* and consumes both fields, replacing the dedicated `/claw/status`
|
||||||
|
* poll the previous design carried.
|
||||||
|
*/
|
||||||
|
interface HarnessAgentsResponse {
|
||||||
|
agents: HarnessAgent[]
|
||||||
|
gateway: OpenClawStatus | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AgentHarnessStreamEvent }
|
||||||
|
|
||||||
|
const AGENT_QUERY_KEYS = {
|
||||||
|
adapters: 'agent-harness-adapters',
|
||||||
|
agents: 'agent-harness-agents',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
async function agentsFetch<T>(
|
||||||
|
baseUrl: string,
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(buildAgentApiUrl(baseUrl, path), init)
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `Request failed with status ${res.status}`
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string }
|
||||||
|
if (body.error) message = body.error
|
||||||
|
} catch {}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentAdapters(enabled = true) {
|
||||||
|
const {
|
||||||
|
baseUrl,
|
||||||
|
isLoading: urlLoading,
|
||||||
|
error: urlError,
|
||||||
|
} = useAgentServerUrl()
|
||||||
|
|
||||||
|
const query = useQuery<HarnessAdapterDescriptor[], Error>({
|
||||||
|
queryKey: [AGENT_QUERY_KEYS.adapters, baseUrl],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await agentsFetch<{ adapters: HarnessAdapterDescriptor[] }>(
|
||||||
|
baseUrl as string,
|
||||||
|
'/adapters',
|
||||||
|
)
|
||||||
|
return data.adapters ?? []
|
||||||
|
},
|
||||||
|
enabled: Boolean(baseUrl) && !urlLoading && enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapters: query.data ?? [],
|
||||||
|
loading: query.isLoading || urlLoading,
|
||||||
|
error: query.error ?? urlError,
|
||||||
|
refetch: query.refetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHarnessAgents(enabled = true) {
|
||||||
|
const {
|
||||||
|
baseUrl,
|
||||||
|
isLoading: urlLoading,
|
||||||
|
error: urlError,
|
||||||
|
} = useAgentServerUrl()
|
||||||
|
|
||||||
|
const query = useQuery<HarnessAgentsResponse, Error>({
|
||||||
|
queryKey: [AGENT_QUERY_KEYS.agents, baseUrl],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await agentsFetch<HarnessAgentsResponse>(
|
||||||
|
baseUrl as string,
|
||||||
|
'/',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
agents: data.agents ?? [],
|
||||||
|
gateway: data.gateway ?? null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: Boolean(baseUrl) && !urlLoading && enabled,
|
||||||
|
// Poll every 5s so the per-agent liveness state (working / idle /
|
||||||
|
// asleep / error) and last-used timestamps stay fresh without a
|
||||||
|
// websocket. `refetchIntervalInBackground: false` lets a hidden
|
||||||
|
// tab go quiet — react-query's default, made explicit.
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
agents: (query.data?.agents ?? []).map(mapHarnessAgentToEntry),
|
||||||
|
harnessAgents: query.data?.agents ?? [],
|
||||||
|
gateway: query.data?.gateway ?? null,
|
||||||
|
loading: query.isLoading || urlLoading,
|
||||||
|
error: query.error ?? urlError,
|
||||||
|
refetch: query.refetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateHarnessAgent() {
|
||||||
|
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: CreateHarnessAgentInput) => {
|
||||||
|
if (!baseUrl || urlLoading) {
|
||||||
|
throw new Error('BrowserOS agent server URL is not ready')
|
||||||
|
}
|
||||||
|
const data = await agentsFetch<{ agent: HarnessAgent }>(baseUrl, '/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
return data.agent
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a partial update to a harness agent. Used by the pin-toggle
|
||||||
|
* star and (eventually) the inline rename UI. Optimistically writes
|
||||||
|
* the patch into the listing query cache so the row updates instantly,
|
||||||
|
* then rolls back if the server rejects the change.
|
||||||
|
*/
|
||||||
|
export function useUpdateHarnessAgent() {
|
||||||
|
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: {
|
||||||
|
agentId: string
|
||||||
|
patch: { name?: string; pinned?: boolean }
|
||||||
|
}) => {
|
||||||
|
if (!baseUrl || urlLoading) {
|
||||||
|
throw new Error('BrowserOS agent server URL is not ready')
|
||||||
|
}
|
||||||
|
const data = await agentsFetch<{ agent: HarnessAgent }>(
|
||||||
|
baseUrl,
|
||||||
|
`/${encodeURIComponent(input.agentId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input.patch),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data.agent
|
||||||
|
},
|
||||||
|
onMutate: async ({ agentId, patch }) => {
|
||||||
|
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
|
||||||
|
await queryClient.cancelQueries({ queryKey })
|
||||||
|
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
|
||||||
|
if (!previous) return { previous: undefined }
|
||||||
|
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
agents: previous.agents.map((agent) =>
|
||||||
|
agent.id === agentId ? { ...agent, ...patch } : agent,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
if (!context?.previous) return
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[AGENT_QUERY_KEYS.agents, baseUrl],
|
||||||
|
context.previous,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteHarnessAgent() {
|
||||||
|
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (agentId: string) => {
|
||||||
|
if (!baseUrl || urlLoading) {
|
||||||
|
throw new Error('BrowserOS agent server URL is not ready')
|
||||||
|
}
|
||||||
|
return agentsFetch<{ success: boolean }>(
|
||||||
|
baseUrl,
|
||||||
|
`/${encodeURIComponent(agentId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatWithHarnessAgent(
|
||||||
|
agentId: string,
|
||||||
|
message: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
attachments?: ReadonlyArray<unknown>,
|
||||||
|
): Promise<Response> {
|
||||||
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
return fetch(`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an existing turn (the server's `ActiveTurnRegistry`
|
||||||
|
* decoupled the turn lifecycle from POST /chat). `lastSeq` lets the
|
||||||
|
* client resume after a disconnect — the server replays buffered
|
||||||
|
* frames with seq > lastSeq, then tails new ones.
|
||||||
|
*/
|
||||||
|
export async function attachToHarnessTurn(
|
||||||
|
agentId: string,
|
||||||
|
options: { turnId?: string; lastSeq?: number; signal?: AbortSignal } = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
const url = new URL(
|
||||||
|
`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat/stream`,
|
||||||
|
)
|
||||||
|
if (options.turnId) url.searchParams.set('turnId', options.turnId)
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (typeof options.lastSeq === 'number') {
|
||||||
|
headers['Last-Event-ID'] = String(options.lastSeq)
|
||||||
|
}
|
||||||
|
return fetch(url.toString(), { signal: options.signal, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessActiveTurnInfo {
|
||||||
|
turnId: string
|
||||||
|
agentId: string
|
||||||
|
sessionId: 'main'
|
||||||
|
status: 'running' | 'done' | 'error' | 'cancelled'
|
||||||
|
lastSeq: number
|
||||||
|
startedAt: number
|
||||||
|
endedAt?: number
|
||||||
|
/** User message that kicked off the turn; null when not captured. */
|
||||||
|
prompt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover an in-flight turn for an agent. Used on chat mount so the
|
||||||
|
* UI reattaches instead of starting a new turn after a tab/refresh.
|
||||||
|
*/
|
||||||
|
export async function fetchActiveHarnessTurn(
|
||||||
|
agentId: string,
|
||||||
|
): Promise<HarnessActiveTurnInfo | null> {
|
||||||
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat/active`,
|
||||||
|
)
|
||||||
|
if (!response.ok) return null
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
active: HarnessActiveTurnInfo | null
|
||||||
|
}
|
||||||
|
return body.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop button. Hits the explicit cancel endpoint instead of just
|
||||||
|
* aborting the fetch (which now only detaches *this* subscriber from
|
||||||
|
* the buffer; the underlying turn would otherwise keep running).
|
||||||
|
*/
|
||||||
|
export async function cancelHarnessTurn(
|
||||||
|
agentId: string,
|
||||||
|
options: { turnId?: string; reason?: string } = {},
|
||||||
|
): Promise<{ cancelled: boolean }> {
|
||||||
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat/cancel`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(options.turnId ? { turnId: options.turnId } : {}),
|
||||||
|
...(options.reason ? { reason: options.reason } : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) return { cancelled: false }
|
||||||
|
return (await response.json()) as { cancelled: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHarnessAgentHistory(
|
||||||
|
agentId: string,
|
||||||
|
): Promise<HarnessAgentHistoryPage> {
|
||||||
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
return agentsFetch<HarnessAgentHistoryPage>(
|
||||||
|
baseUrl,
|
||||||
|
`/${encodeURIComponent(agentId)}/sessions/main/history`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnqueueMessageInput {
|
||||||
|
message: string
|
||||||
|
attachments?: ReadonlyArray<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueHarnessMessage(
|
||||||
|
agentId: string,
|
||||||
|
input: EnqueueMessageInput,
|
||||||
|
): Promise<HarnessQueuedMessage> {
|
||||||
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/agents/${encodeURIComponent(agentId)}/queue`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: input.message,
|
||||||
|
...(input.attachments && input.attachments.length > 0
|
||||||
|
? { attachments: input.attachments }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Request failed with status ${response.status}`
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { error?: string }
|
||||||
|
if (body.error) message = body.error
|
||||||
|
} catch {}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { queued: HarnessQueuedMessage }
|
||||||
|
return body.queued
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeHarnessQueuedMessage(
|
||||||
|
agentId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<{ removed: boolean }> {
|
||||||
|
const baseUrl = await getAgentServerUrl()
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(
|
||||||
|
messageId,
|
||||||
|
)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
if (!response.ok) return { removed: false }
|
||||||
|
return (await response.json()) as { removed: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic enqueue: writes the new queued message into the listing
|
||||||
|
* cache immediately so the queue panel reflects the change without
|
||||||
|
* waiting for the next poll. Rolls back if the server rejects.
|
||||||
|
*/
|
||||||
|
export function useEnqueueHarnessMessage() {
|
||||||
|
const { baseUrl } = useAgentServerUrl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { agentId: string } & EnqueueMessageInput) =>
|
||||||
|
enqueueHarnessMessage(input.agentId, input),
|
||||||
|
onMutate: async (input) => {
|
||||||
|
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
|
||||||
|
await queryClient.cancelQueries({ queryKey })
|
||||||
|
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
|
||||||
|
if (!previous) return { previous: undefined }
|
||||||
|
const optimistic: HarnessQueuedMessage = {
|
||||||
|
id: `optimistic-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
message: input.message,
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
agents: previous.agents.map((agent) =>
|
||||||
|
agent.id === input.agentId
|
||||||
|
? { ...agent, queue: [...(agent.queue ?? []), optimistic] }
|
||||||
|
: agent,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
if (!context?.previous) return
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[AGENT_QUERY_KEYS.agents, baseUrl],
|
||||||
|
context.previous,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic queue removal mirror of `useEnqueueHarnessMessage`.
|
||||||
|
*/
|
||||||
|
export function useRemoveHarnessQueuedMessage() {
|
||||||
|
const { baseUrl } = useAgentServerUrl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { agentId: string; messageId: string }) =>
|
||||||
|
removeHarnessQueuedMessage(input.agentId, input.messageId),
|
||||||
|
onMutate: async (input) => {
|
||||||
|
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
|
||||||
|
await queryClient.cancelQueries({ queryKey })
|
||||||
|
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
|
||||||
|
if (!previous) return { previous: undefined }
|
||||||
|
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
agents: previous.agents.map((agent) =>
|
||||||
|
agent.id === input.agentId
|
||||||
|
? {
|
||||||
|
...agent,
|
||||||
|
queue: (agent.queue ?? []).filter(
|
||||||
|
(entry) => entry.id !== input.messageId,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: agent,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
if (!context?.previous) return
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[AGENT_QUERY_KEYS.agents, baseUrl],
|
||||||
|
context.previous,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
|
||||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||||
|
|
||||||
export interface AgentEntry {
|
export interface AgentEntry {
|
||||||
@@ -7,6 +6,7 @@ export interface AgentEntry {
|
|||||||
name: string
|
name: string
|
||||||
workspace: string
|
workspace: string
|
||||||
model?: unknown
|
model?: unknown
|
||||||
|
source?: 'openclaw' | 'agent-harness'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenClawStatus {
|
export interface OpenClawStatus {
|
||||||
@@ -41,6 +41,7 @@ export interface OpenClawAgentMutationInput {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
modelId?: string
|
modelId?: string
|
||||||
|
supportsImages?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenClawSetupInput {
|
export interface OpenClawSetupInput {
|
||||||
@@ -49,6 +50,10 @@ export interface OpenClawSetupInput {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
modelId?: string
|
modelId?: string
|
||||||
|
// Mirrors LlmProviderConfig.supportsImages — pass-through so the gateway
|
||||||
|
// can declare the model's input modalities correctly when persisting the
|
||||||
|
// custom-provider config.
|
||||||
|
supportsImages?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModelDisplayName(model: unknown): string | undefined {
|
export function getModelDisplayName(model: unknown): string | undefined {
|
||||||
@@ -59,14 +64,8 @@ export function getModelDisplayName(model: unknown): string | undefined {
|
|||||||
export const OPENCLAW_QUERY_KEYS = {
|
export const OPENCLAW_QUERY_KEYS = {
|
||||||
status: 'openclaw-status',
|
status: 'openclaw-status',
|
||||||
agents: 'openclaw-agents',
|
agents: 'openclaw-agents',
|
||||||
podmanOverrides: 'openclaw-podman-overrides',
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface PodmanOverrides {
|
|
||||||
podmanPath: string | null
|
|
||||||
effectivePodmanPath: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GatewayLifecycleAction =
|
export type GatewayLifecycleAction =
|
||||||
| 'setup'
|
| 'setup'
|
||||||
| 'start'
|
| 'start'
|
||||||
@@ -99,7 +98,10 @@ async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
|
|||||||
|
|
||||||
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
|
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
|
||||||
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
|
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
|
||||||
return data.agents ?? []
|
return (data.agents ?? []).map((agent) => ({
|
||||||
|
...agent,
|
||||||
|
source: 'openclaw',
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invalidateOpenClawQueries(
|
async function invalidateOpenClawQueries(
|
||||||
@@ -262,50 +264,6 @@ export function useOpenClawMutations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePodmanOverrides() {
|
|
||||||
const {
|
|
||||||
baseUrl,
|
|
||||||
isLoading: urlLoading,
|
|
||||||
error: urlError,
|
|
||||||
} = useAgentServerUrl()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const query = useQuery<PodmanOverrides, Error>({
|
|
||||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
|
|
||||||
queryFn: () =>
|
|
||||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
|
|
||||||
enabled: !!baseUrl && !urlLoading,
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async (podmanPath: string | null) =>
|
|
||||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ podmanPath }),
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
|
||||||
await Promise.all([
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
|
|
||||||
}),
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [OPENCLAW_QUERY_KEYS.status],
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
overrides: query.data ?? null,
|
|
||||||
loading: query.isLoading || urlLoading,
|
|
||||||
error: (query.error ?? urlError) as Error | null,
|
|
||||||
saving: saveMutation.isPending,
|
|
||||||
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
|
|
||||||
clearOverrides: () => saveMutation.mutateAsync(null),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpenClawStreamEvent {
|
export interface OpenClawStreamEvent {
|
||||||
type:
|
type:
|
||||||
| 'text-delta'
|
| 'text-delta'
|
||||||
@@ -360,19 +318,3 @@ export function buildChatHistoryFromTurns(
|
|||||||
|
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function chatWithAgent(
|
|
||||||
agentId: string,
|
|
||||||
message: string,
|
|
||||||
sessionKey?: string,
|
|
||||||
history: OpenClawChatHistoryMessage[] = [],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<Response> {
|
|
||||||
const baseUrl = await getAgentServerUrl()
|
|
||||||
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message, sessionKey, history }),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -164,9 +164,17 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
|||||||
const resolvedProvider: Provider | null = (() => {
|
const resolvedProvider: Provider | null = (() => {
|
||||||
const id = selectedProviderId ?? defaultProviderId
|
const id = selectedProviderId ?? defaultProviderId
|
||||||
const found = providers.find((p) => p.id === id)
|
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])
|
if (providers[0])
|
||||||
return {
|
return {
|
||||||
|
kind: 'llm' as const,
|
||||||
id: providers[0].id,
|
id: providers[0].id,
|
||||||
name: providers[0].name,
|
name: providers[0].name,
|
||||||
type: providers[0].type,
|
type: providers[0].type,
|
||||||
@@ -175,6 +183,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const providerOptions: Provider[] = providers.map((p) => ({
|
const providerOptions: Provider[] = providers.map((p) => ({
|
||||||
|
kind: 'llm',
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ describe('route-utils', () => {
|
|||||||
expect(shouldUseChatSession('/home/chat')).toBe(true)
|
expect(shouldUseChatSession('/home/chat')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the focus grid on home while hiding it on dedicated full-screen routes', () => {
|
it('hides the focus grid on full-screen routes', () => {
|
||||||
expect(shouldHideFocusGrid('/home')).toBe(false)
|
expect(shouldHideFocusGrid('/home')).toBe(true)
|
||||||
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
|
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
|
||||||
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
|
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
|
||||||
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
|
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const HIDE_FOCUS_GRID_PATHS = new Set([
|
const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||||
|
'/home',
|
||||||
'/home/soul',
|
'/home/soul',
|
||||||
'/home/memory',
|
'/home/memory',
|
||||||
'/home/skills',
|
'/home/skills',
|
||||||
|
|||||||
@@ -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 type { FC } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router'
|
import { Link, useLocation, useNavigate } from 'react-router'
|
||||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
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"
|
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"
|
title="Change AI Provider"
|
||||||
>
|
>
|
||||||
{selectedProvider.type === 'browseros' ? (
|
{selectedProvider.kind === 'acp' ? (
|
||||||
|
<Bot className="h-[18px] w-[18px]" />
|
||||||
|
) : selectedProvider.type === 'browseros' ? (
|
||||||
<BrowserOSIcon size={18} />
|
<BrowserOSIcon size={18} />
|
||||||
) : (
|
) : (
|
||||||
<ProviderIcon
|
<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,178 @@
|
|||||||
|
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'
|
||||||
|
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 useDeepCompareEffect from 'use-deep-compare-effect'
|
||||||
|
import {
|
||||||
|
useAgentAdapters,
|
||||||
|
useHarnessAgents,
|
||||||
|
} from '@/entrypoints/app/agents/useAgents'
|
||||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||||
import { type McpServer, useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
import { type McpServer, useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||||
import { usePersonalization } from '@/lib/personalization/personalizationStorage'
|
import { usePersonalization } from '@/lib/personalization/personalizationStorage'
|
||||||
|
import {
|
||||||
|
buildSidepanelChatTargets,
|
||||||
|
loadSidepanelChatTargetSelection,
|
||||||
|
persistSidepanelChatTargetSelection,
|
||||||
|
resolveSidepanelChatTarget,
|
||||||
|
type SidepanelChatTarget,
|
||||||
|
type SidepanelChatTargetSelection,
|
||||||
|
} from './sidepanel-chat-targets'
|
||||||
|
|
||||||
const constructMcpServers = (servers: McpServer[]) => {
|
const constructMcpServers = (servers: McpServer[]) => {
|
||||||
return servers
|
return servers
|
||||||
@@ -23,14 +35,53 @@ const constructCustomServers = (servers: McpServer[]) => {
|
|||||||
export const useChatRefs = () => {
|
export const useChatRefs = () => {
|
||||||
const { servers: mcpServers } = useMcpServers()
|
const { servers: mcpServers } = useMcpServers()
|
||||||
const {
|
const {
|
||||||
|
providers: llmProviders,
|
||||||
selectedProvider: selectedLlmProvider,
|
selectedProvider: selectedLlmProvider,
|
||||||
|
setDefaultProvider,
|
||||||
isLoading: isLoadingProviders,
|
isLoading: isLoadingProviders,
|
||||||
} = useLlmProviders()
|
} = useLlmProviders()
|
||||||
|
const { adapters, loading: isLoadingAdapters } = useAgentAdapters()
|
||||||
|
const { harnessAgents, loading: isLoadingAgents } = useHarnessAgents()
|
||||||
const { personalization } = usePersonalization()
|
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>(
|
const selectedLlmProviderRef = useRef<LlmProviderConfig | null>(
|
||||||
selectedLlmProvider,
|
selectedLlmProvider,
|
||||||
)
|
)
|
||||||
|
const selectedChatTargetRef = useRef<SidepanelChatTarget | undefined>(
|
||||||
|
selectedChatTarget,
|
||||||
|
)
|
||||||
const enabledMcpServersRef = useRef(constructMcpServers(mcpServers))
|
const enabledMcpServersRef = useRef(constructMcpServers(mcpServers))
|
||||||
const enabledCustomServersRef = useRef(constructCustomServers(mcpServers))
|
const enabledCustomServersRef = useRef(constructCustomServers(mcpServers))
|
||||||
const personalizationRef = useRef(personalization)
|
const personalizationRef = useRef(personalization)
|
||||||
@@ -41,16 +92,36 @@ export const useChatRefs = () => {
|
|||||||
enabledCustomServersRef.current = constructCustomServers(mcpServers)
|
enabledCustomServersRef.current = constructCustomServers(mcpServers)
|
||||||
}, [selectedLlmProvider, mcpServers])
|
}, [selectedLlmProvider, mcpServers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedChatTargetRef.current = selectedChatTarget
|
||||||
|
}, [selectedChatTarget])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
personalizationRef.current = personalization
|
personalizationRef.current = personalization
|
||||||
}, [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 {
|
return {
|
||||||
selectedLlmProviderRef,
|
selectedLlmProviderRef,
|
||||||
|
selectedChatTargetRef,
|
||||||
enabledMcpServersRef,
|
enabledMcpServersRef,
|
||||||
enabledCustomServersRef,
|
enabledCustomServersRef,
|
||||||
personalizationRef,
|
personalizationRef,
|
||||||
|
llmProviders,
|
||||||
|
setDefaultProvider,
|
||||||
|
chatTargets,
|
||||||
|
selectedChatTarget,
|
||||||
|
selectChatTarget,
|
||||||
selectedLlmProvider,
|
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 { declinedAppsStorage } from '@/lib/declined-apps/storage'
|
||||||
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
||||||
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
|
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
|
||||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
import type {
|
||||||
import {
|
ApprovalResponseData,
|
||||||
type ApprovalResponseData,
|
ChatRequestBrowserContext,
|
||||||
buildChatRequestBody,
|
|
||||||
type ChatRequestBrowserContext,
|
|
||||||
} from '@/lib/messaging/server/buildChatRequestBody'
|
} from '@/lib/messaging/server/buildChatRequestBody'
|
||||||
import { track } from '@/lib/metrics/track'
|
import { track } from '@/lib/metrics/track'
|
||||||
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
||||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||||
|
import { sentry } from '@/lib/sentry/sentry'
|
||||||
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
||||||
import {
|
import {
|
||||||
type ApprovalResponse,
|
type ApprovalResponse,
|
||||||
@@ -52,7 +51,12 @@ import {
|
|||||||
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
|
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
|
||||||
import type { ChatMode } from './chatTypes'
|
import type { ChatMode } from './chatTypes'
|
||||||
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
|
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
|
||||||
|
import { toLlmProviderConfig } from './sidepanel-chat-targets'
|
||||||
import { useChatRefs } from './useChatRefs'
|
import { useChatRefs } from './useChatRefs'
|
||||||
|
import {
|
||||||
|
buildSidepanelPreparedSendMessagesRequest,
|
||||||
|
toProviderOption,
|
||||||
|
} from './useChatSessionRequest'
|
||||||
import { useExecutionHistoryTracker } from './useExecutionHistoryTracker'
|
import { useExecutionHistoryTracker } from './useExecutionHistoryTracker'
|
||||||
import { useNotifyActiveTab } from './useNotifyActiveTab'
|
import { useNotifyActiveTab } from './useNotifyActiveTab'
|
||||||
import { useRemoteConversationSave } from './useRemoteConversationSave'
|
import { useRemoteConversationSave } from './useRemoteConversationSave'
|
||||||
@@ -186,16 +190,19 @@ const buildRequestBrowserContext = ({
|
|||||||
export const useChatSession = (options?: ChatSessionOptions) => {
|
export const useChatSession = (options?: ChatSessionOptions) => {
|
||||||
const {
|
const {
|
||||||
selectedLlmProviderRef,
|
selectedLlmProviderRef,
|
||||||
|
selectedChatTargetRef,
|
||||||
enabledMcpServersRef,
|
enabledMcpServersRef,
|
||||||
enabledCustomServersRef,
|
enabledCustomServersRef,
|
||||||
personalizationRef,
|
personalizationRef,
|
||||||
|
setDefaultProvider,
|
||||||
|
chatTargets,
|
||||||
|
selectedChatTarget,
|
||||||
|
selectChatTarget,
|
||||||
selectedLlmProvider,
|
selectedLlmProvider,
|
||||||
isLoadingProviders,
|
isLoadingProviders,
|
||||||
} = useChatRefs()
|
} = useChatRefs()
|
||||||
const invalidateCredits = useInvalidateCredits()
|
const invalidateCredits = useInvalidateCredits()
|
||||||
|
|
||||||
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
baseUrl: agentServerUrl,
|
baseUrl: agentServerUrl,
|
||||||
isLoading: isLoadingAgentUrl,
|
isLoading: isLoadingAgentUrl,
|
||||||
@@ -218,11 +225,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
agentUrlRef.current = agentServerUrl
|
agentUrlRef.current = agentServerUrl
|
||||||
}, [agentServerUrl])
|
}, [agentServerUrl])
|
||||||
|
|
||||||
const providers: Provider[] = llmProviders.map((p) => ({
|
const providers: Provider[] = chatTargets.map(toProviderOption)
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
type: p.type,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [mode, setMode] = useState<ChatMode>('agent')
|
const [mode, setMode] = useState<ChatMode>('agent')
|
||||||
const [textToAction, setTextToAction] = useState<Map<string, ChatAction>>(
|
const [textToAction, setTextToAction] = useState<Map<string, ChatAction>>(
|
||||||
@@ -324,15 +327,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
textToActionRef.current = textToAction
|
textToActionRef.current = textToAction
|
||||||
}, [mode, textToAction])
|
}, [mode, textToAction])
|
||||||
|
|
||||||
const selectedProvider = selectedLlmProvider
|
const selectedProvider = selectedChatTarget
|
||||||
? {
|
? toProviderOption(selectedChatTarget)
|
||||||
id: selectedLlmProvider.id,
|
|
||||||
name: selectedLlmProvider.name,
|
|
||||||
type:
|
|
||||||
selectedLlmProvider.id === 'browseros'
|
|
||||||
? ('browseros' as const)
|
|
||||||
: selectedLlmProvider.type,
|
|
||||||
}
|
|
||||||
: providers[0]
|
: providers[0]
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -346,7 +342,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
} = useChat({
|
} = useChat({
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
prepareSendMessagesRequest: async ({ messages }) => {
|
prepareSendMessagesRequest: async ({ messages }) => {
|
||||||
const provider =
|
const target = selectedChatTargetRef.current
|
||||||
|
const fallbackProvider =
|
||||||
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||||
const activeTabsList = await chrome.tabs.query({
|
const activeTabsList = await chrome.tabs.query({
|
||||||
active: true,
|
active: true,
|
||||||
@@ -395,51 +392,46 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
personalizationRef.current,
|
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) {
|
if (approvalResponses) {
|
||||||
return {
|
return buildSidepanelPreparedSendMessagesRequest({
|
||||||
api: `${agentUrlRef.current}/chat`,
|
agentServerUrl: agentUrlRef.current ?? undefined,
|
||||||
body: buildChatRequestBody({
|
target,
|
||||||
conversationId: conversationIdRef.current,
|
fallbackProvider,
|
||||||
provider,
|
...commonRequest,
|
||||||
mode: currentMode,
|
approvalResponses,
|
||||||
browserContext: requestBrowserContext,
|
})
|
||||||
userSystemPrompt,
|
|
||||||
userWorkingDir: workingDirRef.current,
|
|
||||||
previousConversation,
|
|
||||||
declinedApps,
|
|
||||||
aclRules: enabledAclRules,
|
|
||||||
toolApprovalConfig: approvalConfig,
|
|
||||||
toolApprovalResponses: approvalResponses,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = getLastMessageText(messages)
|
const message = getLastMessageText(messages)
|
||||||
|
|
||||||
const result = {
|
const result = buildSidepanelPreparedSendMessagesRequest({
|
||||||
api: `${agentUrlRef.current}/chat`,
|
agentServerUrl: agentUrlRef.current ?? undefined,
|
||||||
body: buildChatRequestBody({
|
target,
|
||||||
message,
|
fallbackProvider,
|
||||||
conversationId: conversationIdRef.current,
|
message,
|
||||||
provider,
|
...commonRequest,
|
||||||
mode: currentMode,
|
selectedText: activeTabSelection?.text,
|
||||||
browserContext: requestBrowserContext,
|
selectedTextSource: activeTabSelection
|
||||||
userSystemPrompt,
|
? {
|
||||||
userWorkingDir: workingDirRef.current,
|
url: activeTabSelection.url,
|
||||||
previousConversation,
|
title: activeTabSelection.title,
|
||||||
declinedApps,
|
}
|
||||||
aclRules: enabledAclRules,
|
: undefined,
|
||||||
selectedText: activeTabSelection?.text,
|
})
|
||||||
selectedTextSource: activeTabSelection
|
|
||||||
? {
|
|
||||||
url: activeTabSelection.url,
|
|
||||||
title: activeTabSelection.title,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
toolApprovalConfig: approvalConfig,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track which tab's selection was sent so we can clear it on success
|
// Track which tab's selection was sent so we can clear it on success
|
||||||
pendingSelectionTabKeyRef.current =
|
pendingSelectionTabKeyRef.current =
|
||||||
@@ -451,7 +443,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
sendAutomaticallyWhen: () => {
|
sendAutomaticallyWhen: () => {
|
||||||
if (approvalJustRespondedRef.current) {
|
if (approvalJustRespondedRef.current) {
|
||||||
approvalJustRespondedRef.current = false
|
approvalJustRespondedRef.current = false
|
||||||
return true
|
return selectedChatTargetRef.current?.kind !== 'acp'
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
@@ -686,10 +678,22 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
}, [dispatchMessage, isIntegrationsSynced])
|
}, [dispatchMessage, isIntegrationsSynced])
|
||||||
|
|
||||||
const sendMessage = (params: { text: string; action?: ChatAction }) => {
|
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, {
|
track(MESSAGE_SENT_EVENT, {
|
||||||
mode,
|
mode,
|
||||||
provider_type: selectedLlmProvider?.type,
|
provider_id:
|
||||||
model: selectedLlmProvider?.modelId,
|
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) {
|
if (!isIntegrationsSyncedRef.current) {
|
||||||
@@ -741,14 +745,54 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
addToolApprovalResponse(params)
|
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 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, {
|
track(PROVIDER_SELECTED_EVENT, {
|
||||||
provider_id: provider.id,
|
provider_id: target.id,
|
||||||
provider_type: provider.type,
|
provider_type: target.kind === 'acp' ? 'acp' : target.type,
|
||||||
model_id: fullProvider?.modelId,
|
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) => {
|
const getActionForMessage = (message: UIMessage) => {
|
||||||
@@ -762,15 +806,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
|||||||
|
|
||||||
const resetConversation = () => {
|
const resetConversation = () => {
|
||||||
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
|
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
|
||||||
stop()
|
resetConversationState()
|
||||||
void finishExecutionTask({ isAbort: true })
|
|
||||||
setConversationId(crypto.randomUUID())
|
|
||||||
setMessages([])
|
|
||||||
setTextToAction(new Map())
|
|
||||||
setLiked({})
|
|
||||||
setDisliked({})
|
|
||||||
setRestoredConversationId(null)
|
|
||||||
resetRemoteConversation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRestoringConversation =
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export interface AssistantThinkingPart {
|
|||||||
export interface ToolEntry {
|
export interface ToolEntry {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
label: string
|
||||||
|
subject?: string
|
||||||
status: 'running' | 'completed' | 'error'
|
status: 'running' | 'completed' | 'error'
|
||||||
durationMs?: number
|
durationMs?: number
|
||||||
}
|
}
|
||||||
@@ -26,9 +28,24 @@ export type AssistantPart =
|
|||||||
| AssistantThinkingPart
|
| AssistantThinkingPart
|
||||||
| AssistantToolBatchPart
|
| AssistantToolBatchPart
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachments rendered alongside the user's text on the optimistic turn
|
||||||
|
* — populated when the composer staged any images/files. The dataUrl is
|
||||||
|
* the same one the server received; we keep it in memory only for the
|
||||||
|
* lifetime of the live turn (history reload re-fetches via the JSONL).
|
||||||
|
*/
|
||||||
|
export interface UserAttachmentPreview {
|
||||||
|
id: string
|
||||||
|
kind: 'image' | 'file'
|
||||||
|
mediaType: string
|
||||||
|
name: string
|
||||||
|
dataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentConversationTurn {
|
export interface AgentConversationTurn {
|
||||||
id: string
|
id: string
|
||||||
userText: string
|
userText: string
|
||||||
|
userAttachments?: UserAttachmentPreview[]
|
||||||
parts: AssistantPart[]
|
parts: AssistantPart[]
|
||||||
done: boolean
|
done: boolean
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -42,12 +59,3 @@ export interface AgentConversation {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentCardData {
|
|
||||||
agentId: string
|
|
||||||
name: string
|
|
||||||
model?: string
|
|
||||||
status: 'idle' | 'working' | 'error'
|
|
||||||
lastMessage?: string
|
|
||||||
lastMessageTimestamp?: number
|
|
||||||
}
|
|
||||||
|
|||||||
369
packages/browseros-agent/apps/agent/lib/attachments.ts
Normal file
369
packages/browseros-agent/apps/agent/lib/attachments.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* Composer attachment helpers — validation, image compression, and the
|
||||||
|
* client-side payload shape sent to /agents/:id/chat.
|
||||||
|
*
|
||||||
|
* Image attachments travel as `data:` URLs (base64) so the gateway, which
|
||||||
|
* runs on 127.0.0.1 over Lima virtiofs, can ingest them as standard
|
||||||
|
* OpenAI-style content blocks. Non-image text-shaped files are read into
|
||||||
|
* memory and travel as their extracted text body — the server inlines
|
||||||
|
* them as a fenced `<attachment>` block on the user message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_ATTACHMENTS_PER_MESSAGE = 10
|
||||||
|
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024 // 5 MB after compression
|
||||||
|
export const MAX_FILE_TEXT_BYTES = 1 * 1024 * 1024 // 1 MB extracted text
|
||||||
|
export const IMAGE_LONG_EDGE_CAP = 2048
|
||||||
|
|
||||||
|
export const ALLOWED_IMAGE_MEDIA_TYPES = [
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const ALLOWED_FILE_MEDIA_TYPE_PREFIXES = [
|
||||||
|
'text/',
|
||||||
|
'application/json',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ServerImageAttachment = {
|
||||||
|
kind: 'image'
|
||||||
|
mediaType: string
|
||||||
|
dataUrl: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerFileAttachment = {
|
||||||
|
kind: 'file'
|
||||||
|
mediaType: string
|
||||||
|
name: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerAttachmentPayload =
|
||||||
|
| ServerImageAttachment
|
||||||
|
| ServerFileAttachment
|
||||||
|
|
||||||
|
/** UI-side representation: what the composer needs to render a chip. */
|
||||||
|
export interface StagedAttachment {
|
||||||
|
id: string
|
||||||
|
kind: 'image' | 'file'
|
||||||
|
mediaType: string
|
||||||
|
name: string
|
||||||
|
// Set for images so the chip thumbnail can render directly. For files
|
||||||
|
// we don't need a preview yet, but the field exists for v2 PDF previews.
|
||||||
|
dataUrl?: string
|
||||||
|
// Pre-computed payload for the server. Built once at staging time so
|
||||||
|
// re-renders don't re-encode large blobs.
|
||||||
|
payload: ServerAttachmentPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttachmentValidationError =
|
||||||
|
| { code: 'too_many'; message: string }
|
||||||
|
| { code: 'unsupported_type'; message: string; mediaType: string }
|
||||||
|
| { code: 'too_large'; message: string }
|
||||||
|
| { code: 'read_failed'; message: string }
|
||||||
|
|
||||||
|
export type StageAttachmentResult =
|
||||||
|
| { ok: true; attachment: StagedAttachment }
|
||||||
|
| { ok: false; error: AttachmentValidationError }
|
||||||
|
|
||||||
|
function isImageMediaType(mediaType: string): boolean {
|
||||||
|
return (ALLOWED_IMAGE_MEDIA_TYPES as readonly string[]).includes(mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedFileMediaType(mediaType: string): boolean {
|
||||||
|
return ALLOWED_FILE_MEDIA_TYPE_PREFIXES.some((prefix) =>
|
||||||
|
mediaType.startsWith(prefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a unique id without depending on `crypto.randomUUID` outside DOM. */
|
||||||
|
function makeId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `att-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a `File` and produce the staged-attachment shape — validate type,
|
||||||
|
* compress if it's a large image, and pre-build the server payload.
|
||||||
|
*/
|
||||||
|
export async function stageAttachment(
|
||||||
|
file: File,
|
||||||
|
): Promise<StageAttachmentResult> {
|
||||||
|
const mediaType = file.type || 'application/octet-stream'
|
||||||
|
|
||||||
|
if (isImageMediaType(mediaType)) {
|
||||||
|
try {
|
||||||
|
const compressed = await compressImageIfNeeded(file)
|
||||||
|
const dataUrl = await readAsDataUrl(compressed)
|
||||||
|
// 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) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'too_large',
|
||||||
|
message: `Image "${file.name}" is too large (max ${humanBytes(
|
||||||
|
MAX_IMAGE_BYTES,
|
||||||
|
)}).`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
attachment: {
|
||||||
|
id: makeId(),
|
||||||
|
kind: 'image',
|
||||||
|
mediaType,
|
||||||
|
name: file.name || 'image',
|
||||||
|
dataUrl,
|
||||||
|
payload: {
|
||||||
|
kind: 'image',
|
||||||
|
mediaType,
|
||||||
|
dataUrl,
|
||||||
|
name: file.name || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'read_failed',
|
||||||
|
message:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: `Failed to read image "${file.name}".`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAllowedFileMediaType(mediaType)) {
|
||||||
|
let text: string
|
||||||
|
try {
|
||||||
|
text = await file.text()
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'read_failed',
|
||||||
|
message:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: `Failed to read file "${file.name}".`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text.length > MAX_FILE_TEXT_BYTES) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'too_large',
|
||||||
|
message: `File "${file.name}" is too large (max ${humanBytes(
|
||||||
|
MAX_FILE_TEXT_BYTES,
|
||||||
|
)}).`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
attachment: {
|
||||||
|
id: makeId(),
|
||||||
|
kind: 'file',
|
||||||
|
mediaType,
|
||||||
|
name: file.name || 'attachment',
|
||||||
|
payload: {
|
||||||
|
kind: 'file',
|
||||||
|
mediaType,
|
||||||
|
name: file.name || 'attachment',
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'unsupported_type',
|
||||||
|
message: `Unsupported attachment type: ${mediaType || 'unknown'}`,
|
||||||
|
mediaType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage multiple files at once, enforcing the per-message cap. The result
|
||||||
|
* partitions successful stages and any errors so the caller can show
|
||||||
|
* granular toasts.
|
||||||
|
*/
|
||||||
|
export async function stageAttachments(
|
||||||
|
files: File[],
|
||||||
|
alreadyStaged: number,
|
||||||
|
): Promise<{
|
||||||
|
staged: StagedAttachment[]
|
||||||
|
errors: AttachmentValidationError[]
|
||||||
|
}> {
|
||||||
|
const remainingSlots = Math.max(
|
||||||
|
0,
|
||||||
|
MAX_ATTACHMENTS_PER_MESSAGE - alreadyStaged,
|
||||||
|
)
|
||||||
|
const staged: StagedAttachment[] = []
|
||||||
|
const errors: AttachmentValidationError[] = []
|
||||||
|
|
||||||
|
if (remainingSlots === 0 && files.length > 0) {
|
||||||
|
errors.push({
|
||||||
|
code: 'too_many',
|
||||||
|
message: `At most ${MAX_ATTACHMENTS_PER_MESSAGE} attachments per message.`,
|
||||||
|
})
|
||||||
|
return { staged, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
const overflow = files.length - remainingSlots
|
||||||
|
if (overflow > 0) {
|
||||||
|
errors.push({
|
||||||
|
code: 'too_many',
|
||||||
|
message: `Only the first ${remainingSlots} of ${files.length} files were attached (max ${MAX_ATTACHMENTS_PER_MESSAGE}).`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files.slice(0, remainingSlots)) {
|
||||||
|
const result = await stageAttachment(file)
|
||||||
|
if (result.ok) {
|
||||||
|
staged.push(result.attachment)
|
||||||
|
} else {
|
||||||
|
errors.push(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { staged, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize images that are oversized to a sane long-edge cap. JPEG/WebP
|
||||||
|
* source files are re-encoded to JPEG; PNGs/GIFs that are already small
|
||||||
|
* are passed through untouched.
|
||||||
|
*/
|
||||||
|
export async function compressImageIfNeeded(file: File): Promise<Blob> {
|
||||||
|
// Cheap path: small files don't need any transform.
|
||||||
|
if (file.size <= 1.5 * 1024 * 1024) return file
|
||||||
|
|
||||||
|
const bitmap = await blobToImageBitmap(file)
|
||||||
|
const { width, height } = bitmap
|
||||||
|
const longEdge = Math.max(width, height)
|
||||||
|
if (longEdge <= IMAGE_LONG_EDGE_CAP && file.size <= MAX_IMAGE_BYTES) {
|
||||||
|
bitmap.close?.()
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.min(1, IMAGE_LONG_EDGE_CAP / longEdge)
|
||||||
|
const targetWidth = Math.max(1, Math.round(width * scale))
|
||||||
|
const targetHeight = Math.max(1, Math.round(height * scale))
|
||||||
|
|
||||||
|
const canvas =
|
||||||
|
typeof OffscreenCanvas !== 'undefined'
|
||||||
|
? new OffscreenCanvas(targetWidth, targetHeight)
|
||||||
|
: Object.assign(document.createElement('canvas'), {
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d') as
|
||||||
|
| CanvasRenderingContext2D
|
||||||
|
| OffscreenCanvasRenderingContext2D
|
||||||
|
| null
|
||||||
|
if (!ctx) {
|
||||||
|
bitmap.close?.()
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight)
|
||||||
|
bitmap.close?.()
|
||||||
|
|
||||||
|
const outputType = 'image/jpeg'
|
||||||
|
if (canvas instanceof HTMLCanvasElement) {
|
||||||
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) resolve(blob)
|
||||||
|
else reject(new Error('Image compression failed.'))
|
||||||
|
},
|
||||||
|
outputType,
|
||||||
|
0.85,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return await (canvas as OffscreenCanvas).convertToBlob({
|
||||||
|
type: outputType,
|
||||||
|
quality: 0.85,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToImageBitmap(blob: Blob): Promise<ImageBitmap> {
|
||||||
|
if (typeof createImageBitmap === 'function') {
|
||||||
|
return createImageBitmap(blob)
|
||||||
|
}
|
||||||
|
// Fallback: load via an Image element and use the canvas decode path.
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
try {
|
||||||
|
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const el = new Image()
|
||||||
|
el.onload = () => resolve(el)
|
||||||
|
el.onerror = () =>
|
||||||
|
reject(new Error('Failed to decode image for compression.'))
|
||||||
|
el.src = url
|
||||||
|
})
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('Canvas 2D context unavailable.')
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
const blobOut = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, 'image/png'),
|
||||||
|
)
|
||||||
|
if (!blobOut) throw new Error('Canvas toBlob returned null.')
|
||||||
|
return await createImageBitmap(blobOut)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAsDataUrl(blob: Blob): Promise<string> {
|
||||||
|
if ('arrayBuffer' in blob && typeof FileReader === 'undefined') {
|
||||||
|
const buffer = await blob.arrayBuffer()
|
||||||
|
const base64 = arrayBufferToBase64(buffer)
|
||||||
|
const type = blob.type || 'application/octet-stream'
|
||||||
|
return `data:${type};base64,${base64}`
|
||||||
|
}
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = () =>
|
||||||
|
reject(reader.error ?? new Error('FileReader failed to read blob.'))
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.byteLength; i += chunkSize) {
|
||||||
|
binary += String.fromCharCode.apply(
|
||||||
|
null,
|
||||||
|
Array.from(bytes.subarray(i, Math.min(i + chunkSize, bytes.byteLength))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanBytes(bytes: number): string {
|
||||||
|
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||||
|
return `${bytes} B`
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user