Compare commits

..

8 Commits

Author SHA1 Message Date
shivammittal274
11aca93cf5 fix: address Greptile review feedback
- Replace process.exit(1) with process.exitCode + return in cmdWaitFor
  to allow async CDP cleanup in finally blocks
- Fix cmdScroll enabling Runtime instead of Page domain
- Add BROWSEROS_EXTENSION_ID env var override for extension ID
- Align CLAUDE.md dev server command with SKILL.md canonical command
2026-03-17 15:10:14 +05:30
shivammittal274
bd619d1a29 docs: prefer snapshot over screenshot, add holistic debugging guidance
- Add snapshot vs screenshot guidance table — prefer snapshot for
  structural checks, screenshot only for visual/layout verification
- Add server log checking instructions ([agent], [server], [build] tags)
- Add JS error checking via eval
- Add API connectivity verification
- Add common issues troubleshooting table
- Update all examples to use snapshot as default verification
2026-03-17 14:40:12 +05:30
shivammittal274
f18ae0237c feat: add press_key, scroll, hover, select_option, wait_for to inspect-ui
Brings inspect-ui.ts to parity with server's MCP input tools:
- press_key: key combos like Enter, Control+A, Meta+Shift+P
  (ported from keyboard.ts pressCombo)
- scroll: up/down/left/right with configurable amount
- hover: hover over element by ID for tooltip/hover state testing
- select_option: select dropdown option by value or visible text
  (ported from browser.ts selectOption)
- wait_for: poll for text or CSS selector with 10s timeout

Updated skill documentation with new commands and examples.
2026-03-17 02:41:12 +05:30
shivammittal274
81744417ab feat: add test-ui skill for visual testing of agent extension UI
Adds a Claude Code skill that lets the agent visually test both
surfaces of the BrowserOS extension:
- New tab page (app.html) — left sidebar with Home, Scheduled Tasks,
  Settings, Skills, Memory, Soul, Connect Apps
- Right side panel (sidepanel.html) — chat interface

Includes all gotchas discovered through real testing: randomized ports,
fresh profile onboarding redirect, stale element IDs after navigation,
BrowserOS-specific sidePanel APIs, DOM.getDocument requirement.
2026-03-17 02:35:27 +05:30
shivammittal274
9ec4445804 fix: runtime fixes for inspect-ui discovered during live testing
- Remove Input.enable (domain has no enable method)
- Add DOM.getDocument before DOM operations (required by protocol)
- Use BrowserOS-specific sidePanel.browserosToggle API instead of
  standard chrome.sidePanel.open (side panel starts disabled)
- Enable side panel with setOptions before toggling
2026-03-17 02:25:45 +05:30
shivammittal274
02bbb3fdd5 docs: add self-testing workflow for UI changes via CDP inspector 2026-03-17 00:47:23 +05:30
shivammittal274
f11d16c674 fix: address code review feedback for inspect-ui script
- Use Delete key (not Backspace) to match server's keyboard.ts clearField
- Add windowId resolution to open-sidepanel (chrome.sidePanel.open requires it)
- Make target matching case-insensitive
- Replace process.exit(1) in eval with thrown error for proper cleanup
- Add comment referencing DEV_PORTS source of truth
2026-03-17 00:46:19 +05:30
shivammittal274
64ddcc2a07 feat: add CDP UI inspector script for dev self-testing 2026-03-17 00:38:15 +05:30
689 changed files with 23538 additions and 89046 deletions

2
.gitattributes vendored
View File

@@ -9,6 +9,4 @@ packages/browseros/chromium_patches/**/*.py linguist-generated
scripts/*.py linguist-generated
# Mark build directories as generated
build/* linguist-generated
# Mark eval/test framework as vendored so it's excluded from language stats
packages/browseros-agent/apps/eval/** linguist-vendored
docs/videos/** filter=lfs diff=lfs merge=lfs -text

View File

@@ -1,11 +1,11 @@
name: CLA Assistant
name: 'CLA Assistant'
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
# Explicitly configure permissions
permissions:
actions: write
contents: write
@@ -13,46 +13,47 @@ permissions:
statuses: write
jobs:
cla:
CLAAssistant:
runs-on: ubuntu-latest
if: |
(github.event_name == 'pull_request_target') ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
steps:
- name: CLA Assistant
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }}
with:
path-to-signatures: 'cla-signatures.json'
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md'
# Path where signatures will be stored
path-to-signatures: 'signatures/version1/cla.json'
# Path to your CLA document
path-to-document: 'https://github.com/browseros-ai/BrowserOS/blob/main/CLA.md'
# Branch to store signatures (should not be protected)
branch: 'main'
remote-organization-name: 'browseros-ai'
remote-repository-name: 'cla-signatures'
allowlist: 'shadowfax92,felarof99,bot*,*[bot],dependabot,renovate,github-actions,snyk-bot,imgbot,greenkeeper,semantic-release-bot,allcontributors'
lock-pullrequest-aftermerge: false
# Allowlist for users who don't need to sign (bots, core team members)
allowlist: shadowfax92,felarof99,dependabot[bot],renovate[bot],github-actions[bot]
# Optional: Custom messages
custom-notsigned-prcomment: |
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement](https://github.com/${{ github.repository }}/blob/main/CLA.md).
**CLA Assistant Lite bot** Thank you for your submission! We require contributors to sign our [Contributor License Agreement](https://github.com/browseros-ai/BrowserOS/blob/main/CLA.md) before we can accept your contribution.
**To sign the CLA**, please add a comment to this PR with the following text:
By signing the CLA, you confirm that:
- You have read and agree to the AGPL-3.0 license terms
- Your contribution is your original work
- You grant us the rights to use your contribution under the AGPL-3.0 license
```
I have read the CLA Document and I hereby sign the CLA
```
**To sign the CLA, please comment on this PR with:**
`I have read the CLA Document and I hereby sign the CLA`
You only need to sign once. After signing, this check will pass automatically.
---
<details>
<summary>Troubleshooting</summary>
- **Already signed but still failing?** Comment `recheck` to trigger a re-verification.
- **Signed with a different email?** Make sure your commit email matches your GitHub account email, or add your commit email to your GitHub account.
</details>
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: |
All contributors have signed the CLA. Thank you!
**CLA Assistant Lite bot** ✅ All contributors have signed the CLA. Thank you for helping make BrowserOS better!
# Lock PR after merge to prevent signature tampering
lock-pullrequest-aftermerge: true
# Custom commit messages
create-file-commit-message: 'docs: Create CLA signatures file'
signed-commit-message: 'docs: $contributorName signed the CLA in $owner/$repo#$pullRequestNo'

View File

@@ -1,108 +0,0 @@
name: Weekly Eval
on:
schedule:
# Every Saturday at 06:00 UTC
- cron: '0 6 * * 6'
push:
branches: [main]
paths:
- 'packages/browseros-agent/apps/server/src/agent/**'
- 'packages/browseros-agent/apps/server/src/tools/**'
workflow_dispatch:
inputs:
config:
description: 'Eval config file (relative to apps/eval/)'
required: false
default: 'configs/browseros-agent-weekly.json'
permissions:
contents: read
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 360
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install BrowserOS
run: |
wget -q https://github.com/browseros-ai/BrowserOS/releases/download/v0.44.0.1/BrowserOS_v0.44.0.1_amd64.deb
sudo dpkg -i BrowserOS_v0.44.0.1_amd64.deb
browseros --version || echo "BrowserOS installed at $(which browseros)"
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
working-directory: packages/browseros-agent
run: bun install --ignore-scripts && bun run build:agent-sdk
- name: Install Python eval dependencies
run: pip install agisdk 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
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Install captcha solver extension
working-directory: packages/browseros-agent/apps/eval
run: |
mkdir -p extensions
curl -sL -o /tmp/nopecha.zip https://github.com/NopeCHALLC/nopecha-extension/releases/latest/download/chromium_automation.zip
unzip -qo /tmp/nopecha.zip -d extensions/nopecha
- name: Run eval
working-directory: packages/browseros-agent/apps/eval
env:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
BROWSEROS_BINARY: /usr/bin/browseros
WEBARENA_INFINITY_DIR: /tmp/webarena-infinity
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
echo "Running eval with config: $EVAL_CONFIG"
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts -c "$EVAL_CONFIG"
- name: Upload runs to R2
if: success()
working-directory: packages/browseros-agent/apps/eval
env:
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
CONFIG_NAME=$(basename "$EVAL_CONFIG" .json)
bun scripts/upload-run.ts "results/$CONFIG_NAME"
- name: Generate trend report
if: success()
timeout-minutes: 5
continue-on-error: true
working-directory: packages/browseros-agent
env:
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
- name: Upload report as artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: eval-report-${{ github.run_id }}
path: /tmp/eval-report.html

View File

@@ -1,148 +0,0 @@
name: Release BrowserOS Extension
on:
workflow_dispatch:
concurrency:
group: release-agent-extension
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
defaults:
run:
working-directory: packages/browseros-agent/apps/agent
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun ci
working-directory: packages/browseros-agent
- name: Build and zip extension
run: bun run codegen && bun run zip
env:
VITE_PUBLIC_BROWSEROS_API: https://api.browseros.com
- name: Get version and zip path
id: version
run: |
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
ZIP_FILE=$(ls "$(pwd)/dist/"*-chrome.zip | head -n 1)
echo "zip_path=$ZIP_FILE" >> "$GITHUB_OUTPUT"
echo "zip_name=$(basename "$ZIP_FILE")" >> "$GITHUB_OUTPUT"
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
AGENT_PATH="packages/browseros-agent/apps/agent"
CURRENT_TAG="agent-extension-v${{ steps.version.outputs.version }}"
PREV_TAG=$(git tag -l "agent-extension-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release" > /tmp/release-notes.md
else
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$AGENT_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
while IFS= read -r SHA; do
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
# Skip PR number if already in the commit subject (squash merges include it)
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: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="agent-extension-v${{ steps.version.outputs.version }}"
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
TITLE="BrowserOS Extension - v${{ steps.version.outputs.version }}"
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
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
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
gh release upload "$TAG" "${{ steps.version.outputs.zip_path }}" --clobber
else
gh release create "$TAG" \
--title "$TITLE" \
--notes-file /tmp/release-notes.md \
"${{ steps.version.outputs.zip_path }}"
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-extension-changelog-v${VERSION}"
CHANGELOG="packages/browseros-agent/apps/agent/CHANGELOG.md"
git checkout main
{
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 extension changelog for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--title "docs: update agent extension changelog for v${VERSION}" \
--body "Auto-generated changelog update for BrowserOS Extension v${VERSION}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --auto || true
working-directory: ${{ github.workspace }}

View File

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

View File

@@ -1,161 +0,0 @@
name: Release BrowserOS CLI
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.1.0)"
required: true
type: string
concurrency:
group: release-cli
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: release-core
permissions:
contents: write
pull-requests: write
defaults:
run:
working-directory: packages/browseros-agent/apps/cli
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: packages/browseros-agent/apps/cli/go.mod
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Run tests
run: make test
- name: Run vet
run: make vet
- name: Build all platforms
run: make release VERSION=${{ inputs.version }} POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}
- name: Install dependencies
run: bun install
working-directory: packages/browseros-agent
- name: Upload to CDN
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 }}
R2_UPLOAD_PREFIX: cli
CLI_VERSION: ${{ inputs.version }}
run: |
bun scripts/build/cli.ts \
--release \
--version "$CLI_VERSION" \
--binaries-dir apps/cli/dist
working-directory: packages/browseros-agent
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CLI_PATH="packages/browseros-agent/apps/cli"
TAG="browseros-cli-v${{ inputs.version }}"
CHANGELOG_FILE="/tmp/release-changelog.md"
PREV_TAG=$(git tag -l "browseros-cli-v*" --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release of browseros-cli." > "$CHANGELOG_FILE"
else
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$CLI_PATH")
if [ -z "$COMMITS" ]; then
echo "No notable changes." > "$CHANGELOG_FILE"
else
echo "## What's Changed" > "$CHANGELOG_FILE"
echo "" >> "$CHANGELOG_FILE"
while IFS= read -r SHA; do
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
echo "- ${SUBJECT} (#${PR_NUM})" >> "$CHANGELOG_FILE"
else
echo "- ${SUBJECT}" >> "$CHANGELOG_FILE"
fi
done <<< "$COMMITS"
fi
fi
cat "$CHANGELOG_FILE" > /tmp/release-notes.md
cat >> /tmp/release-notes.md <<'EOF'
## Install `browseros-cli`
### npm / npx
```bash
npx browseros-cli --help
npm install -g browseros-cli
```
### macOS / Linux
```bash
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
```
### Windows
```powershell
irm https://cdn.browseros.com/cli/install.ps1 | iex
```
After install, run `browseros-cli init` to point the CLI at your BrowserOS MCP server.
EOF
working-directory: ${{ github.workspace }}
- name: Create tag and release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="browseros-cli-v${{ inputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git tag -a "$TAG" -m "browseros-cli v${{ inputs.version }}"
git push origin "$TAG"
fi
CLI_DIST="packages/browseros-agent/apps/cli/dist"
gh release create "$TAG" \
--title "BrowserOS CLI - v${{ inputs.version }}" \
--notes-file /tmp/release-notes.md \
${CLI_DIST}/*
working-directory: ${{ github.workspace }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
make npm-version VERSION=${{ inputs.version }}
cd npm
npm publish --access public

View File

@@ -1,147 +0,0 @@
name: Release BrowserOS Server
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.0.80)"
required: true
type: string
concurrency:
group: release-server
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: release-core
permissions:
contents: write
defaults:
run:
working-directory: packages/browseros-agent
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Install dependencies
run: bun ci
- name: Prepare production env file
run: cp apps/server/.env.production.example apps/server/.env.production
- name: Validate version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version }}
run: |
PACKAGE_VERSION=$(node -p "require('./apps/server/package.json').version")
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
if [ "$PACKAGE_VERSION" != "$REQUESTED_VERSION" ]; then
echo "Requested version $REQUESTED_VERSION does not match apps/server/package.json ($PACKAGE_VERSION)"
exit 1
fi
- name: Build release artifacts
run: bun run build:server:ci
- name: Verify release artifacts
run: |
mapfile -t ZIP_FILES < <(find dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
if [ "${#ZIP_FILES[@]}" -eq 0 ]; then
echo "No server release zip files were produced"
exit 1
fi
printf 'Found release artifacts:\n%s\n' "${ZIP_FILES[@]}"
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
run: |
SERVER_APP_PATH="packages/browseros-agent/apps/server"
SERVER_BUILD_DIR="packages/browseros-agent/scripts/build/server"
SERVER_BUILD_ENTRY="packages/browseros-agent/scripts/build/server.ts"
SERVER_RESOURCE_MANIFEST="packages/browseros-agent/scripts/build/config/server-prod-resources.json"
SERVER_WORKSPACE_PKG="packages/browseros-agent/package.json"
CURRENT_TAG="browseros-server-v$PACKAGE_VERSION"
PREV_TAG=$(git tag -l "browseros-server-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release of browseros-server." > /tmp/release-notes.md
else
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- \
"$SERVER_APP_PATH" \
"$SERVER_BUILD_DIR" \
"$SERVER_BUILD_ENTRY" \
"$SERVER_RESOURCE_MANIFEST" \
"$SERVER_WORKSPACE_PKG")
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
while IFS= read -r SHA; do
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
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: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
RELEASE_SHA: ${{ steps.version.outputs.release_sha }}
run: |
TAG="browseros-server-v$PACKAGE_VERSION"
TITLE="BrowserOS Server - v$PACKAGE_VERSION"
mapfile -t ZIP_FILES < <(find packages/browseros-agent/dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping tag creation"
else
git tag -a "$TAG" -m "browseros-server v$PACKAGE_VERSION" "$RELEASE_SHA"
fi
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
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
gh release upload "$TAG" "${ZIP_FILES[@]}" --clobber
else
gh release create "$TAG" \
--title "$TITLE" \
--notes-file /tmp/release-notes.md \
"${ZIP_FILES[@]}"
fi
working-directory: ${{ github.workspace }}

View File

@@ -1,141 +0,0 @@
name: Tests
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- .github/workflows/test.yml
- packages/browseros-agent/**
workflow_dispatch:
permissions:
contents: read
env:
BROWSEROS_APPIMAGE_URL: https://files.browseros.com/download/BrowserOS.AppImage
jobs:
test:
name: Tests / ${{ matrix.suite }}
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: packages/browseros-agent
strategy:
fail-fast: false
matrix:
include:
- suite: tools
test_path: tests/tools
junit_path: test-results/tools.xml
- suite: integration
test_path: tests/server.integration.test.ts
junit_path: test-results/integration.xml
- suite: sdk
test_path: tests/sdk
junit_path: test-results/sdk.xml
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun ci
- name: Resolve BrowserOS cache key
id: browseros-cache-key
run: |
set -euo pipefail
headers="$(curl -fsSI "$BROWSEROS_APPIMAGE_URL")"
etag="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/\r$/, "", $2); gsub(/"/, "", $2); print $2; exit}')"
last_modified="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {$1=""; sub(/^ /, ""); sub(/\r$/, ""); print; exit}')"
raw_key="${etag:-$last_modified}"
if [ -z "$raw_key" ]; then
raw_key="$BROWSEROS_APPIMAGE_URL"
fi
cache_key="$(printf '%s' "$raw_key" | shasum -a 256 | awk '{print $1}')"
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
- name: Restore BrowserOS cache
id: browseros-cache
uses: actions/cache@v4
with:
path: packages/browseros-agent/.ci/bin/BrowserOS.AppImage
key: ${{ steps.browseros-cache-key.outputs.key }}
- name: Download BrowserOS
if: steps.browseros-cache.outputs.cache-hit != 'true'
run: |
mkdir -p .ci/bin
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
chmod +x .ci/bin/BrowserOS.AppImage
- name: Prepare BrowserOS wrapper
run: |
mkdir -p .ci/bin
cat > .ci/bin/browseros <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export APPIMAGE_EXTRACT_AND_RUN=1
exec "$(dirname "$0")/BrowserOS.AppImage" "$@"
EOF
chmod +x .ci/bin/browseros
- name: Create server env file
working-directory: packages/browseros-agent/apps/server
run: cp .env.example .env.development
- name: Run ${{ matrix.suite }} tests
id: test
env:
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
BROWSEROS_TEST_HEADLESS: "true"
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
run: |
set +e
mkdir -p test-results
cd apps/server
bun run test:cleanup
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
exit_code=$?
cd ../..
if [ ! -f "${{ matrix.junit_path }}" ]; then
cat > "${{ matrix.junit_path }}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="1" failures="1">
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
<testcase classname="workflow" name="${{ matrix.suite }} setup">
<failure message="Test run failed before JUnit output was written">See workflow logs for details.</failure>
</testcase>
</testsuite>
</testsuites>
EOF
fi
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
- name: Upload JUnit XML
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-${{ matrix.suite }}
path: packages/browseros-agent/${{ matrix.junit_path }}
- name: Summarize suite result
if: always()
run: |
if [ "${{ steps.test.outputs.exit_code }}" = "0" ]; then
echo "### :white_check_mark: ${{ matrix.suite }} suite passed" >> "$GITHUB_STEP_SUMMARY"
else
echo "### :x: ${{ matrix.suite }} suite failed (exit code ${{ steps.test.outputs.exit_code }})" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

6
.gitignore vendored
View File

@@ -1,6 +1,4 @@
**/.DS_Store
**.auctor/**
.auctor.json
.gcs_entries
**/dmg
**/env
@@ -28,7 +26,3 @@ gclient.json
**/resources/binaries/
packages/browseros/build/tools/
# AI SDK DevTools traces
.devtools/
.omc/

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
{
"terminal.integrated.tabs.title": "${sequence} ${process}",
"terminal.integrated.tabs.description": "${cwd}"
}

216
README.md
View File

@@ -6,7 +6,6 @@
[![Slack](https://img.shields.io/badge/Slack-Join%20us-4A154B?logo=slack&logoColor=white)](https://dub.sh/browserOS-slack)
[![Twitter](https://img.shields.io/twitter/follow/browserOS_ai?style=social)](https://twitter.com/browseros_ai)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Docs](https://img.shields.io/badge/Docs-docs.browseros.com-blue)](https://docs.browseros.com)
<br></br>
<a href="https://files.browseros.com/download/BrowserOS.dmg">
<img src="https://img.shields.io/badge/Download-macOS-black?style=flat&logo=apple&logoColor=white" alt="Download for macOS (beta)" />
@@ -23,183 +22,146 @@
<br />
</div>
BrowserOS is an open-source Chromium fork that runs AI agents natively. **The privacy-first alternative to ChatGPT Atlas, Perplexity Comet, and Dia.**
##
🌐 BrowserOS is an open-source Chromium fork that runs AI agents natively. **The privacy-first alternative to ChatGPT Atlas, Perplexity Comet, and Dia.**
Use your own API keys or run local models with Ollama. Your data never leaves your machine.
🔒 Use your own API keys or run local models with Ollama. Your data never leaves your machine.
> **[Documentation](https://docs.browseros.com)** · **[Discord](https://discord.gg/YKwjt5vuKr)** · **[Slack](https://dub.sh/browserOS-slack)** · **[Twitter](https://x.com/browserOS_ai)** · **[Feature Requests](https://github.com/browseros-ai/BrowserOS/issues/99)**
💡 Join our [Discord](https://discord.gg/YKwjt5vuKr) or [Slack](https://dub.sh/browserOS-slack) and help us build! Have feature requests? [Suggest here](https://github.com/browseros-ai/BrowserOS/issues/99).
## Quick Start
## Quick start
1. **Download and install** BrowserOS — [macOS](https://files.browseros.com/download/BrowserOS.dmg) · [Windows](https://files.browseros.com/download/BrowserOS_installer.exe) · [Linux (AppImage)](https://files.browseros.com/download/BrowserOS.AppImage) · [Linux (Debian)](https://cdn.browseros.com/download/BrowserOS.deb)
2. **Import your Chrome data** (optional) — bookmarks, passwords, extensions all carry over
3. **Connect your AI provider** — Claude, OpenAI, Gemini, ChatGPT Pro via OAuth, or local models via Ollama/LM Studio
1. Download and install BrowserOS:
- [macOS](https://files.browseros.com/download/BrowserOS.dmg)
- [Windows](https://files.browseros.com/download/BrowserOS_installer.exe)
- [Linux (AppImage)](https://files.browseros.com/download/BrowserOS.AppImage)
- [Linux (Debian)](https://cdn.browseros.com/download/BrowserOS.deb)
## Features
2. Import your Chrome data (optional)
| Feature | Description | Docs |
|---------|-------------|------|
| **AI Agent** | 53+ browser automation tools — navigate, click, type, extract data, all with natural language | [Guide](https://docs.browseros.com/getting-started) |
| **MCP Server** | Control the browser from Claude Code, Gemini CLI, or any MCP client | [Setup](https://docs.browseros.com/features/use-with-claude-code) |
| **Workflows** | Build repeatable browser automations with a visual graph builder | [Docs](https://docs.browseros.com/features/workflows) |
| **Cowork** | Combine browser automation with local file operations — research the web, save reports to your folder | [Docs](https://docs.browseros.com/features/cowork) |
| **Scheduled Tasks** | Run agents on autopilot — daily, hourly, or every few minutes | [Docs](https://docs.browseros.com/features/scheduled-tasks) |
| **Memory** | Persistent memory across conversations — your assistant remembers context over time | [Docs](https://docs.browseros.com/features/memory) |
| **SOUL.md** | Define your AI's personality and instructions in a single markdown file | [Docs](https://docs.browseros.com/features/soul-md) |
| **LLM Hub** | Compare Claude, ChatGPT, and Gemini responses side-by-side on any page | [Docs](https://docs.browseros.com/features/llm-chat-hub) |
| **40+ App Integrations** | Gmail, Slack, GitHub, Linear, Notion, Figma, Salesforce, and more via MCP | [Docs](https://docs.browseros.com/features/connect-apps) |
| **Vertical Tabs** | Side-panel tab management — stay organized even with 100+ tabs open | [Docs](https://docs.browseros.com/features/vertical-tabs) |
| **Ad Blocking** | uBlock Origin + Manifest V2 support — [10x more protection](https://docs.browseros.com/features/ad-blocking) than Chrome | [Docs](https://docs.browseros.com/features/ad-blocking) |
| **Cloud Sync** | Sync browser config and agent history across devices | [Docs](https://docs.browseros.com/features/sync) |
| **Skills** | Custom instruction sets that shape how your AI assistant behaves | [Docs](https://docs.browseros.com/features/skills) |
| **Smart Nudges** | Contextual suggestions to connect apps and use features at the right moment | [Docs](https://docs.browseros.com/features/smart-nudges) |
3. Connect your AI provider — use Claude, OpenAI, Gemini, or local models via Ollama and LMStudio.
4. Start automating!
## What makes BrowserOS special
- 🏠 Feels like home — same Chrome interface, all your extensions just work
- 🤖 AI agents that run on YOUR browser, not in the cloud
- 🔒 Privacy first — bring your own keys or run local models with Ollama. Your browsing history stays on your machine
- 🤝 [BrowserOS as MCP server](https://docs.browseros.com/features/use-with-claude-code) — control the browser from `claude-code`, `gemini-cli`, or any MCP client (31 tools)
- 🔄 [Workflows](https://docs.browseros.com/features/workflows) — build repeatable browser automations with a visual graph builder
- 📂 [Cowork](https://docs.browseros.com/features/cowork) — combine browser automation with local file operations. Research the web, save reports to your folder
- ⏰ [Scheduled Tasks](https://docs.browseros.com/features/scheduled-tasks) — run the agent on autopilot, daily or every few minutes
- 💬 [LLM Hub](https://docs.browseros.com/features/llm-chat-hub) — compare Claude, ChatGPT, and Gemini side-by-side on any page
- 🛡️ Built-in ad blocker — [10x more protection than Chrome](https://docs.browseros.com/features/ad-blocking) with uBlock Origin + Manifest V2 support
- 🚀 100% open source under AGPL-3.0
## Demos
### BrowserOS agent in action
### 🤖 BrowserOS agent in action
[![BrowserOS agent in action](docs/videos/browserOS-agent-in-action.gif)](https://www.youtube.com/watch?v=SoSFev5R5dI)
<br/><br/>
### Install [BrowserOS as MCP](https://docs.browseros.com/features/use-with-claude-code) and control it from `claude-code`
### 🎇 Install [BrowserOS as MCP](https://docs.browseros.com/features/use-with-claude-code) and control it from `claude-code`
https://github.com/user-attachments/assets/c725d6df-1a0d-40eb-a125-ea009bf664dc
<br/><br/>
### Use BrowserOS to chat
### 💬 Use BrowserOS to chat
https://github.com/user-attachments/assets/726803c5-8e36-420e-8694-c63a2607beca
<br/><br/>
### Use BrowserOS to scrape data
### Use BrowserOS to scrape data
https://github.com/user-attachments/assets/9f038216-bc24-4555-abf1-af2adcb7ebc0
<br/><br/>
## Install `browseros-cli`
## Why We're Building BrowserOS
Use `browseros-cli` to launch and control BrowserOS from the terminal or from AI coding agents like Claude Code.
For the first time since Netscape pioneered the web in 1994, AI gives us the chance to completely reimagine the browser. We've seen tools like Cursor deliver 10x productivity gains for developers—yet everyday browsing remains frustratingly archaic.
**macOS / Linux:**
You're likely juggling 70+ tabs, battling your browser instead of having it assist you. Routine tasks, like ordering something from amazon or filling a form should be handled seamlessly by AI agents.
```bash
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
```
At BrowserOS, we're convinced that AI should empower you by automating tasks locally and securely—keeping your data private. We are building the best browser for this future!
**Windows:**
## How we compare
```powershell
irm https://cdn.browseros.com/cli/install.ps1 | iex
```
<details>
<summary><b>vs Chrome</b></summary>
<br>
While we're grateful for Google open-sourcing Chromium, but Chrome hasn't evolved much in 10 years. No AI features, no automation, no MCP support.
</details>
After install, run `browseros-cli init` to connect the CLI to your running BrowserOS instance.
<details>
<summary><b>vs Brave</b></summary>
<br>
We love what Brave started, but they've spread themselves too thin with crypto, search, VPNs. We're laser-focused on AI-powered browsing.
</details>
## LLM Providers
<details>
<summary><b>vs Arc/Dia</b></summary>
<br>
Many loved Arc, but it was closed source. When they abandoned users, there was no recourse. We're 100% open source - fork it anytime!
</details>
BrowserOS works with any LLM. Bring your own keys, use OAuth, or run models locally.
<details>
<summary><b>vs Perplexity Comet</b></summary>
<br>
They're a search/ad company. Your browser history becomes their product. We keep everything local.
</details>
| Provider | Type | Auth |
|----------|------|------|
| Kimi K2.5 | Cloud (default) | Built-in |
| ChatGPT Pro/Plus | Cloud | [OAuth](https://docs.browseros.com/features/chatgpt) |
| GitHub Copilot | Cloud | [OAuth](https://docs.browseros.com/features/github-copilot) |
| Qwen Code | Cloud | [OAuth](https://docs.browseros.com/features/qwen-code) |
| Claude (Anthropic) | Cloud | API key |
| GPT-4o / o3 (OpenAI) | Cloud | API key |
| Gemini (Google) | Cloud | API key |
| Azure OpenAI | Cloud | API key |
| AWS Bedrock | Cloud | IAM credentials |
| OpenRouter | Cloud | API key |
| Ollama | Local | [Setup](https://docs.browseros.com/features/ollama) |
| LM Studio | Local | [Setup](https://docs.browseros.com/features/lm-studio) |
## How We Compare
| | BrowserOS | Chrome | Brave | Dia | Comet | Atlas |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| Open Source | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| AI Agent | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ |
| MCP Server | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Visual Workflows | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Cowork (files + browser) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Scheduled Tasks | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Bring Your Own Keys | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Local Models (Ollama) | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Local-first Privacy | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Ad Blocking (MV2) | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
**Detailed comparisons:**
- [BrowserOS vs Chrome DevTools MCP](https://docs.browseros.com/comparisons/chrome-devtools-mcp) — developer-focused comparison for browser automation
- [BrowserOS vs Claude Cowork](https://docs.browseros.com/comparisons/claude-cowork) — getting real work done with AI
- [BrowserOS vs OpenClaw](https://docs.browseros.com/comparisons/openclaw) — everyday AI assistance
## Architecture
BrowserOS is a monorepo with two main subsystems: the **browser** (Chromium fork) and the **agent platform** (TypeScript/Go).
```
BrowserOS/
├── packages/browseros/ # Chromium fork + build system (Python)
│ ├── chromium_patches/ # Patches applied to Chromium source
│ ├── build/ # Build CLI and modules
│ └── resources/ # Icons, entitlements, signing
├── packages/browseros-agent/ # Agent platform (TypeScript/Go)
│ ├── apps/
│ │ ├── server/ # MCP server + AI agent loop (Bun)
│ │ ├── agent/ # Browser extension UI (WXT + React)
│ │ ├── cli/ # CLI tool (Go)
│ │ ├── eval/ # Benchmark framework
│ │ └── controller-ext/ # Chrome API bridge extension
│ │
│ └── packages/
│ ├── agent-sdk/ # Node.js SDK (npm: @browseros-ai/agent-sdk)
│ ├── cdp-protocol/ # CDP type bindings
│ └── shared/ # Shared constants
```
| Package | What it does |
|---------|-------------|
| [`packages/browseros`](packages/browseros/) | Chromium fork — patches, build system, signing |
| [`apps/server`](packages/browseros-agent/apps/server/) | Bun server exposing 53+ MCP tools and running the AI agent loop |
| [`apps/agent`](packages/browseros-agent/apps/agent/) | Browser extension — new tab, side panel chat, onboarding, settings |
| [`apps/cli`](packages/browseros-agent/apps/cli/) | Go CLI — control BrowserOS from the terminal or AI coding agents |
| [`apps/eval`](packages/browseros-agent/apps/eval/) | Benchmark framework — WebVoyager, Mind2Web evaluation |
| [`agent-sdk`](packages/browseros-agent/packages/agent-sdk/) | Node.js SDK for browser automation with natural language |
| [`cdp-protocol`](packages/browseros-agent/packages/cdp-protocol/) | Type-safe Chrome DevTools Protocol bindings |
<details>
<summary><b>vs ChatGPT Atlas</b></summary>
<br>
Your browsing data could be used for ads or to train their models. We keep your history and agent interactions strictly local.
</details>
## Contributing
We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRIBUTING.md) for details.
We'd love your help making BrowserOS better!
- [Report bugs](https://github.com/browseros-ai/BrowserOS/issues)
- [Suggest features](https://github.com/browseros-ai/BrowserOS/issues/99)
- [Join Discord](https://discord.gg/YKwjt5vuKr) · [Join Slack](https://dub.sh/browserOS-slack)
- [Follow on Twitter](https://x.com/browserOS_ai)
**Agent development** (TypeScript/Go) — see the [agent monorepo README](packages/browseros-agent/README.md) for setup instructions.
**Browser development** (C++/Python) — requires ~100GB disk space. See [`packages/browseros`](packages/browseros/) for build instructions.
## Credits
- [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.
- 🐛 [Report bugs](https://github.com/browseros-ai/BrowserOS/issues)
- 💡 [Suggest features](https://github.com/browseros-ai/BrowserOS/issues/99)
- 💬 [Join Discord](https://discord.gg/YKwjt5vuKr)
- 🐦 [Follow on Twitter](https://x.com/browserOS_ai)
## License
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
Copyright &copy; 2026 Felafax, Inc.
## Credits
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) - BrowserOS uses some patches for enhanced privacy. Thanks to everyone behind this project!
- [The Chromium Project](https://www.chromium.org/) - At the core of BrowserOS, making it possible to exist in the first place.
## Citation
If you use BrowserOS in your research or project, please cite:
```bibtex
@software{browseros2025,
author = {Sonti, Nithin and Sonti, Nikhil 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},
}
```
Copyright &copy; 2025 Felafax, Inc.
## Stargazers
Thank you to all our supporters!
[![Star History Chart](https://api.star-history.com/svg?repos=browseros-ai/BrowserOS&type=Date)](https://www.star-history.com/#browseros-ai/BrowserOS&Date)
##
<p align="center">
Built with ❤️ from San Francisco
</p>

View File

@@ -23,9 +23,6 @@
"group": "Core Features",
"pages": [
"features/bring-your-own-llm",
"features/chatgpt-pro-oauth",
"features/github-copilot-oauth",
"features/qwen-code-oauth",
"features/local-models",
"features/workflows",
"features/scheduled-tasks",

View File

@@ -3,17 +3,13 @@ title: "Ad Blocking"
description: "BrowserOS supports full ad blocking with uBlock Origin"
---
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most powerful open-source ad blocker available — the full extension, not the watered-down "Lite" version.
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most effective open-source ad blocker available.
## Why BrowserOS?
## How It Works
Chrome [killed support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for uBlock Origin by phasing out Manifest V2 extensions. The only option left on Chrome is "uBlock Origin Lite," a significantly weaker version that can't use advanced filtering rules.
Chrome has been [phasing out support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for Manifest V2 extensions, which uBlock Origin relies on for its full blocking capabilities. We re-enabled Manifest V2 support in BrowserOS so uBlock Origin can run at full power.
**BrowserOS re-enabled full Manifest V2 support**, so you can install and run the original uBlock Origin at full power — the same extension Chrome no longer allows.
<Card title="Install uBlock Origin" icon="shield-check" href="https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">
Install the full uBlock Origin extension from the Chrome Web Store. Works on BrowserOS out of the box.
</Card>
Install it from the Chrome Web Store: [uBlock Origin](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm)
## BrowserOS vs Chrome

View File

@@ -13,33 +13,6 @@ See how to connect your own LLM in under a minute:
src="https://pub-80f8a01e6e8b4239ae53a7652ef85877.r2.dev/resources/feature-videos/1-bring-your-own-LLM.mov"
></video>
## Use Your Existing Subscription
Already paying for ChatGPT Pro, GitHub Copilot, or Qwen Code? Connect your existing account to BrowserOS with a single sign-in — no API keys, no extra cost.
<CardGroup cols={3}>
<Card href="/features/chatgpt-pro-oauth">
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
**ChatGPT Pro / Plus**
Sign in with your OpenAI account. Access GPT-5 Codex, GPT-5.4, and the full Codex lineup with up to 400K context.
</Card>
<Card href="/features/github-copilot-oauth">
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
**GitHub Copilot**
Sign in with your GitHub account. Access 19+ models including Claude, GPT-5, and Gemini through one subscription.
</Card>
<Card href="/features/qwen-code-oauth">
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
**Qwen Code**
Sign in with your Qwen account. Access Qwen 3 Coder with a 1 million token context window.
</Card>
</CardGroup>
---
## Which Model Should I Use?
| Mode | What works | Recommendation |
@@ -131,29 +104,6 @@ Connect to powerful AI models using your API keys. Your keys stay on your machin
![Gemini config](/images/byollm--gemini-provider-config.png)
</Accordion>
<div id="nvidia" />
<Accordion title="NVIDIA (Free)" icon="microchip">
NVIDIA's [build.nvidia.com](https://build.nvidia.com/models) hosts 80+ models — including GLM 5.1, MiniMax M2.7, GPT-OSS-120B, Qwen 3.5, Mistral, and Nemotron — behind a **free OpenAI-compatible API endpoint**. Great for chatting, prototyping, and personal projects.
**Get your API key:**
1. Go to [build.nvidia.com/models](https://build.nvidia.com/models) and sign in with a free NVIDIA developer account
2. Pick any model tagged **Free Endpoint** (e.g. [`minimaxai/minimax-m2.7`](https://build.nvidia.com/minimaxai/minimax-m2.7), [`z-ai/glm-5.1`](https://build.nvidia.com/z-ai/glm-5.1), [`qwen/qwen3.5-122b-a10b`](https://build.nvidia.com/qwen/qwen3.5-122b-a10b))
3. Click **Get API Key** on the model page and copy the `nvapi-...` key
**Add to BrowserOS:**
1. Go to `chrome://browseros/settings`
2. Click **USE** on the **OpenAI Compatible** card
3. Set **Base URL** to `https://integrate.api.nvidia.com/v1`
4. Set **Model ID** to a model from the catalog (e.g. `minimaxai/minimax-m2.7`, `z-ai/glm-5.1`, `qwen/qwen3.5-122b-a10b`)
5. Paste your NVIDIA API key
6. Set **Context Window** based on the model (most are `128000` or higher)
7. Click **Save**
<Tip>
NVIDIA's free endpoints share GPU capacity across all developers, so throughput is slower than a paid API. They're best for Chat Mode, exploring new open-source models, and personal projects. For production agent workloads, use a paid provider like Claude or Kimi.
</Tip>
</Accordion>
<div id="claude" />
<Accordion title="Claude (Best for Agents)" icon="message-bot">
Claude Opus 4.5 gives the best results for Agent Mode.

View File

@@ -1,56 +0,0 @@
---
title: "ChatGPT Pro / Plus"
description: "Use your ChatGPT subscription to power BrowserOS"
---
Connect your ChatGPT Pro or Plus subscription to BrowserOS and access GPT-5 Codex, GPT-5.4, and the full lineup of OpenAI's most advanced models — with up to 400K context. No API keys needed.
## Setup
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
![AI Settings screen](/images/setting-up-chatgpt/llm-screen.png)
**2.** Click **USE** on the **ChatGPT Plus/Pro** card. You'll be prompted to sign in with your OpenAI account.
![Login screen](/images/setting-up-chatgpt/login-screen.png)
**3.** Sign in with the OpenAI account that has your ChatGPT Pro or Plus subscription active, and accept the authorization.
![Accept authorization](/images/setting-up-chatgpt/accept-screen.png)
**4.** Once authorized, ChatGPT will appear as a provider in your settings. Select a model and start using it.
## Available Models
| Model | Context Window |
|-------|---------------|
| `gpt-5.4` | 400K |
| `gpt-5.3-codex` | 400K |
| `gpt-5.2-codex` | 400K |
| `gpt-5.2` | 200K |
| `gpt-5.1-codex` | 400K |
| `gpt-5.1-codex-max` | 400K |
| `gpt-5.1-codex-mini` | 400K |
| `gpt-5.1` | 200K |
<Info>
ChatGPT Pro subscribers have access to the full model lineup. ChatGPT Plus subscribers can access a subset of models depending on their plan. The available models will be shown automatically after you connect.
</Info>
<Tip>
The Codex models (e.g., `gpt-5.3-codex`) are optimized for code and reasoning tasks — ideal for complex browser automation workflows that involve form filling, data extraction, and multi-step navigation.
</Tip>
## Reasoning Settings
ChatGPT Pro includes additional settings for models that support reasoning:
- **Reasoning Effort** — Control how much the model "thinks" before responding. Options: none, low, medium, high.
- **Reasoning Summary** — Choose how reasoning is displayed. Options: auto, concise, detailed.
These settings are available in the provider configuration after connecting.
## Disconnecting
To disconnect your OpenAI account, go to **Settings**, find the ChatGPT Plus/Pro provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.

View File

@@ -1,60 +0,0 @@
---
title: "GitHub Copilot"
description: "Use your GitHub Copilot subscription to power BrowserOS"
---
Connect your GitHub Copilot subscription to BrowserOS and access 19+ models — including Claude, GPT-5, and Gemini — through a single GitHub sign-in. No API keys needed.
<Info>
**Free tier** includes GPT-5 Mini, Claude Haiku 4.5, GPT-4o, and GPT-4.1. **Copilot Pro** ($10/month) unlocks Claude Sonnet 4.6, Claude Opus 4.6, Gemini 3 Pro, GPT-5.4, and more.
</Info>
## Setup
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
![AI Settings screen](/images/setting-up-copilot/llm-screen.png)
**2.** Click **USE** on the **GitHub Copilot** card. A device code will appear — copy it, then click the link to open GitHub's device authorization page.
![Device code displayed](/images/setting-up-copilot/device-code.png)
**3.** Select your GitHub account to authorize.
![Select GitHub account](/images/setting-up-copilot/select-account.png)
**4.** Paste the device code and authorize BrowserOS to access your Copilot subscription.
![Authorize device](/images/setting-up-copilot/authorize-device.png)
**5.** Once authorized, GitHub Copilot will appear as a provider in your settings. Select a model and start using it.
## Available Models
### Free Tier
| Model | Context Window |
|-------|---------------|
| `gpt-5-mini` | 128K |
| `claude-haiku-4.5` | 128K |
| `gpt-4o` | 64K |
| `gpt-4.1` | 64K |
### Copilot Pro / Pro+
| Model | Context Window |
|-------|---------------|
| `claude-sonnet-4.6` | 200K |
| `claude-opus-4.6` | 200K |
| `gemini-2.5-pro` | 1M |
| `gemini-3-pro-preview` | 1M |
| `gpt-5.4` | 400K |
| `gpt-5.3-codex` | 400K |
| `gpt-5.2-codex` | 400K |
| `grok-code-fast-1` | 128K |
<Tip>
GitHub Copilot is the most versatile provider — one subscription gives you access to models from OpenAI, Anthropic, Google, and xAI. Great if you want to switch between models for different tasks.
</Tip>
## Disconnecting
To disconnect your GitHub account, go to **Settings**, find the GitHub Copilot provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.

View File

@@ -1,39 +0,0 @@
---
title: "Qwen Code"
description: "Use your Qwen Code account to power BrowserOS"
---
Connect your Qwen Code account to BrowserOS and access Alibaba's coding models with up to a **1 million token context window** — the largest of any provider we support. No API keys needed.
## Setup
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
![AI Settings screen](/images/setting-up-qwen/llm-screen.png)
**2.** Click **USE** on the **Qwen Code** card. You'll be prompted to sign in with your Qwen account.
![Select Qwen Code](/images/setting-up-qwen/select-qwen.png)
**3.** Sign in with your Alibaba Cloud / Qwen account to authorize BrowserOS.
![Qwen sign in](/images/setting-up-qwen/qwen-signin.png)
**4.** Once authorized, Qwen Code will appear as a provider in your settings. Select a model and start using it.
## Available Models
| Model | Context Window |
|-------|---------------|
| `coder-model` | 1M |
| `qwen3-coder-plus` | 1M |
| `qwen3-coder-flash` | 1M |
| `qwen3.5-plus` | 1M |
<Tip>
Qwen Code's 1 million token context window is ideal for tasks that involve long documents, entire documentation sites, or working across many browser tabs simultaneously — the agent can hold everything in context at once.
</Tip>
## Disconnecting
To disconnect your Qwen account, go to **Settings**, find the Qwen Code provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 KiB

View File

@@ -42,10 +42,6 @@ Welcome to BrowserOS! Let's get you set up.
## You're all set!
<Tip>
**Block ads with uBlock Origin** — Chrome dropped support for the full uBlock Origin extension, but BrowserOS brought it back. [Install it from the Chrome Web Store](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm) and browse ad-free. [Learn more →](/features/ad-blocking)
</Tip>
Explore what BrowserOS can do:
<Columns cols={2}>

View File

@@ -1,57 +0,0 @@
commit-msg:
commands:
conventional:
run: |
msg=$(head -1 {1})
if [[ ! "$msg" =~ ^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?\!?:\ .+ ]]; then
echo "Commit message must follow Conventional Commits format:"
echo " <type>(<optional scope>): <description>"
echo " Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
echo ""
echo "Examples:"
echo " feat(auth): add OAuth2 support"
echo " fix: resolve null pointer exception"
exit 1
fi
pre-commit:
commands:
biome-check:
root: "packages/browseros-agent/"
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true
file-length:
root: "packages/browseros-agent/"
glob: "*.{ts,tsx}"
exclude: "*.{test,spec,d}.ts|*.{test,spec}.tsx|**/__tests__/**|**/tests/**|**/*.generated.*"
run: |
for file in {staged_files}; do
if [[ -f "$file" ]]; then
lines=$(wc -l < "$file" | tr -d ' ')
if [[ $lines -gt 400 ]]; then
echo "⚠️ Warning: $file has $lines lines (threshold: 400)"
echo " Consider splitting this file if it has multiple responsibilities."
fi
fi
done
pre-push:
commands:
branch-name:
run: |
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
exit 0
fi
if [[ ! "$branch" =~ ^(feat|fix|bugfix|hotfix|release|docs|refactor|test|chore|experiment)/[a-z0-9-]+$ ]]; then
echo "⚠️ Warning: Branch name '$branch' doesn't match recommended format."
echo " Use: <type>/<short-description>"
echo " Types: feat, fix, bugfix, hotfix, release, docs, refactor, test, chore, experiment"
echo " Example: feat/add-auth, fix/login-crash"
echo ""
echo " To rename your branch:"
echo " git branch -m <new-name>"
echo " git push -u origin <new-name>"
fi

View File

@@ -0,0 +1,41 @@
version: 2
updates:
- package-ecosystem: bun
directory: /
schedule:
interval: weekly
day: 'sunday'
time: '02:00'
timezone: Europe/Berlin
open-pull-requests-limit: 10
groups:
dependencies:
applies-to: security-updates
dependency-type: production
exclude-patterns:
- 'puppeteer*'
patterns:
- '*'
dev-dependencies:
applies-to: security-updates
dependency-type: development
exclude-patterns:
- 'puppeteer*'
patterns:
- '*'
puppeteer:
patterns:
- 'puppeteer*'
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: 'sunday'
time: '04:00'
timezone: Europe/Berlin
open-pull-requests-limit: 10
groups:
all:
applies-to: security-updates
patterns:
- '*'

View File

@@ -9,9 +9,6 @@ on:
jobs:
security-audit:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/browseros-agent
steps:
- name: Checkout code

View File

@@ -0,0 +1,58 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
cla:
runs-on: ubuntu-latest
if: |
(github.event_name == 'pull_request_target') ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
steps:
- name: CLA Assistant
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }}
with:
path-to-signatures: 'cla-signatures.json'
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md'
branch: 'main'
remote-organization-name: 'browseros-ai'
remote-repository-name: 'cla-signatures'
allowlist: 'bot*,*[bot],dependabot,renovate,github-actions,snyk-bot,imgbot,greenkeeper,semantic-release-bot,allcontributors'
lock-pullrequest-aftermerge: false
custom-notsigned-prcomment: |
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement](https://github.com/${{ github.repository }}/blob/main/CLA.md).
**To sign the CLA**, please add a comment to this PR with the following text:
```
I have read the CLA Document and I hereby sign the CLA
```
You only need to sign once. After signing, this check will pass automatically.
---
<details>
<summary>Troubleshooting</summary>
- **Already signed but still failing?** Comment `recheck` to trigger a re-verification.
- **Signed with a different email?** Make sure your commit email matches your GitHub account email, or add your commit email to your GitHub account.
</details>
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: |
All contributors have signed the CLA. Thank you!

View File

@@ -22,11 +22,11 @@ jobs:
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
contents: write # Can push branches and create commits
pull-requests: write # Can create and update PRs
issues: read
id-token: write
actions: read
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -38,5 +38,11 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Allow all tools - branch protection rules at repo level prevent direct pushes to main/master
# Omitting --allowedTools means all tools are available by default

View File

@@ -4,16 +4,11 @@ on:
pull_request:
branches:
- main
paths:
- "packages/browseros-agent/**"
jobs:
biome:
name: runner / Biome
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/browseros-agent
permissions:
contents: read
steps:
@@ -33,9 +28,6 @@ jobs:
typecheck:
name: runner / Typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/browseros-agent
permissions:
contents: read
steps:
@@ -50,9 +42,6 @@ jobs:
- name: Install dependencies
run: bun ci
- name: Prepare wxt
run: VITE_PUBLIC_BROWSEROS_API=http://localhost:3000 bun run --cwd apps/agent wxt prepare
- name: Run codegen
run: bun run --cwd apps/agent codegen

View File

@@ -2,12 +2,12 @@ name: PR Conventional Commit Validation
on:
pull_request:
types: [opened, edited]
types: [opened, synchronize, reopened, edited]
permissions:
pull-requests: write
issues: write
contents: read
pull-requests: write # Read PR details and add labels
issues: write # Labels are managed via issues API
contents: read # Read repository content
jobs:
validate-pr-title:

View File

@@ -0,0 +1,37 @@
name: Release Agent SDK
on:
workflow_dispatch:
jobs:
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/agent-sdk
steps:
- uses: actions/checkout@v6
- 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: .
- name: Build
run: bun run build
- name: Test
run: bun test
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -0,0 +1,24 @@
name: Tests
on: []
jobs:
test:
name: Run Tests
runs-on: macos-latest
timeout-minutes: 10
steps:
- name: 📥 Checkout code
uses: actions/checkout@v6
- name: 🧰 Setup Bun
uses: oven-sh/setup-bun@v2
- name: 📦 Install dependencies
run: bun ci
- name: 🧪 Run all tests
run: bun test:all
env:
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

View File

@@ -187,12 +187,7 @@ log.txt
# Testing iteration temp files
tmp/
# CI artifacts
.ci/
test-results/
# Coding agent artifacts
.agent/
.llm/
.grove/
docs/plans/2026-03-24-models-dev-integration.md

View File

@@ -32,7 +32,7 @@ Use **kebab-case** for all file and folder names:
| Multi-word files | kebab-case | `gemini-agent.ts`, `mcp-context.ts` |
| Single-word files | lowercase | `types.ts`, `browser.ts`, `index.ts` |
| Test files | `.test.ts` suffix | `mcp-context.test.ts` |
| Folders | kebab-case | `rate-limiter/`, `browser-tools/` |
| Folders | kebab-case | `controller-server/`, `rate-limiter/` |
Classes remain PascalCase in code, but live in kebab-case files:
```typescript
@@ -81,9 +81,6 @@ bun run dev:server # Build server for development
bun run dev:ext # Build extension for development
bun run dist:server # Build server for production (all targets)
bun run dist:ext # Build extension for production
# Refresh models.dev data
bun run generate:models # Fetches latest from models.dev/api.json
```
## Architecture
@@ -97,16 +94,21 @@ The main MCP server that exposes browser automation tools via HTTP/SSE.
**Key components:**
- `src/tools/` - MCP tool definitions, split into:
- `cdp-based/` - Tools using Chrome DevTools Protocol (navigation, DOM interaction, network, console, emulation, input, etc.)
- `cdp-based/` - Tools using Chrome DevTools Protocol (network, console, emulation, input, etc.)
- `controller-based/` - Tools using the browser extension (navigation, clicks, screenshots, tabs, history, bookmarks)
- `src/controller-server/` - WebSocket server that bridges to the browser extension
- `ControllerBridge` handles WebSocket connections with extension clients
- `ControllerContext` wraps the bridge for tool handlers
- `src/common/` - Shared utilities (McpContext, PageCollector, browser connection, identity, db)
- `src/agent/` - AI agent functionality (Gemini adapter, rate limiting, session management)
- `src/http/` - Hono HTTP server with MCP, health, and provider routes
**Tool types:**
- CDP tools require a direct CDP connection (`--cdp-port`)
- Controller tools work via the browser extension over WebSocket
### Shared (`packages/shared`)
Shared constants, types, and configuration used across packages. Avoids magic numbers.
Shared constants, types, and configuration used by both server and extension. Avoids magic numbers.
**Structure:**
- `src/constants/` - Configuration values (ports, timeouts, limits, urls, paths)
@@ -114,12 +116,22 @@ Shared constants, types, and configuration used across packages. Avoids magic nu
**Exports:** `@browseros/shared/constants/*`, `@browseros/shared/types/*`
### Controller Extension (`apps/controller-ext`)
Chrome extension that receives commands from the server via WebSocket.
**Entry point:** `src/background/index.ts` → `BrowserOSController`
**Structure:**
- `src/actions/` - Action handlers organized by domain (browser/, tab/, bookmark/, history/)
- `src/adapters/` - Chrome API adapters (TabAdapter, BookmarkAdapter, HistoryAdapter)
- `src/websocket/` - WebSocket client that connects to the server
### Communication Flow
```
AI Agent/MCP Client → HTTP Server (Hono) → Tool Handler
CDP → BrowserOS / Chrome APIs
CDP (direct) ←── or ──→ WebSocket → Extension → Chrome APIs
```
## Creating Packages

View File

@@ -1,6 +1,8 @@
# BrowserOS Agent
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, evaluation framework, and SDK.
Monorepo for the BrowserOS-agent -- contains 3 packages: agent-UI, server (which contains the agent loop) and controller-extension (which is used by the tools within the agent loop).
> **⚠️ NOTE:** This is only a submodule, the main project is at -- https://github.com/browseros-ai/BrowserOS
## Monorepo Structure
@@ -8,29 +10,24 @@ The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserO
apps/
server/ # Bun server - MCP endpoints + agent loop
agent/ # Agent UI (Chrome extension)
cli/ # Go CLI for controlling BrowserOS from the terminal
eval/ # Evaluation framework for benchmarking agents
controller-ext/ # BrowserOS Controller (Chrome extension for chrome.* APIs)
packages/
agent-sdk/ # Node.js SDK (@browseros-ai/agent-sdk)
cdp-protocol/ # Type-safe Chrome DevTools Protocol bindings
shared/ # Shared constants (ports, timeouts, limits)
```
| Package | Description |
|---------|-------------|
| `apps/server` | Bun server exposing MCP tools and running the agent loop |
| `apps/agent` | Agent UI Chrome extension for the chat interface |
| `apps/cli` | Go CLI — control BrowserOS from the terminal or AI coding agents |
| `apps/eval` | Benchmark framework — WebVoyager, Mind2Web evaluation |
| `packages/agent-sdk` | Node.js SDK for browser automation with natural language |
| `packages/cdp-protocol` | Auto-generated CDP type bindings used by the server |
| `apps/agent` | Agent UI - Chrome extension for the chat interface |
| `apps/controller-ext` | BrowserOS Controller - Chrome extension that bridges `chrome.*` APIs (tabs, bookmarks, history) to the server via WebSocket |
| `packages/shared` | Shared constants used across packages |
## Architecture
- `apps/server`: Bun server which contains the agent loop and tools.
- `apps/agent`: Agent UI (Chrome extension).
- `apps/controller-ext`: BrowserOS Controller - a Chrome extension that bridges `chrome.*` APIs to the server. Controller tools within the server communicate with this extension via WebSocket.
```
┌──────────────────────────────────────────────────────────────────────────┐
@@ -48,19 +45,19 @@ packages/
│ /health ─── Health check │
│ │
│ Tools: │
── CDP-backed browser tools (tabs, navigation, input, screenshots, │
bookmarks, history, console, DOM, tab groups, windows, ...)
── CDP Tools (console, network, input, screenshot, ...)
└── Controller Tools (tabs, navigation, clicks, bookmarks, history)
└──────────────────────────────────────────────────────────────────────────┘
CDP (client)
─────────────────────┐
Chromium CDP
(cdpPort: 9000) │
│ │
Server connects
│ TO this as client
─────────────────────┘
│ CDP (client)WebSocket (server)
┌─────────────────────┐ ┌─────────────────────────────────────┐
Chromium CDP BrowserOS Controller Extension
(cdpPort: 9000) (extensionPort: 9300)
│ Server connects Bridges chrome.tabs, chrome.history
│ TO this as client │ │ chrome.bookmarks to the server
└─────────────────────┘ └─────────────────────────────────────┘
```
### Ports
@@ -69,7 +66,7 @@ packages/
|------|--------------|---------|
| 9100 | `BROWSEROS_SERVER_PORT` | HTTP server - MCP endpoints, agent chat, health |
| 9000 | `BROWSEROS_CDP_PORT` | Chromium CDP server (BrowserOS Server connects as client) |
| 9300 | `BROWSEROS_EXTENSION_PORT` | Legacy BrowserOS launch arg kept for compatibility; not used by the server |
| 9300 | `BROWSEROS_EXTENSION_PORT` | WebSocket server for controller extension |
## Development
@@ -93,8 +90,9 @@ process-compose up
The `process-compose up` command runs the following in order:
1. `bun install` — installs dependencies
2. `bun --cwd apps/agent codegen` — generates agent code
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
2. `bun --cwd apps/controller-ext build` — builds the controller extension
3. `bun --cwd apps/agent codegen` — generates agent code
4. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
### Environment Variables
@@ -110,7 +108,7 @@ Runtime uses `.env.development`, while production artifact builds use `.env.prod
|----------|---------|-------------|
| `BROWSEROS_SERVER_PORT` | 9100 | HTTP server port (MCP, chat, health) |
| `BROWSEROS_CDP_PORT` | 9000 | Chromium CDP port (server connects as client) |
| `BROWSEROS_EXTENSION_PORT` | 9300 | Legacy BrowserOS launch arg kept for compatibility |
| `BROWSEROS_EXTENSION_PORT` | 9300 | WebSocket port for controller extension |
| `BROWSEROS_CONFIG_URL` | - | Remote config endpoint for rate limits |
| `BROWSEROS_INSTALL_ID` | - | Unique installation identifier (analytics) |
| `BROWSEROS_CLIENT_ID` | - | Client identifier (analytics) |
@@ -142,7 +140,7 @@ Copy from `apps/server/.env.production.example` before running `build:server`.
|----------|---------|-------------|
| `BROWSEROS_SERVER_PORT` | 9100 | Passed to BrowserOS via CLI args |
| `BROWSEROS_CDP_PORT` | 9000 | Passed to BrowserOS via CLI args |
| `BROWSEROS_EXTENSION_PORT` | 9300 | Legacy BrowserOS CLI arg still passed for compatibility |
| `BROWSEROS_EXTENSION_PORT` | 9300 | Passed to BrowserOS via CLI args |
| `VITE_BROWSEROS_SERVER_PORT` | 9100 | Agent UI connects to server (must match `BROWSEROS_SERVER_PORT`) |
| `BROWSEROS_BINARY` | - | Path to BrowserOS binary |
| `USE_BROWSEROS_BINARY` | true | Use BrowserOS instead of default Chrome |
@@ -159,13 +157,15 @@ bun run start:server # Start the server
bun run start:agent # Start agent extension (dev mode)
# Build
bun run build # Build server and agent
bun run build # Build server, agent, and controller extension
bun run build:server # Build production server resource artifacts and upload zips to R2
bun run build:agent # Build agent extension
bun run build:ext # Build controller extension
# Test
bun run test # Run standard tests
bun run test:cdp # Run CDP-based tests
bun run test:controller # Run controller-based tests
bun run test:integration # Run integration tests
# Quality

View File

@@ -15,6 +15,9 @@ VITE_PUBLIC_SENTRY_DSN=
# BrowserOS API URL
VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com
# Launch feature flags
VITE_PUBLIC_KIMI_LAUNCH=false
# GraphQL Schema Path (optional — falls back to schema/schema.graphql)
GRAPHQL_SCHEMA_PATH=

View File

@@ -1,30 +0,0 @@
# BrowserOS Agent Extension
## v0.0.99 (2026-04-08)
## What's Changed
- chore: bump server and extension version (#659)
- chore(agent): remove workflows feature (#656)
- feat: replace model picker with shadcn Combobox + fuse.js fuzzy search (#617)
- feat: clean-up - remove obsolete controller extension (#610)
- docs: update agent extension changelog for v0.0.98 (#609)
## v0.0.98 (2026-03-27)
## What's Changed
- chore: update agent version (#608)
- chore: fix version number for extension (#606)
- fix: improve chat history freshness and reduce query payload (#598)
- feat: isolate new-tab agent navigation from origin tab (#593)
- docs: overhaul READMEs across all major packages (#594)
- fix(ui): resolve MCP promo banner dismiss button overlapping with text (#581)
- docs: update agent extension changelog for v0.0.52 (#573)
## v0.0.52 (2026-03-26)
Initial release

View File

@@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|------|------------|---------|
| Folders | kebab-case | `ai-settings/`, `jtbd-popup/`, `llm-hub/` |
| React components (.tsx) | PascalCase | `AISettingsPage.tsx`, `SurveyHeader.tsx` |
| Hooks (.ts) | camelCase with `use` prefix | `useVoiceInput.ts`, `useMessageTree.ts` |
| Hooks (.ts) | camelCase with `use` prefix | `useRunWorkflow.ts`, `useVoiceInput.ts` |
| Non-component files (.ts) | lowercase | `types.ts`, `models.ts`, `storage.ts` |
## Project Overview

View File

@@ -1,24 +1,16 @@
# BrowserOS Agent Extension
# BrowserOS Agent Chrome Extension
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](../../../../LICENSE)
The built-in browser extension that powers BrowserOS's AI interface — new tab with unified search, side panel chat, onboarding, and settings. Built with [WXT](https://wxt.dev) and React.
> For user-facing feature documentation, see [docs.browseros.com](https://docs.browseros.com).
The official Chrome extension for BrowserOS Agent, providing the UI layer for interacting with BrowserOS Core and Controllers. This extension enables intelligent browser automation, AI-powered search, and seamless integration with multiple LLM providers.
## Features
- **AI-Powered New Tab**: Custom new tab page with unified search across Google and AI assistants
- **Side Panel Chat**: Full-featured chat interface for interacting with BrowserOS
- **Side Panel Chat**: Full-featured chat interface for interacting with BrowserOS Core
- **Multi-Provider Support**: Connect to various LLM providers (OpenAI, Anthropic, Azure, Bedrock, and more)
- **MCP Integration**: Model Context Protocol support for extending AI capabilities
- **Visual Feedback**: Animated glow effect on tabs during AI agent operations
- **Privacy-First**: Local data handling with configurable provider settings
## How It Connects
The extension communicates with the [BrowserOS Server](../../apps/server/) running locally. The server handles the AI agent loop, MCP tools, and CDP connections — the extension provides the UI layer.
## Project Structure
```
@@ -88,20 +80,47 @@ Settings dashboard with multiple sections:
Content script that creates a visual indicator (pulsing orange glow) around the browser viewport when an AI agent is actively working on a tab.
## How Tools Are Used
### Bun
Bun is the exclusive runtime and package manager:
- All scripts use `bun run <script>` instead of npm
- Package installation via `bun install`
- Environment files automatically loaded (no dotenv needed)
- Enforced via `engines` field in `package.json`
```bash
bun install # Install dependencies
bun run dev # Development mode
bun run build # Production build
bun run lint # Run Biome linting
```
### Biome
Unified linter and formatter configured in `biome.json`:
- **Formatting**: 2-space indentation, single quotes, no semicolons
- **Linting**: Recommended rules plus custom rules for unused imports/variables
- **CSS Support**: Tailwind directives parsing enabled
- **Import Organization**: Automatic import sorting via assist actions
```bash
bun run lint # Check for issues
bun run lint:fix # Auto-fix issues
```
## Development
### Prerequisites
- [Bun](https://bun.sh) installed
- Chrome or Chromium-based browser
- BrowserOS Server running locally (for full functionality)
- BrowserOS Core running locally (for full functionality)
### Setup
```bash
# Copy environment file
cp .env.example .env.development
# Install dependencies
bun install
@@ -134,30 +153,12 @@ SENTRY_AUTH_TOKEN=your-token
### GraphQL Schema
Codegen requires a GraphQL schema. By default it uses the bundled `schema/schema.graphql`, so no extra setup is needed. If you have access to the original API source, you can set the following environment variable:
Codegen requires a GraphQL schema. By default it uses the bundled `schema/schema.graphql`, so no extra setup is needed. If you have access to the original API source, you can set the following environment variable
```env
GRAPHQL_SCHEMA_PATH=/path/to/api-repo/.../schema.graphql
```
## Development Tooling
### Bun
Bun is the exclusive runtime and package manager:
- All scripts use `bun run <script>` instead of npm
- Package installation via `bun install`
- Environment files automatically loaded (no dotenv needed)
- Enforced via `engines` field in `package.json`
### Biome
Unified linter and formatter configured in `biome.json`:
- **Formatting**: 2-space indentation, single quotes, no semicolons
- **Linting**: Recommended rules plus custom rules for unused imports/variables
- **CSS Support**: Tailwind directives parsing enabled
- **Import Organization**: Automatic import sorting via assist actions
## Scripts
| Script | Description |
@@ -168,5 +169,4 @@ Unified linter and formatter configured in `biome.json`:
| `bun run lint` | Run Biome linter |
| `bun run lint:fix` | Auto-fix linting issues |
| `bun run typecheck` | Run TypeScript type checking |
| `bun run codegen` | Generate GraphQL types |
| `bun run clean:cache` | Clear build caches |

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"root": false,
"extends": "//",
"vcs": {

View File

@@ -66,7 +66,7 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
return (
<Dialog open={!!run} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[70vw] sm:max-w-4xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{run.status === 'completed' ? (
@@ -94,7 +94,7 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
<p className="text-destructive text-sm">{run.result}</p>
</div>
) : run.result ? (
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4 [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4">
<MessageResponse>{run.result}</MessageResponse>
</div>
) : (

View File

@@ -1,4 +1,4 @@
import { Check, Plus } from 'lucide-react'
import { Check } from 'lucide-react'
import type { FC, PropsWithChildren } from 'react'
import { useState } from 'react'
import {
@@ -77,19 +77,6 @@ export const ChatProviderSelector: FC<
)
})}
</CommandGroup>
<div className="border-border border-t p-1">
<button
type="button"
className="flex w-full items-center gap-3 rounded-md p-2 text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
window.open('/app.html#/settings/ai', '_blank')
setOpen(false)
}}
>
<Plus className="h-4 w-4" />
Add Provider
</button>
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -1,26 +0,0 @@
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { getCreditTextColor } from '@/lib/credits/credit-colors'
import { cn } from '@/lib/utils'
interface CreditBadgeProps {
credits: number
onClick?: () => void
}
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
getCreditTextColor(credits),
)}
title={`${credits} credits remaining`}
>
<Coins className="h-3.5 w-3.5" />
<span>{credits}</span>
</button>
)
}

View File

@@ -1,148 +0,0 @@
import { REFERRAL_LIMITS } from '@browseros/shared/constants/limits'
import { ExternalLink, Loader2, Send } from 'lucide-react'
import type { FC } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCredits, useInvalidateCredits } from '@/lib/credits/useCredits'
import {
getShareOnTwitterUrl,
submitReferral,
} from '@/lib/referral/submit-referral'
interface ShareForCreditsProps {
compact?: boolean
}
export const ShareForCredits: FC<ShareForCreditsProps> = ({ compact }) => {
const [tweetUrl, setTweetUrl] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [result, setResult] = useState<{
success: boolean
message: string
} | null>(null)
const { data } = useCredits()
const invalidateCredits = useInvalidateCredits()
const credits = data?.credits ?? 0
const atDailyMax = credits >= REFERRAL_LIMITS.MAX_DAILY_CREDITS
const handleSubmit = async () => {
if (!tweetUrl.trim() || !data?.browserosId || atDailyMax) return
setIsSubmitting(true)
setResult(null)
try {
const res = await submitReferral(tweetUrl.trim(), data.browserosId)
if (res.success) {
setResult({
success: true,
message: `${res.creditsAdded ?? 200} credits added!`,
})
setTweetUrl('')
invalidateCredits()
} else {
setResult({
success: false,
message: res.reason ?? 'Submission failed. Please try again.',
})
}
} catch {
setResult({
success: false,
message: 'Network error. Please try again.',
})
} finally {
setIsSubmitting(false)
}
}
if (atDailyMax) {
return (
<div className={compact ? 'space-y-2' : 'space-y-3'}>
<p className={compact ? 'text-muted-foreground text-xs' : 'text-sm'}>
You've reached the daily cap of {REFERRAL_LIMITS.MAX_DAILY_CREDITS}{' '}
credits. Come back tomorrow to earn more!
</p>
</div>
)
}
return (
<div className={compact ? 'space-y-2' : 'space-y-3'}>
<p className={compact ? 'text-muted-foreground text-xs' : 'text-sm'}>
Share BrowserOS on Twitter to earn{' '}
{REFERRAL_LIMITS.CREDITS_PER_REFERRAL} bonus credits!
</p>
<ul className="list-disc space-y-0.5 pl-4 text-muted-foreground text-xs">
<li>
Tweet must mention <span className="font-medium">@browserOS_ai</span>
</li>
<li>Tweet must be posted within the last 30 minutes</li>
<li>Each tweet can only be submitted once</li>
<li>
Daily cap of {REFERRAL_LIMITS.MAX_DAILY_CREDITS} credits — resets at
midnight UTC
</li>
</ul>
<Button variant="outline" size="sm" className="w-full gap-2" asChild>
<a
href={getShareOnTwitterUrl()}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.currentTarget.href = getShareOnTwitterUrl()
}}
>
<ExternalLink className="h-3.5 w-3.5" />
Share on Twitter
</a>
</Button>
<p className="text-muted-foreground text-xs">
Already shared? Paste your tweet link:
</p>
<div className="flex gap-2">
<Input
type="url"
placeholder="https://x.com/..."
value={tweetUrl}
onChange={(e) => setTweetUrl(e.target.value)}
className="h-8 text-xs"
disabled={isSubmitting}
/>
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !tweetUrl.trim()}
className="shrink-0 gap-1.5"
>
{isSubmitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
Submit
</Button>
</div>
{result && (
<p
className={
result.success
? 'text-green-600 text-xs dark:text-green-400'
: 'text-destructive text-xs'
}
>
{result.message}
</p>
)}
</div>
)
}

View File

@@ -3,7 +3,7 @@ import {
BookOpen,
Bot,
Compass,
CreditCard,
GitBranch,
MessageSquare,
Palette,
RotateCcw,
@@ -80,10 +80,10 @@ const primarySettingsSections: NavSection[] = [
},
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{
name: 'Usage & Billing',
to: '/settings/usage',
icon: CreditCard,
feature: Feature.CREDITS_SUPPORT,
name: 'Workflows',
to: '/workflows',
icon: GitBranch,
feature: Feature.WORKFLOW_SUPPORT,
},
],
},

View File

@@ -176,14 +176,14 @@ function AlertDialogCancel({
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -72,4 +72,4 @@ function AlertDescription({
)
}
export { Alert, AlertDescription, AlertTitle }
export { Alert, AlertTitle, AlertDescription }

View File

@@ -104,10 +104,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -251,10 +251,10 @@ function CarouselNext({
}
export {
Carousel,
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
CarouselNext,
}

View File

@@ -39,4 +39,4 @@ function CollapsibleContent({
)
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -198,11 +198,11 @@ function CommandShortcut({
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
CommandSeparator,
}

View File

@@ -283,18 +283,18 @@ function DropdownMenuSubContent({
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenuSubContent,
}

View File

@@ -179,12 +179,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
}
export {
useFormField,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
useFormField,
FormField,
}

View File

@@ -50,4 +50,4 @@ function HoverCardContent({
)
}
export { HoverCard, HoverCardContent, HoverCardTrigger }
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -184,7 +184,7 @@ export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -55,4 +55,4 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -49,4 +49,4 @@ function ResizableHandle({
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -129,11 +129,11 @@ function SheetDescription({
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetFooter,
SheetTitle,
SheetTrigger,
SheetDescription,
}

View File

@@ -18,7 +18,6 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
closeButton
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,

View File

@@ -86,4 +86,4 @@ function TabsContent({
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -68,4 +68,4 @@ function TooltipContent({
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -2,7 +2,6 @@ import type { FC } from 'react'
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
import { NewTab } from '../newtab/index/NewTab'
import { NewTabChat } from '../newtab/index/NewTabChat'
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
import { Personalize } from '../newtab/personalize/Personalize'
import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
@@ -11,6 +10,7 @@ import { Onboarding } from '../onboarding/index/Onboarding'
import { StepsLayout } from '../onboarding/steps/StepsLayout'
import { AISettingsPage } from './ai-settings/AISettingsPage'
import { ConnectMCP } from './connect-mcp/ConnectMCP'
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
import { CustomizationPage } from './customization/CustomizationPage'
import { SurveyPage } from './jtbd-agent/SurveyPage'
import { AuthLayout } from './layout/AuthLayout'
@@ -27,7 +27,7 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
import { SoulPage } from './soul/SoulPage'
import { UsagePage } from './usage/UsagePage'
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
const params = new URLSearchParams(window.location.search)
@@ -51,7 +51,9 @@ const OptionsRedirect: FC = () => {
soul: '/home/soul',
skills: '/home/skills',
'jtbd-agent': '/settings/survey',
workflows: '/workflows',
scheduled: '/scheduled',
'create-graph': '/workflows/create-graph',
}
const newPath = routeMap[path] || '/settings/ai'
@@ -77,7 +79,6 @@ export const App: FC = () => {
{/* Home routes */}
<Route path="home" element={<NewTabLayout />}>
<Route index element={<NewTab />} />
<Route path="chat" element={<NewTabChat />} />
<Route path="personalize" element={<Personalize />} />
<Route path="soul" element={<SoulPage />} />
<Route path="skills" element={<SkillsPage />} />
@@ -86,6 +87,7 @@ export const App: FC = () => {
{/* Primary nav routes */}
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="workflows" element={<WorkflowsPageWrapper />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
</Route>
@@ -99,10 +101,12 @@ export const App: FC = () => {
<Route path="customization" element={<CustomizationPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
</Route>
</Route>
{/* Full-screen without sidebar */}
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
{/* Onboarding routes - no sidebar, no auth required */}
<Route path="onboarding">
<Route index element={<Onboarding />} />

View File

@@ -13,17 +13,6 @@ import {
} from '@/components/ui/alert-dialog'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import {
CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
CHATGPT_PRO_OAUTH_STARTED_EVENT,
GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
GITHUB_COPILOT_OAUTH_STARTED_EVENT,
QWEN_CODE_OAUTH_COMPLETED_EVENT,
QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
QWEN_CODE_OAUTH_STARTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
@@ -32,13 +21,7 @@ import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import { testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import {
type OAuthProviderFlowConfig,
useOAuthProviderFlow,
} from '@/lib/llm-providers/useOAuthProviderFlow'
import { track } from '@/lib/metrics/track'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import { DeviceCodeDialog } from './DeviceCodeDialog'
import {
DeleteRemoteLlmProviderDocument,
GetRemoteLlmProvidersDocument,
@@ -46,51 +29,9 @@ import {
import type { IncompleteProvider } from './IncompleteProviderCard'
import { IncompleteProvidersList } from './IncompleteProvidersList'
import { LlmProvidersHeader } from './LlmProvidersHeader'
import { McpPromoBanner } from './McpPromoBanner'
import { NewProviderDialog } from './NewProviderDialog'
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
// All OAuth providers share the same flow via useOAuthProviderFlow
const OAUTH_PROVIDERS_CONFIG: Record<string, OAuthProviderFlowConfig> = {
'chatgpt-pro': {
providerType: 'chatgpt-pro',
displayName: 'ChatGPT Plus/Pro',
startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT,
completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
},
'github-copilot': {
providerType: 'github-copilot',
displayName: 'GitHub Copilot',
startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT,
completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
clientAuth: {
deviceCodeEndpoint: 'https://github.com/login/device/code',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
clientId: 'Ov23li8tweQw6odWQebz',
scopes: 'read:user',
requiresPKCE: false,
contentType: 'json',
},
},
'qwen-code': {
providerType: 'qwen-code',
displayName: 'Qwen Code',
startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT,
completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT,
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
clientAuth: {
deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token',
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
scopes: 'openid profile email model.completion',
requiresPKCE: true,
contentType: 'form',
},
},
}
/**
* AI Settings page for managing LLM providers
* @public
@@ -137,7 +78,9 @@ export const AISettingsPage: FC = () => {
const incompleteProviders = useMemo<IncompleteProvider[]>(() => {
if (!remoteProvidersData?.llmProviders?.nodes) return []
const localProviderIds = new Set(providers.map((p) => p.id))
return remoteProvidersData.llmProviders.nodes
.filter((node): node is NonNullable<typeof node> => node !== null)
.filter((node) => !localProviderIds.has(node.rowId))
@@ -158,71 +101,12 @@ export const AISettingsPage: FC = () => {
null,
)
// OAuth flows — shared hook eliminates per-provider duplication
const chatgptPro = useOAuthProviderFlow(
OAUTH_PROVIDERS_CONFIG['chatgpt-pro'],
providers,
saveProvider,
)
const copilot = useOAuthProviderFlow(
OAUTH_PROVIDERS_CONFIG['github-copilot'],
providers,
saveProvider,
)
const qwenCode = useOAuthProviderFlow(
OAUTH_PROVIDERS_CONFIG['qwen-code'],
providers,
saveProvider,
)
const activeDeviceCode =
chatgptPro.pendingDeviceCode ??
copilot.pendingDeviceCode ??
qwenCode.pendingDeviceCode
const clearActiveDeviceCode = () => {
chatgptPro.clearDeviceCode()
copilot.clearDeviceCode()
qwenCode.clearDeviceCode()
}
const oauthFlows: Record<
string,
{
startOAuthFlow: (url: string | undefined) => Promise<void>
disconnect: () => Promise<void>
disconnectedEvent: string
}
> = {
'chatgpt-pro': {
startOAuthFlow: chatgptPro.startOAuthFlow,
disconnect: chatgptPro.disconnect,
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
},
'github-copilot': {
startOAuthFlow: copilot.startOAuthFlow,
disconnect: copilot.disconnect,
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
},
'qwen-code': {
startOAuthFlow: qwenCode.startOAuthFlow,
disconnect: qwenCode.disconnect,
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
},
}
const handleAddProvider = () => {
setTemplateValues(undefined)
setIsNewDialogOpen(true)
}
const handleUseTemplate = (template: ProviderTemplate) => {
// OAuth providers: trigger OAuth flow
const oauthFlow = oauthFlows[template.id]
if (oauthFlow) {
oauthFlow.startOAuthFlow(agentServerUrl ?? undefined)
return
}
setTemplateValues({
type: template.id,
name: template.name,
@@ -245,18 +129,11 @@ export const AISettingsPage: FC = () => {
}
const confirmDeleteProvider = async () => {
if (!providerToDelete) return
// Clear OAuth tokens on server for OAuth-based providers
const oauthFlow = oauthFlows[providerToDelete.type]
if (oauthFlow) {
await oauthFlow.disconnect()
track(oauthFlow.disconnectedEvent)
if (providerToDelete) {
await deleteProvider(providerToDelete.id)
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
setProviderToDelete(null)
}
await deleteProvider(providerToDelete.id)
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
setProviderToDelete(null)
}
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
@@ -359,8 +236,6 @@ export const AISettingsPage: FC = () => {
onAddProvider={handleAddProvider}
/>
<McpPromoBanner />
<ProviderTemplatesSection onUseTemplate={handleUseTemplate} />
<ConfiguredProvidersList
@@ -435,11 +310,6 @@ export const AISettingsPage: FC = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeviceCodeDialog
deviceCode={activeDeviceCode}
onClose={clearActiveDeviceCode}
/>
</div>
)
}

View File

@@ -1,81 +0,0 @@
import { Check, Copy, ExternalLink } from 'lucide-react'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { PendingDeviceCode } from '@/lib/llm-providers/useOAuthProviderFlow'
interface DeviceCodeDialogProps {
deviceCode: PendingDeviceCode | null
onClose: () => void
}
export const DeviceCodeDialog: FC<DeviceCodeDialogProps> = ({
deviceCode,
onClose,
}) => {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (!deviceCode) return
try {
await navigator.clipboard.writeText(deviceCode.userCode)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Clipboard API failed
}
}
return (
<Dialog open={!!deviceCode} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect to {deviceCode?.providerName}</DialogTitle>
<DialogDescription>
Paste this code on the {deviceCode?.providerName} page that just
opened in your browser.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<div className="flex items-center gap-3 rounded-xl border-2 border-[var(--accent-orange)]/40 border-dashed bg-[var(--accent-orange)]/5 px-6 py-4">
<code className="font-bold font-mono text-2xl text-foreground tracking-widest">
{deviceCode?.userCode}
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-center text-muted-foreground text-xs">
This dialog will close automatically once authentication completes.
</p>
{deviceCode?.verificationUri && (
<a
href={deviceCode.verificationUri}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[var(--accent-orange)] text-xs transition-colors hover:underline"
>
Open verification page
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,57 +0,0 @@
import { ArrowRight, Server, X } from 'lucide-react'
import { type FC, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { MCP_PROMO_BANNER_CLICKED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
export const McpPromoBanner: FC = () => {
const [dismissed, setDismissed] = useState(false)
const navigate = useNavigate()
if (dismissed) return null
const handleClick = () => {
track(MCP_PROMO_BANNER_CLICKED_EVENT)
navigate('/settings/mcp')
}
return (
<div className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:shadow-md">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10">
<Server className="h-5 w-5 text-[var(--accent-orange)]" />
</div>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-semibold text-sm">
Use BrowserOS with Claude Code, Cursor & more
<span className="text-[var(--accent-orange)] text-xs">
(66+ tools)
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-orange)]/10 px-2.5 py-1 font-semibold text-[var(--accent-orange)] text-xs">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-orange)]" />
New
</span>
</p>
<p className="text-muted-foreground text-xs">
Connect your favorite coding tools to BrowserOS as an MCP server
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleClick}
className="shrink-0 border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
>
Set up
<ArrowRight className="ml-1 h-3 w-3" />
</Button>
<button
type="button"
onClick={() => setDismissed(true)}
className="shrink-0 rounded-sm p-1 text-muted-foreground opacity-50 transition-opacity hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
}

View File

@@ -1,26 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod'
import Fuse from 'fuse.js'
import {
Check,
CheckCircle2,
ChevronDown,
ExternalLink,
Loader2,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { CheckCircle2, ExternalLink, Loader2, XCircle } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
@@ -39,11 +23,6 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
@@ -56,22 +35,19 @@ import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import {
AI_PROVIDER_ADDED_EVENT,
AI_PROVIDER_UPDATED_EVENT,
KIMI_API_KEY_CONFIGURED_EVENT,
KIMI_API_KEY_GUIDE_CLICKED_EVENT,
MODEL_SELECTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import {
getDefaultBaseUrlForProviders,
getProviderTemplate,
MINIMAX_REGIONS,
providerTypeOptions,
} from '@/lib/llm-providers/providerTemplates'
import { type TestResult, testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import { getModelContextLength, getModelsForProvider } from './models'
import { getModelContextLength, getModelOptions } from './models'
const providerTypeEnum = z.enum([
'moonshot',
@@ -85,10 +61,6 @@ const providerTypeEnum = z.enum([
'lmstudio',
'bedrock',
'browseros',
'chatgpt-pro',
'github-copilot',
'qwen-code',
'minimax',
])
/**
@@ -107,14 +79,11 @@ export const providerFormSchema = z
temperature: z.number().min(0).max(2),
// Azure-specific
resourceName: z.string().optional(),
// Bedrock-specific / MiniMax region
// Bedrock-specific
accessKeyId: z.string().optional(),
secretAccessKey: z.string().optional(),
region: z.string().optional(),
sessionToken: z.string().optional(),
// ChatGPT Pro (Codex)
reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(),
reasoningSummary: z.enum(['auto', 'concise', 'detailed']).optional(),
})
.superRefine((data, ctx) => {
// Azure: require either resourceName or baseUrl
@@ -158,38 +127,6 @@ export const providerFormSchema = z
})
}
}
// OAuth providers: no credentials needed (server-managed)
else if (
data.type === 'chatgpt-pro' ||
data.type === 'github-copilot' ||
data.type === 'qwen-code'
) {
// No validation needed — OAuth tokens are on the server
}
// MiniMax: require baseUrl + apiKey
else if (data.type === 'minimax') {
if (!data.baseUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Base URL is required',
path: ['baseUrl'],
})
} else if (!/^https?:\/\/.+/.test(data.baseUrl)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Must be a valid URL',
path: ['baseUrl'],
})
}
if (!data.apiKey?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'API Key is required',
path: ['apiKey'],
})
}
}
// Other providers: require baseUrl
else if (!data.baseUrl) {
ctx.addIssue({
@@ -212,13 +149,6 @@ export const providerFormSchema = z
*/
export type ProviderFormValues = z.infer<typeof providerFormSchema>
function formatContextWindow(tokens: number): string {
if (tokens >= 1000000)
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`
return `${tokens}`
}
/**
* Props for NewProviderDialog
* @public
@@ -244,20 +174,16 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
initialValues,
onSave,
}) => {
const [isCustomModel, setIsCustomModel] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [modelPickerOpen, setModelPickerOpen] = useState(false)
const [modelSearch, setModelSearch] = useState('')
const modelListRef = useRef<HTMLDivElement>(null)
const { supports } = useCapabilities()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const kimiLaunch = useKimiLaunch()
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
if (opt.value === 'chatgpt-pro')
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (opt.value === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
if (opt.value === 'moonshot')
return kimiLaunch || initialValues?.type === 'moonshot'
if (opt.value === 'openai-compatible') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
}
@@ -283,8 +209,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: initialValues?.secretAccessKey || '',
region: initialValues?.region || '',
sessionToken: initialValues?.sessionToken || '',
reasoningEffort: initialValues?.reasoningEffort || 'high',
reasoningSummary: initialValues?.reasoningSummary || 'auto',
},
})
@@ -316,24 +240,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
watchedSessionToken,
])
const modelInfoList = getModelsForProvider(watchedType as ProviderType)
const modelFuse = useMemo(
() =>
new Fuse(modelInfoList, {
keys: ['modelId'],
threshold: 0.4,
distance: 100,
}),
[modelInfoList],
)
const filteredModels = modelSearch
? modelFuse.search(modelSearch).map((r) => r.item)
: modelInfoList
const showCustomEntry =
modelSearch && !filteredModels.some((m) => m.modelId === modelSearch)
// Get model options for current provider type
const modelOptions = getModelOptions(watchedType as ProviderType)
// Handle provider type change (user-initiated via Select)
const handleTypeChange = (newType: ProviderType) => {
@@ -342,17 +250,15 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
if (defaultUrl) {
form.setValue('baseUrl', defaultUrl)
}
if (newType === 'minimax') {
form.setValue('region', 'chinese')
}
form.setValue('modelId', '')
setIsCustomModel(false)
}
// Auto-fill context window when model changes (only for new providers)
useEffect(() => {
if (initialValues?.id) return
if (watchedModelId) {
if (watchedModelId && watchedModelId !== 'custom') {
const contextLength = getModelContextLength(
watchedType as ProviderType,
watchedModelId,
@@ -363,6 +269,17 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
}
}, [watchedModelId, watchedType, form, initialValues?.id])
// Handle model selection (including custom option)
const handleModelChange = (value: string) => {
if (value === 'custom') {
setIsCustomModel(true)
form.setValue('modelId', '')
} else {
setIsCustomModel(false)
form.setValue('modelId', value)
}
}
// Reset form when initialValues change
useEffect(() => {
if (initialValues) {
@@ -384,9 +301,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: initialValues.secretAccessKey || '',
region: initialValues.region || '',
sessionToken: initialValues.sessionToken || '',
reasoningEffort: initialValues.reasoningEffort || 'high',
reasoningSummary: initialValues.reasoningSummary || 'auto',
})
setIsCustomModel(false)
}
}, [initialValues, form])
@@ -410,9 +326,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: '',
region: '',
sessionToken: '',
reasoningEffort: 'high',
reasoningSummary: 'auto',
})
setIsCustomModel(false)
}
// Clear test result when dialog opens/closes
setTestResult(null)
@@ -433,11 +348,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
provider_type: values.type,
model: values.modelId,
})
} else {
track(AI_PROVIDER_UPDATED_EVENT, {
provider_type: values.type,
model: values.modelId,
})
}
if (values.type === 'moonshot') {
track(KIMI_API_KEY_CONFIGURED_EVENT, {
@@ -453,14 +363,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
const canTest = (): boolean => {
if (!watchedModelId) return false
// OAuth providers: always testable (server has the OAuth token)
if (
watchedType === 'chatgpt-pro' ||
watchedType === 'github-copilot' ||
watchedType === 'qwen-code'
)
return true
if (watchedType === 'azure') {
return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey
}
@@ -542,85 +444,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
}
const renderProviderSpecificFields = () => {
// OAuth-only providers (no API key needed)
if (watchedType === 'github-copilot' || watchedType === 'qwen-code') {
const name = watchedType === 'github-copilot' ? 'GitHub' : 'Qwen Code'
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
Credentials are managed via {name} OAuth. No API key needed.
</div>
)
}
// ChatGPT Pro: OAuth credentials + Codex reasoning settings
if (watchedType === 'chatgpt-pro') {
return (
<>
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
Credentials are managed via OAuth. No API key needed.
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="reasoningEffort"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Effort</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || 'high'}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How much the model thinks before responding
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reasoningSummary"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Summary</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || 'auto'}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="concise">Concise</SelectItem>
<SelectItem value="detailed">Detailed</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Detail level of visible thinking steps
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)
}
if (watchedType === 'azure') {
return (
<>
@@ -751,94 +574,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
)
}
// Minimax: region selector
if (watchedType === 'minimax') {
return (
<>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem>
<FormLabel>Region *</FormLabel>
<Select
onValueChange={(v) => {
field.onChange(v)
form.setValue(
'baseUrl',
MINIMAX_REGIONS[v as keyof typeof MINIMAX_REGIONS].api,
)
}}
value={field.value || 'chinese'}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="chinese">
Chinese (api.minimaxi.com)
</SelectItem>
<SelectItem value="international">
International (api.minimax.io)
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the endpoint closest to your location
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Base URL *</FormLabel>
<FormControl>
<Input placeholder="https://api.minimaxi.com/v1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key *</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your MiniMax API key"
{...field}
/>
</FormControl>
<FormDescription>
Your API key is encrypted and stored locally.{' '}
{setupGuideUrl && (
<a
href={setupGuideUrl}
onClick={handleSetupGuideClick}
className="inline-flex cursor-pointer items-center gap-1 text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
{setupGuideText}
</a>
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)
}
// Standard providers (OpenAI, Anthropic, Google, etc.)
return (
<>
@@ -964,147 +699,52 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
control={form.control}
name="modelId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormItem>
<FormLabel>Model *</FormLabel>
{modelInfoList.length === 0 ? (
<FormControl>
<Input
placeholder={
watchedType === 'azure'
? 'Enter your deployment name'
: watchedType === 'bedrock'
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
: 'Enter model ID'
}
{...field}
/>
</FormControl>
) : (
<Popover
open={modelPickerOpen}
onOpenChange={(isOpen) => {
setModelPickerOpen(isOpen)
if (!isOpen) setModelSearch('')
}}
>
<PopoverTrigger asChild>
<button
{isCustomModel || modelOptions.length === 1 ? (
<>
<FormControl>
<Input
placeholder={
watchedType === 'azure'
? 'Enter your deployment name'
: watchedType === 'bedrock'
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
: 'Enter custom model ID'
}
{...field}
/>
</FormControl>
{modelOptions.length > 1 && (
<Button
type="button"
className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs',
field.value
? 'text-foreground'
: 'text-muted-foreground',
)}
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => setIsCustomModel(false)}
>
<span className="truncate">
{field.value || 'Select a model...'}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={(v) => {
setModelSearch(v)
requestAnimationFrame(() => {
modelListRef.current?.scrollTo(0, 0)
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && modelSearch) {
e.preventDefault()
e.stopPropagation()
form.setValue('modelId', modelSearch)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: modelSearch,
is_custom_model: !modelInfoList.some(
(m) => m.modelId === modelSearch,
),
})
setModelPickerOpen(false)
setModelSearch('')
}
}}
/>
<CommandList ref={modelListRef}>
<CommandEmpty>
No models found. Press Enter to use &quot;
{modelSearch}&quot;
</CommandEmpty>
{showCustomEntry && (
<CommandGroup forceMount>
<CommandItem
forceMount
value={`custom:${modelSearch}`}
onSelect={() => {
form.setValue('modelId', modelSearch)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: modelSearch,
is_custom_model: true,
})
setModelPickerOpen(false)
setModelSearch('')
}}
>
<span className="flex-1 truncate">
{modelSearch}
</span>
{field.value === modelSearch && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</CommandItem>
</CommandGroup>
)}
{filteredModels.length > 0 && (
<CommandGroup>
{filteredModels.map((model) => (
<CommandItem
key={model.modelId}
value={model.modelId}
onSelect={() => {
form.setValue('modelId', model.modelId)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: model.modelId,
context_window: model.contextLength,
is_custom_model: !modelInfoList.some(
(m) => m.modelId === model.modelId,
),
})
setModelPickerOpen(false)
setModelSearch('')
}}
>
<span className="flex-1 truncate">
{model.modelId}
</span>
{model.contextLength > 0 && (
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(
model.contextLength,
)}
</span>
)}
{field.value === model.modelId && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
Back to model list
</Button>
)}
</>
) : (
<Select
onValueChange={handleModelChange}
value={field.value}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{modelOptions.map((modelId) => (
<SelectItem key={modelId} value={modelId}>
{modelId === 'custom' ? '+ Custom model' : modelId}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FormMessage />
</FormItem>

View File

@@ -2,6 +2,7 @@ import { Check, Loader2, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { cn } from '@/lib/utils'
@@ -29,6 +30,7 @@ export const ProviderCard: FC<ProviderCardProps> = ({
isTesting = false,
}) => {
const inputId = `provider-${provider.id}`
const kimiLaunch = useKimiLaunch()
return (
<label
@@ -77,25 +79,32 @@ export const ProviderCard: FC<ProviderCardProps> = ({
</Badge>
)}
</div>
{isBuiltIn && provider.type === 'browseros' && kimiLaunch && (
<span className="mb-1 inline-block rounded-full border border-orange-300/60 bg-orange-100/70 px-3 py-0.5 font-semibold text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
In partnership with Moonshot AI
</span>
)}
<p className="truncate text-muted-foreground text-sm">
{isBuiltIn ? (
<>
BrowserOS-hosted model with strict rate limits.{' '}
<a
href="https://docs.browseros.com/features/bring-your-own-llm"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
Bring your own key
</a>{' '}
for better performance.
</>
) : provider.baseUrl ? (
`${provider.modelId}${provider.baseUrl}`
kimiLaunch ? (
'Extended usage limits for the next 2 weeks!'
) : (
<>
BrowserOS-hosted model with strict rate limits.{' '}
<a
href="https://docs.browseros.com/features/bring-your-own-llm"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
Bring your own key
</a>{' '}
for better performance.
</>
)
) : (
provider.modelId
`${provider.modelId}${provider.baseUrl}`
)}
</p>
</div>

View File

@@ -7,14 +7,12 @@ import { cn } from '@/lib/utils'
interface ProviderTemplateCardProps {
template: ProviderTemplate
highlighted?: boolean
isNew?: boolean
onUseTemplate: (template: ProviderTemplate) => void
}
export const ProviderTemplateCard: FC<ProviderTemplateCardProps> = ({
template,
highlighted = false,
isNew = false,
onUseTemplate,
}) => {
return (
@@ -22,19 +20,12 @@ export const ProviderTemplateCard: FC<ProviderTemplateCardProps> = ({
type="button"
onClick={() => onUseTemplate(template)}
className={cn(
'group relative flex w-full items-center gap-3 rounded-lg border bg-background p-4 text-left transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
'group flex w-full items-center gap-3 rounded-lg border bg-background p-4 text-left transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
highlighted
? 'border-orange-300/80 bg-orange-50/30 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5'
: isNew
? 'border-2 border-[var(--accent-orange)]/50'
: 'border-border',
: 'border-border',
)}
>
{isNew && (
<span className="absolute -top-2 left-3 rounded-full bg-[var(--accent-orange)] px-2 py-0.5 font-semibold text-[9px] text-white uppercase tracking-wider">
New
</span>
)}
<div className="flex min-w-0 flex-1 items-center gap-3">
<ProviderIcon
type={template.id}

View File

@@ -7,6 +7,7 @@ import {
} from '@/components/ui/collapsible'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import {
type ProviderTemplate,
providerTemplates,
@@ -22,13 +23,10 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
onUseTemplate,
}) => {
const { supports } = useCapabilities()
const kimiLaunch = useKimiLaunch()
const filteredTemplates = providerTemplates.filter((template) => {
if (template.id === 'chatgpt-pro')
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (template.id === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (template.id === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
if (template.id === 'moonshot') return kimiLaunch
if (template.id === 'openai-compatible') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
}
@@ -55,20 +53,14 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
<CollapsibleContent>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredTemplates.map((template) => {
const isNew =
template.id === 'chatgpt-pro' ||
template.id === 'github-copilot' ||
template.id === 'qwen-code'
return (
<ProviderTemplateCard
key={template.id}
template={template}
isNew={isNew}
onUseTemplate={onUseTemplate}
/>
)
})}
{filteredTemplates.map((template) => (
<ProviderTemplateCard
key={template.id}
template={template}
highlighted={template.id === 'moonshot'}
onUseTemplate={onUseTemplate}
/>
))}
</div>
</CollapsibleContent>
</div>

View File

@@ -1,56 +1,116 @@
import {
getModelsDevModels,
type ModelsDevModel,
} from '@/lib/llm-providers/models-dev'
import type { ProviderType } from '@/lib/llm-providers/types'
/**
* Model information with context length
*/
export interface ModelInfo {
modelId: string
contextLength: number
supportsImages?: boolean
supportsReasoning?: boolean
supportsToolCall?: boolean
}
const CUSTOM_PROVIDER_MODELS: Partial<Record<ProviderType, ModelInfo[]>> = {
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
'openai-compatible': [],
ollama: [],
'chatgpt-pro': [
{ modelId: 'gpt-5.4', contextLength: 400000 },
{ modelId: 'gpt-5.3-codex', contextLength: 400000 },
{ modelId: 'gpt-5.2-codex', contextLength: 400000 },
/**
* Models data organized by provider type (matches backend AIProvider enum)
*/
export interface ModelsData {
anthropic: ModelInfo[]
openai: ModelInfo[]
'openai-compatible': ModelInfo[]
google: ModelInfo[]
openrouter: ModelInfo[]
azure: ModelInfo[]
ollama: ModelInfo[]
lmstudio: ModelInfo[]
bedrock: ModelInfo[]
browseros: ModelInfo[]
moonshot: ModelInfo[]
}
/**
* Available models per provider with context lengths
* Based on: https://github.com/browseros-ai/BrowserOS-agent/blob/main/src/options/data/models.ts
*/
export const MODELS_DATA: ModelsData = {
moonshot: [{ modelId: 'kimi-k2.5', contextLength: 200000 }],
anthropic: [
{ modelId: 'claude-opus-4-5-20251101', contextLength: 200000 },
{ modelId: 'claude-haiku-4-5-20251001', contextLength: 200000 },
{ modelId: 'claude-sonnet-4-5-20250929', contextLength: 200000 },
{ modelId: 'claude-sonnet-4-20250514', contextLength: 200000 },
{ modelId: 'claude-opus-4-20250514', contextLength: 200000 },
{ modelId: 'claude-3-7-sonnet-20250219', contextLength: 200000 },
{ modelId: 'claude-3-5-haiku-20241022', contextLength: 200000 },
],
openai: [
{ modelId: 'gpt-5.2', contextLength: 200000 },
{ modelId: 'gpt-5.1-codex', contextLength: 400000 },
{ modelId: 'gpt-5.1-codex-max', contextLength: 400000 },
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
{ modelId: 'gpt-5.1', contextLength: 200000 },
{ modelId: 'gpt-5.2-pro', contextLength: 200000 },
{ modelId: 'gpt-5', contextLength: 200000 },
{ modelId: 'gpt-5-mini', contextLength: 200000 },
{ modelId: 'gpt-5-nano', contextLength: 200000 },
{ modelId: 'gpt-4.1', contextLength: 200000 },
{ modelId: 'gpt-4.1-mini', contextLength: 200000 },
{ modelId: 'o4-mini', contextLength: 200000 },
{ modelId: 'o3-mini', contextLength: 200000 },
{ modelId: 'gpt-4o', contextLength: 128000 },
{ modelId: 'gpt-4o-mini', contextLength: 128000 },
],
'qwen-code': [
{ modelId: 'coder-model', contextLength: 1000000 },
{ modelId: 'qwen3-coder-plus', contextLength: 1000000 },
{ modelId: 'qwen3-coder-flash', contextLength: 1000000 },
{ modelId: 'qwen3.5-plus', contextLength: 1000000 },
'openai-compatible': [],
google: [
{ modelId: 'gemini-3-pro-preview', contextLength: 1048576 },
{ modelId: 'gemini-3-flash-preview', contextLength: 1048576 },
{ modelId: 'gemini-2.5-flash', contextLength: 1048576 },
{ modelId: 'gemini-2.5-pro', contextLength: 1048576 },
],
openrouter: [
{ modelId: 'google/gemini-3-pro-preview', contextLength: 1048576 },
{ modelId: 'google/gemini-3-flash-preview', contextLength: 1048576 },
{ modelId: 'google/gemini-2.5-flash', contextLength: 1048576 },
{ modelId: 'anthropic/claude-opus-4.5', contextLength: 200000 },
{ modelId: 'anthropic/claude-haiku-4.5', contextLength: 200000 },
{ modelId: 'anthropic/claude-sonnet-4.5', contextLength: 200000 },
{ modelId: 'anthropic/claude-sonnet-4', contextLength: 200000 },
{ modelId: 'anthropic/claude-3.7-sonnet', contextLength: 200000 },
{ modelId: 'openai/gpt-4o', contextLength: 128000 },
{ modelId: 'openai/gpt-oss-120b', contextLength: 128000 },
{ modelId: 'openai/gpt-oss-20b', contextLength: 128000 },
{ modelId: 'qwen/qwen3-14b', contextLength: 131072 },
{ modelId: 'qwen/qwen3-8b', contextLength: 131072 },
],
azure: [],
ollama: [
{ modelId: 'qwen3:4b', contextLength: 262144 },
{ modelId: 'qwen3:8b', contextLength: 40960 },
{ modelId: 'qwen3:14b', contextLength: 40960 },
{ modelId: 'gpt-oss:20b', contextLength: 128000 },
{ modelId: 'gpt-oss:120b', contextLength: 128000 },
],
lmstudio: [
{ modelId: 'openai/gpt-oss-20b', contextLength: 128000 },
{ modelId: 'openai/gpt-oss-120b', contextLength: 128000 },
{ modelId: 'qwen/qwen3-vl-8b', contextLength: 131072 },
],
bedrock: [],
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
}
function fromModelsDevModel(m: ModelsDevModel): ModelInfo {
return {
modelId: m.id,
contextLength: m.contextWindow,
supportsImages: m.supportsImages,
supportsReasoning: m.supportsReasoning,
supportsToolCall: m.supportsToolCall,
}
}
/**
* Get models for a specific provider type
*/
export function getModelsForProvider(providerType: ProviderType): ModelInfo[] {
const custom = CUSTOM_PROVIDER_MODELS[providerType]
if (custom !== undefined) return custom
return getModelsDevModels(providerType).map(fromModelsDevModel)
return MODELS_DATA[providerType] || []
}
/**
* Get model options for select dropdown (model IDs + custom option)
*/
export function getModelOptions(providerType: ProviderType): string[] {
const models = getModelsForProvider(providerType)
const modelIds = models.map((m) => m.modelId)
return modelIds.length > 0 ? [...modelIds, 'custom'] : ['custom']
}
/**
* Get context length for a specific model
*/
export function getModelContextLength(
providerType: ProviderType,
modelId: string,
@@ -59,3 +119,14 @@ export function getModelContextLength(
const model = models.find((m) => m.modelId === modelId)
return model?.contextLength
}
/**
* Check if model ID is a custom (user-entered) value
*/
export function isCustomModel(
providerType: ProviderType,
modelId: string,
): boolean {
const models = getModelsForProvider(providerType)
return !models.some((m) => m.modelId === modelId)
}

View File

@@ -156,7 +156,6 @@ export const ConnectMCP: FC = () => {
})
if (response.success) {
removeServer(id)
mutateUserIntegrations()
} else {
failedToRemoveMcp(name, 'Success not returned from server')
}

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import useSWR from 'swr'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
interface UserMCPIntegrationsList {
@@ -9,11 +9,7 @@ interface UserMCPIntegrationsList {
count: number
}
export const INTEGRATIONS_QUERY_KEY = 'klavis-user-integrations'
const getUserMCPIntegrations = async (
hostUrl: string,
): Promise<UserMCPIntegrationsList> => {
const getUserMCPIntegrations = async ([hostUrl]: [hostUrl: string]) => {
const response = await fetch(`${hostUrl}/klavis/user-integrations`)
const data = (await response.json()) as UserMCPIntegrationsList
return data
@@ -22,19 +18,12 @@ const getUserMCPIntegrations = async (
export const useGetUserMCPIntegrations = () => {
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const query = useQuery({
queryKey: [INTEGRATIONS_QUERY_KEY, agentServerUrl],
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
queryFn: () => getUserMCPIntegrations(agentServerUrl!),
enabled: !!agentServerUrl,
refetchOnWindowFocus: true,
})
return {
data: query.data,
isLoading: query.isLoading,
isFetching: query.isFetching,
isSuccess: query.isSuccess,
mutate: query.refetch,
}
return useSWR(
agentServerUrl ? [agentServerUrl, 'klavis/user-integrations'] : null,
getUserMCPIntegrations,
{
keepPreviousData: true,
revalidateOnFocus: true,
},
)
}

View File

@@ -0,0 +1,472 @@
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { compact } from 'es-toolkit/array'
import type { FC, FormEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import type { Provider } from '@/components/chat/chatComponentTypes'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable'
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import {
GRAPH_SAVED_EVENT,
GRAPH_UPDATED_EVENT,
NEW_GRAPH_CREATED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { track } from '@/lib/metrics/track'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
import { sentry } from '@/lib/sentry/sentry'
import { useWorkflows } from '@/lib/workflows/workflowStorage'
import { GraphCanvas } from './GraphCanvas'
import { GraphChat } from './GraphChat'
import { WorkflowsChatHeader } from './WorkflowsChatHeader'
type MessageType = 'create-graph' | 'update-graph' | 'run-graph'
type GraphMessageMetadata = {
messageType?: MessageType
codeId?: string
graph?: GraphData
window?: chrome.windows.Window
}
export type GraphData = {
nodes: {
id: string
type: string
data: {
label: string
}
}[]
edges: {
id: string
source: string
target: string
}[]
}
const getLastMessageText = (messages: UIMessage[]) => {
const lastMessage = messages[messages.length - 1]
if (!lastMessage) return ''
return lastMessage.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
}
export const CreateGraph: FC = () => {
const [searchParams] = useSearchParams()
const workflowIdParam = searchParams.get('workflowId')
const [graphName, setGraphName] = useState('')
const [codeId, setCodeId] = useState<string | undefined>(undefined)
const [graphData, setGraphData] = useState<GraphData | undefined>(undefined)
const [savedWorkflowId, setSavedWorkflowId] = useState<string | undefined>(
undefined,
)
const [savedCodeId, setSavedCodeId] = useState<string | undefined>(undefined)
const [isInitialized, setIsInitialized] = useState(!workflowIdParam)
const [canvasPanelSize, setCanvasPanelSize] = useState<
{ asPercentage: number; inPixels: number } | undefined
>(undefined)
const [query, setQuery] = useState('')
const [showDiscardDialog, setShowDiscardDialog] = useState(false)
const { workflows, addWorkflow, editWorkflow } = useWorkflows()
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
const rpcClient = useRpcClient()
// Initialize edit mode when workflowId is provided
useDeepCompareEffect(() => {
if (!workflowIdParam || isInitialized) return
const workflow = workflows.find((w) => w.id === workflowIdParam)
if (!workflow) return
const initializeEditMode = async () => {
setGraphName(workflow.workflowName)
setCodeId(workflow.codeId)
setSavedWorkflowId(workflow.id)
setSavedCodeId(workflow.codeId)
try {
const response = await rpcClient.graph[':id'].$get({
param: { id: workflow.codeId },
})
if (response.ok) {
const data = await response.json()
if ('graph' in data && data.graph) {
setGraphData(data.graph as GraphData)
}
}
} catch (error) {
sentry.captureException(error, {
extra: {
message: 'Failed to fetch graph data from the server',
codeId: workflow.codeId,
},
})
}
setIsInitialized(true)
}
initializeEditMode()
}, [workflowIdParam, workflows, isInitialized, rpcClient])
const updateQuery = (newQuery: string) => {
setQuery(newQuery)
}
const onSubmit = (e: FormEvent) => {
e.preventDefault()
if (codeId) {
sendMessage({
text: query,
metadata: {
messageType: 'update-graph' as MessageType,
codeId,
},
})
track(GRAPH_UPDATED_EVENT)
} else {
sendMessage({
text: query,
metadata: {
messageType: 'create-graph' as MessageType,
},
})
track(NEW_GRAPH_CREATED_EVENT)
}
setQuery('')
}
const {
baseUrl: agentServerUrl,
isLoading: _isLoadingAgentUrl,
error: agentUrlError,
} = useAgentServerUrl()
const {
selectedLlmProviderRef,
enabledMcpServersRef,
enabledCustomServersRef,
personalizationRef,
selectedLlmProvider,
isLoadingProviders,
} = useChatRefs()
const agentUrlRef = useRef(agentServerUrl)
const codeIdRef = useRef(codeId)
useEffect(() => {
agentUrlRef.current = agentServerUrl
codeIdRef.current = codeId
}, [agentServerUrl, codeId])
const { sendMessage, stop, status, messages, error, setMessages } = useChat({
transport: new DefaultChatTransport({
prepareSendMessagesRequest: async ({ messages }) => {
const lastMessage = messages[messages.length - 1]
const lastMessageText = getLastMessageText(messages)
const metadata = lastMessage.metadata as
| GraphMessageMetadata
| undefined
if (metadata?.messageType === 'create-graph') {
return {
api: `${agentUrlRef.current}/graph`,
body: {
query: lastMessageText,
},
}
}
if (metadata?.messageType === 'update-graph' && codeIdRef.current) {
return {
api: `${agentUrlRef.current}/graph/${codeIdRef.current}`,
body: {
query: lastMessageText,
},
}
}
if (metadata?.messageType === 'run-graph' && codeIdRef.current) {
const provider = selectedLlmProviderRef.current
const enabledMcpServers = enabledMcpServersRef.current
const customMcpServers = enabledCustomServersRef.current
return {
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
body: {
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
model: provider?.modelId ?? 'browseros',
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
resourceName: provider?.resourceName,
// Bedrock-specific
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
browserContext: {
windowId: metadata?.window?.id,
activeTab: metadata?.window?.tabs?.[0],
enabledMcpServers: compact(enabledMcpServers),
customMcpServers,
},
userSystemPrompt: personalizationRef.current,
},
}
}
return {
api: `${agentUrlRef.current}/graph`,
body: {
query: lastMessageText,
},
}
},
}),
})
const lastAssistantMessageWithGraph = messages.findLast((m) => {
if (m.role !== 'assistant') return false
const metadata = m.metadata as GraphMessageMetadata | undefined
return metadata?.graph !== undefined
})
const onClickTest = async () => {
const backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
sendMessage({
text: 'Run a test of the graph you just created.',
metadata: {
messageType: 'run-graph' as MessageType,
codeId,
window: backgroundWindow,
},
})
}
const hasUnsavedChanges = savedWorkflowId ? codeId !== savedCodeId : true
const shouldBlockNavigation = !!codeId && hasUnsavedChanges
// Handle browser refresh/close
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (shouldBlockNavigation) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [shouldBlockNavigation])
const onClickSave = async () => {
if (!graphName || !codeId) return
if (savedWorkflowId) {
await editWorkflow(savedWorkflowId, {
workflowName: graphName,
codeId,
})
setSavedCodeId(codeId)
} else {
const newWorkflow = await addWorkflow({
workflowName: graphName,
codeId,
})
setSavedWorkflowId(newWorkflow.id)
setSavedCodeId(codeId)
}
track(GRAPH_SAVED_EVENT)
}
// Provider data for header
const providers: Provider[] = llmProviders.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
}))
const selectedProviderForHeader: Provider | undefined = selectedLlmProvider
? {
id: selectedLlmProvider.id,
name: selectedLlmProvider.name,
type: selectedLlmProvider.type,
}
: providers[0]
// Has generated code but can't auto-save (no name)
const hasUnsavedWork = codeId && !graphName
const resetToNewWorkflow = () => {
setCodeId(undefined)
setGraphData(undefined)
setGraphName('')
setSavedWorkflowId(undefined)
setSavedCodeId(undefined)
setMessages([])
}
const handleSelectProvider = (provider: Provider) => {
setDefaultProvider(provider.id)
}
const handleNewWorkflow = async () => {
// Can auto-save: has name AND code
if (graphName && codeId) {
await onClickSave()
resetToNewWorkflow()
return
}
// Has unsaved work that can't be auto-saved: show confirmation
if (hasUnsavedWork) {
setShowDiscardDialog(true)
return
}
// Nothing to save, just reset
resetToNewWorkflow()
}
const handleConfirmDiscard = () => {
setShowDiscardDialog(false)
resetToNewWorkflow()
}
const handleSuggestionClick = (prompt: string) => {
sendMessage({
text: prompt,
metadata: {
messageType: 'create-graph' as MessageType,
},
})
}
useDeepCompareEffect(() => {
if (status === 'ready' && lastAssistantMessageWithGraph) {
const metadata = lastAssistantMessageWithGraph.metadata as
| GraphMessageMetadata
| undefined
setCodeId(metadata?.codeId)
setGraphData(metadata?.graph)
}
}, [status, lastAssistantMessageWithGraph ?? {}])
if (!isInitialized || isLoadingProviders || !selectedProviderForHeader) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
<div className="fade-in animate-in text-muted-foreground duration-200 [animation-delay:300ms] [animation-fill-mode:backwards]">
Loading...
</div>
</div>
)
}
return (
<div className="h-screen w-screen bg-background text-foreground">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
id="graph-canvas"
defaultSize={'70%'}
minSize={'30%'}
maxSize={'70%'}
onResize={(size) => setCanvasPanelSize(size)}
>
<GraphCanvas
graphName={graphName}
onGraphNameChange={(val) => setGraphName(val)}
graphData={graphData}
codeId={codeId}
onClickTest={onClickTest}
onClickSave={onClickSave}
isSaved={!!savedWorkflowId}
hasUnsavedChanges={hasUnsavedChanges}
shouldBlockNavigation={shouldBlockNavigation}
panelSize={canvasPanelSize}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
id="graph-chat"
defaultSize={'30%'}
maxSize={'70%'}
minSize={'30%'}
>
<div className="flex h-full flex-col">
<WorkflowsChatHeader
selectedProvider={selectedProviderForHeader}
providers={providers}
onSelectProvider={handleSelectProvider}
onNewWorkflow={handleNewWorkflow}
hasMessages={messages.length > 0}
/>
<div className="min-h-0 flex-1">
<GraphChat
messages={messages}
onSubmit={onSubmit}
onInputChange={updateQuery}
onStop={stop}
input={query}
status={status}
agentUrlError={agentUrlError}
chatError={error}
onSuggestionClick={handleSuggestionClick}
/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
<AlertDialog open={showDiscardDialog} onOpenChange={setShowDiscardDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard unsaved workflow?</AlertDialogTitle>
<AlertDialogDescription>
You have an unsaved workflow. Creating a new one will discard your
current changes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDiscard}>
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { type FC, Suspense } from 'react'
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
import { CreateGraph } from './CreateGraph'
export const CreateGraphWrapper: FC = () => {
return (
<RpcClientProvider>
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
<CreateGraph />
</Suspense>
</RpcClientProvider>
)
}

View File

@@ -0,0 +1,140 @@
import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'
import {
CheckCircle,
Download,
GitBranch,
GitMerge,
MousePointer,
Navigation,
Play,
RotateCw,
Split,
Square,
} from 'lucide-react'
import type React from 'react'
import { memo } from 'react'
import { cn } from '@/lib/utils'
const nodeConfig: Record<
NodeType,
{ color: string; icon: React.ElementType; label: string }
> = {
start: {
color: 'text-green-600 dark:text-green-400',
icon: Play,
label: 'Start',
},
end: {
color: 'text-red-600 dark:text-red-400',
icon: Square,
label: 'End',
},
nav: {
color: 'text-blue-600 dark:text-blue-400',
icon: Navigation,
label: 'Navigate',
},
act: {
color: 'text-purple-600 dark:text-purple-400',
icon: MousePointer,
label: 'Action',
},
extract: {
color: 'text-amber-600 dark:text-amber-400',
icon: Download,
label: 'Extract',
},
verify: {
color: 'text-emerald-600 dark:text-emerald-400',
icon: CheckCircle,
label: 'Verify',
},
decision: {
color: 'text-pink-600 dark:text-pink-400',
icon: GitBranch,
label: 'Decision',
},
loop: {
color: 'text-cyan-600 dark:text-cyan-400',
icon: RotateCw,
label: 'Loop',
},
fork: {
color: 'text-indigo-600 dark:text-indigo-400',
icon: Split,
label: 'Fork',
},
join: {
color: 'text-lime-600 dark:text-lime-400',
icon: GitMerge,
label: 'Join',
},
}
export type NodeType =
| 'start'
| 'end'
| 'nav'
| 'act'
| 'extract'
| 'verify'
| 'decision'
| 'loop'
| 'fork'
| 'join'
type CustomNodeData = Node<{
type: NodeType
label: string
}>
export const CustomNode = memo(
({ data: { label, type } }: NodeProps<CustomNodeData>) => {
const config = nodeConfig[type || 'start']
const Icon = config.icon
const showSourceHandle = type !== 'end'
const showTargetHandle = type !== 'start'
return (
<div className="min-w-45 rounded-lg border border-border bg-card px-4 py-3 shadow-md transition-all">
{showTargetHandle && (
<Handle
type="target"
position={Position.Top}
className="h-2 w-2 bg-accent-orange!"
/>
)}
<div className="flex items-center gap-2">
<div className={cn('shrink-0', config.color)}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div
className={cn(
'mb-0.5 font-semibold text-xs uppercase tracking-wide',
config.color,
)}
>
{config.label}
</div>
<div className="wrap-break-word font-medium text-foreground text-sm">
{label}
</div>
</div>
</div>
{showSourceHandle && (
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-2 bg-accent-orange!"
/>
)}
</div>
)
},
)
CustomNode.displayName = 'CustomNode'

View File

@@ -0,0 +1,514 @@
import cytoscape from 'cytoscape'
import dagre from 'cytoscape-dagre'
// @ts-expect-error no types available
import nodeHtmlLabel from 'cytoscape-node-html-label'
import DOMPurify from 'dompurify'
import {
ArrowLeft,
Maximize,
Minus,
Pencil,
Play,
Plus,
Save,
} from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import ProductLogo from '@/assets/product_logo.svg'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import type { GraphData } from './CreateGraph'
import type { NodeType } from './CustomNode'
cytoscape.use(dagre)
nodeHtmlLabel(cytoscape)
const NODE_CONFIG: Record<
NodeType,
{ color: string; bgColor: string; icon: string; label: string }
> = {
start: {
color: '#22c55e',
bgColor: 'rgba(34, 197, 94, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>`,
label: 'START',
},
end: {
color: '#ef4444',
bgColor: 'rgba(239, 68, 68, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>`,
label: 'END',
},
nav: {
color: '#3b82f6',
bgColor: 'rgba(59, 130, 246, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>`,
label: 'NAVIGATE',
},
act: {
color: '#8b5cf6',
bgColor: 'rgba(139, 92, 246, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"></path></svg>`,
label: 'ACTION',
},
extract: {
color: '#f59e0b',
bgColor: 'rgba(245, 158, 11, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`,
label: 'EXTRACT',
},
verify: {
color: '#10b981',
bgColor: 'rgba(16, 185, 129, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
label: 'VERIFY',
},
decision: {
color: '#ec4899',
bgColor: 'rgba(236, 72, 153, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
label: 'DECISION',
},
loop: {
color: '#06b6d4',
bgColor: 'rgba(6, 182, 212, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`,
label: 'LOOP',
},
fork: {
color: '#6366f1',
bgColor: 'rgba(99, 102, 241, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"></path><path d="M8 3H3v5"></path><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"></path><path d="m15 9 6-6"></path></svg>`,
label: 'FORK',
},
join: {
color: '#84cc16',
bgColor: 'rgba(132, 204, 22, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path></svg>`,
label: 'JOIN',
},
}
const initialData: GraphData = {
nodes: [
{
id: 'start',
type: 'start',
data: { label: 'Use the Chat to build your workflow!' },
},
],
edges: [],
}
const MIN_NODE_WIDTH = 180
const MAX_NODE_WIDTH = 240
const BASE_NODE_HEIGHT = 70
const CHAR_WIDTH = 7
const ICON_AND_PADDING = 62
const MAX_ZOOM = 1.2
const calculateNodeDimensions = (
label: string,
): { width: number; height: number } => {
const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING
const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth))
const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH)
const lines = Math.ceil(label.length / maxCharsPerLine)
const extraHeight = Math.max(0, lines - 1) * 18
const height = BASE_NODE_HEIGHT + extraHeight
return { width, height }
}
const createNodeHtml = (type: NodeType, label: string): string => {
const config = NODE_CONFIG[type] || NODE_CONFIG.start
const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] })
return `
<div class="graph-node" style="
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 160px;
max-width: 220px;
padding: 12px 16px;
background-color: var(--graph-node-bg);
border: 1px solid var(--graph-node-border);
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: system-ui, -apple-system, sans-serif;
">
<div style="
flex-shrink: 0;
color: ${config.color};
margin-top: 2px;
">
${config.icon}
</div>
<div style="flex: 1; min-width: 0;">
<div style="
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
color: ${config.color};
margin-bottom: 4px;
">${config.label}</div>
<div style="
font-size: 13px;
font-weight: 500;
color: var(--graph-node-text);
line-height: 1.4;
word-wrap: break-word;
">${sanitizedLabel}</div>
</div>
</div>
`
}
type GraphCanvasProps = {
graphName: string
onGraphNameChange: (name: string) => void
graphData?: GraphData
codeId?: string
onClickTest: () => unknown
onClickSave: () => unknown
isSaved: boolean
hasUnsavedChanges: boolean
shouldBlockNavigation: boolean
panelSize?: { asPercentage: number; inPixels: number }
}
export const GraphCanvas: FC<GraphCanvasProps> = ({
graphName,
onGraphNameChange,
graphData = initialData,
codeId,
onClickTest,
onClickSave,
isSaved,
hasUnsavedChanges,
shouldBlockNavigation,
panelSize,
}) => {
const [isEditingName, setIsEditingName] = useState(false)
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const cyRef = useRef<cytoscape.Core | null>(null)
const handleBack = () => {
if (shouldBlockNavigation) {
const confirmed = window.confirm(
'You have unsaved changes. Are you sure you want to leave?',
)
if (!confirmed) return
}
navigate(-1)
}
const canTest = !!codeId
const canSave = !!graphName && !!codeId && hasUnsavedChanges
const getTestTooltip = () => {
if (!codeId) return 'Create a workflow using the chat first'
return 'Run a test of this workflow'
}
const getSaveTooltip = () => {
if (!codeId) return 'Create a workflow using the chat first'
if (!graphName) return 'Provide a name for the workflow'
if (isSaved && !hasUnsavedChanges) return 'Workflow already saved'
return isSaved ? 'Save changes to this workflow' : 'Save this workflow'
}
const getSaveButtonLabel = () => {
return isSaved ? 'Save Changes' : 'Save Workflow'
}
const zoomIn = useCallback(() => {
cyRef.current?.zoom(cyRef.current.zoom() * 1.2)
cyRef.current?.center()
}, [])
const zoomOut = useCallback(() => {
cyRef.current?.zoom(cyRef.current.zoom() / 1.2)
cyRef.current?.center()
}, [])
const fitView = useCallback(() => {
cyRef.current?.fit(undefined, 50)
cyRef.current?.center()
}, [])
useEffect(() => {
if (!containerRef.current) return
const cy = cytoscape({
container: containerRef.current,
elements: [],
style: [
{
selector: 'node',
style: {
width: 'data(nodeWidth)',
height: 'data(nodeHeight)',
'background-opacity': 0,
'border-width': 0,
},
},
{
selector: 'edge',
style: {
width: 2,
'line-color': '#f97316',
'target-arrow-color': '#f97316',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.2,
},
},
{
selector: 'edge.back-edge',
style: {
'line-style': 'dashed',
'line-dash-pattern': [6, 3],
'curve-style': 'unbundled-bezier',
'control-point-distances': [100],
'control-point-weights': [0.5],
},
},
],
layout: { name: 'preset' },
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
selectionType: 'single',
autoungrabify: true,
autounselectify: true,
maxZoom: MAX_ZOOM,
minZoom: 0.2,
})
// @ts-expect-error nodeHtmlLabel extension
cy.nodeHtmlLabel([
{
query: 'node',
halign: 'center',
valign: 'center',
halignBox: 'center',
valignBox: 'center',
tpl: (data: { type: NodeType; label: string }) => {
return createNodeHtml(data.type, data.label)
},
},
])
cyRef.current = cy
return () => {
cy.destroy()
}
}, [])
const updateGraph = useCallback((data: GraphData) => {
const cy = cyRef.current
if (!cy) return
cy.elements().remove()
const nodes = data.nodes.map((node) => {
const dimensions = calculateNodeDimensions(node.data.label)
return {
data: {
id: node.id,
label: node.data.label,
type: node.type as NodeType,
nodeWidth: dimensions.width,
nodeHeight: dimensions.height,
},
}
})
const edges = data.edges.map((edge) => ({
data: {
id: edge.id,
source: edge.source,
target: edge.target,
},
}))
cy.add([...nodes, ...edges])
cy.layout({
name: 'dagre',
rankDir: 'TB',
nodeSep: 80,
rankSep: 100,
padding: 50,
animate: true,
animationDuration: 300,
fit: true,
} as cytoscape.LayoutOptions).run()
setTimeout(() => {
cy.edges().forEach((edge) => {
const sourceNode = edge.source()
const targetNode = edge.target()
const sourceY = sourceNode.position('y')
const targetY = targetNode.position('y')
if (sourceY > targetY) {
edge.addClass('back-edge')
}
})
}, 350)
}, [])
useDeepCompareEffect(() => {
updateGraph(graphData)
}, [graphData])
useEffect(() => {
if (panelSize?.inPixels !== undefined) {
cyRef.current?.resize()
setTimeout(() => fitView(), 100)
}
}, [panelSize?.inPixels, fitView])
return (
<div className="flex h-full flex-col [--graph-node-bg:rgba(255,255,255,1)] [--graph-node-border:rgba(228,228,231,1)] [--graph-node-text:rgba(24,24,27,1)] dark:[--graph-node-bg:rgba(24,24,27,1)] dark:[--graph-node-border:rgba(63,63,70,1)] dark:[--graph-node-text:rgba(250,250,250,1)]">
{/* Graph Header */}
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<img src={ProductLogo} alt="BrowserOS" className="h-8 w-8 shrink-0" />
{isEditingName ? (
<input
type="text"
value={graphName}
onChange={(e) => onGraphNameChange(e.target.value)}
onBlur={() => setIsEditingName(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') setIsEditingName(false)
}}
// biome-ignore lint/a11y/noAutofocus: needed to autofocus field when edit mode is toggled
autoFocus
placeholder="Enter workflow name..."
className="max-w-64 border-[var(--accent-orange)] border-b bg-transparent font-semibold text-sm outline-none placeholder:font-normal placeholder:text-muted-foreground/60"
/>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditingName(true)}
className="group min-w-0 gap-2 px-2 py-1"
>
{graphName ? (
<span className="truncate font-semibold text-sm">
{graphName}
</span>
) : (
<span className="text-muted-foreground/60 text-sm italic">
Untitled workflow
</span>
)}
<Pencil className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Button>
)}
</div>
{/* Control Buttons */}
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
size="sm"
onClick={onClickTest}
disabled={!canTest}
>
<Play className="mr-1.5 h-4 w-4" />
Test Workflow
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{getTestTooltip()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
onClick={onClickSave}
disabled={!canSave}
className="bg-[var(--accent-orange)] shadow-lg shadow-orange-500/20 hover:bg-[var(--accent-orange-bright)] disabled:bg-[var(--accent-orange)]/50"
>
<Save className="mr-1.5 h-4 w-4" />
{getSaveButtonLabel()}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{getSaveTooltip()}</TooltipContent>
</Tooltip>
</div>
</header>
{/* Graph Canvas */}
<div className="relative min-h-0 flex-1 overflow-hidden [--dot-color:rgba(0,0,0,0.2)] dark:[--dot-color:rgba(255,255,255,0.15)]">
<div
ref={containerRef}
className="h-full w-full bg-zinc-50 dark:bg-zinc-900"
style={{
backgroundImage:
'radial-gradient(circle, var(--dot-color) 1.5px, transparent 1.5px)',
backgroundSize: '20px 20px',
}}
/>
{/* Zoom Controls */}
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-1 rounded-lg border-2 border-border bg-card p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomIn}
title="Zoom in"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomOut}
title="Zoom out"
>
<Minus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={fitView}
title="Fit view"
>
<Maximize className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
import type { UIMessage } from 'ai'
import { Send, SquareStop } from 'lucide-react'
import type { FC, FormEventHandler, KeyboardEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
import { getResponseAndQueryFromMessageId } from '@/entrypoints/sidepanel/index/useChatSession'
import {
GRAPH_MESSAGE_DISLIKE_EVENT,
GRAPH_MESSAGE_LIKE_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import { GraphEmptyState } from './GraphEmptyState'
import { getWorkflowDisplayMessages } from './workflow-tidbit-messages'
interface GraphChatProps {
onSubmit: FormEventHandler<HTMLFormElement>
onInputChange: (value: string) => void
onStop: () => void
input: string
status: 'streaming' | 'submitted' | 'ready' | 'error'
messages: UIMessage[]
chatError?: Error
agentUrlError?: Error | null
onSuggestionClick: (prompt: string) => void
}
export const GraphChat: FC<GraphChatProps> = ({
onSubmit,
onInputChange,
onStop,
input,
status,
messages,
chatError,
agentUrlError,
onSuggestionClick,
}) => {
const [liked, setLiked] = useState<Record<string, boolean>>({})
const [disliked, setDisliked] = useState<Record<string, boolean>>({})
const [mounted, setMounted] = useState(false)
const displayMessages = getWorkflowDisplayMessages(messages)
useEffect(() => {
setMounted(true)
}, [])
const {
popupVisible,
recordMessageSent,
triggerIfEligible,
onTakeSurvey: onTakeSurveyBase,
onDismiss: onDismissJtbdPopup,
} = useJtbdPopup()
const onTakeSurvey = () =>
onTakeSurveyBase({ experimentId: 'workflow_survey' })
// Trigger JTBD popup when AI finishes responding
const previousChatStatus = useRef(status)
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only trigger on status change
useEffect(() => {
const aiWasProcessing =
previousChatStatus.current === 'streaming' ||
previousChatStatus.current === 'submitted'
const aiJustFinished = aiWasProcessing && status === 'ready'
if (aiJustFinished && messages.length > 0) {
triggerIfEligible()
}
previousChatStatus.current = status
}, [status])
const onClickLike = (messageId: string) => {
const { responseText, queryText } = getResponseAndQueryFromMessageId(
messages,
messageId,
)
track(GRAPH_MESSAGE_LIKE_EVENT, { responseText, queryText, messageId })
setLiked((prev) => ({
...prev,
[messageId]: !prev[messageId],
}))
}
const onClickDislike = (messageId: string, comment?: string) => {
const { responseText, queryText } = getResponseAndQueryFromMessageId(
messages,
messageId,
)
track(GRAPH_MESSAGE_DISLIKE_EVENT, {
responseText,
queryText,
messageId,
comment,
})
setDisliked((prev) => ({
...prev,
[messageId]: !prev[messageId],
}))
}
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
recordMessageSent()
onSubmit(e)
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault()
if (input.trim()) {
e.currentTarget.form?.requestSubmit()
}
}
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="styled-scrollbar min-h-0 flex-1 overflow-y-auto pb-2">
{displayMessages.length === 0 ? (
<GraphEmptyState
mounted={mounted}
onSuggestionClick={onSuggestionClick}
/>
) : (
<ChatMessages
liked={liked}
disliked={disliked}
onClickDislike={onClickDislike}
onClickLike={onClickLike}
messages={displayMessages}
status={status}
showJtbdPopup={popupVisible}
showDontShowAgain={false}
onTakeSurvey={onTakeSurvey}
onDismissJtbdPopup={onDismissJtbdPopup}
/>
)}
</div>
{agentUrlError && <ChatError error={agentUrlError} />}
{chatError && <ChatError error={chatError} />}
<div className="shrink-0 border-border/40 border-t bg-background/80 p-2 backdrop-blur-md">
<form
onSubmit={handleSubmit}
className="relative flex w-full items-end gap-2"
>
<textarea
className={cn(
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
)}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
'Visit Amazon and add sensodyne toothpaste to the cart.'
}
rows={1}
/>
{status === 'streaming' ? (
<button
type="button"
onClick={onStop}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
>
<SquareStop className="h-3.5 w-3.5" />
<span className="sr-only">Stop</span>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
<span className="sr-only">Send</span>
</button>
)}
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { Workflow } from 'lucide-react'
import type { FC } from 'react'
import { cn } from '@/lib/utils'
interface Suggestion {
display: string
prompt: string
icon: string
}
const WORKFLOW_SUGGESTIONS: Suggestion[] = [
{
display: 'Search Amazon and add toothpaste to cart',
prompt:
'Go to Amazon, search for toothpaste, select 1 pack filter and add the first result to cart',
icon: '🛒',
},
{
display: 'Accept LinkedIn connection requests',
prompt:
'Open LinkedIn and go to my connection requests, accept one by one in a loop for 25 times',
icon: '🤝',
},
{
display: 'Unsubscribe from Gmail subscriptions',
prompt:
'Go to Gmail, navigate to manage subscriptions and unsubscribe from all',
icon: '📧',
},
]
interface GraphEmptyStateProps {
mounted: boolean
onSuggestionClick: (prompt: string) => void
}
export const GraphEmptyState: FC<GraphEmptyStateProps> = ({
mounted,
onSuggestionClick,
}) => {
return (
<div
className={cn(
'm-0! flex h-full flex-col items-center justify-center space-y-4 text-center opacity-0 transition-all duration-700',
mounted ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
)}
>
<div className="mb-2 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50">
<Workflow className="h-7 w-7 text-[var(--accent-orange)]" />
</div>
<div>
<h2 className="mb-1 font-semibold text-lg">
Create reliable workflows
</h2>
<p className="max-w-[240px] text-muted-foreground text-xs">
Chat with the agent to create and refine browser automation
</p>
</div>
<div className="mt-6 grid w-full max-w-[300px] grid-cols-1 gap-2">
{WORKFLOW_SUGGESTIONS.map((suggestion) => (
<button
type="button"
key={suggestion.display}
onClick={() => onSuggestionClick(suggestion.prompt)}
className="group flex items-center justify-between rounded-lg border border-border/50 bg-card px-3 py-2.5 text-left text-xs transition-all duration-200 hover:border-[var(--accent-orange)]/50 hover:bg-[var(--accent-orange)]/5"
>
{suggestion.display}
<span className="opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{suggestion.icon}
</span>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { Github, Plus, SettingsIcon } from 'lucide-react'
import type { FC } from 'react'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { productRepositoryUrl } from '@/lib/constants/productUrls'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
interface WorkflowsChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
onSelectProvider: (provider: Provider) => void
onNewWorkflow: () => void
hasMessages: boolean
}
export const WorkflowsChatHeader: FC<WorkflowsChatHeaderProps> = ({
selectedProvider,
providers,
onSelectProvider,
onNewWorkflow,
hasMessages,
}) => {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
<div className="flex items-center gap-2">
<ChatProviderSelector
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={onSelectProvider}
>
<button
type="button"
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title="Change AI Provider"
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={18} />
) : (
<ProviderIcon
type={selectedProvider.type as ProviderType}
size={18}
/>
)}
<span className="font-semibold text-base">
{selectedProvider.name}
</span>
</button>
</ChatProviderSelector>
</div>
<div className="flex items-center gap-1">
{hasMessages && (
<button
type="button"
onClick={onNewWorkflow}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New workflow"
>
<Plus className="h-4 w-4" />
</button>
)}
<a
href={productRepositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Star on Github"
>
<Github className="h-4 w-4" />
</a>
<a
href="/app.html#/settings"
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Settings"
>
<SettingsIcon className="h-4 w-4" />
</a>
<ThemeToggle
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
iconClassName="h-4 w-4"
/>
</div>
</header>
)
}

View File

@@ -0,0 +1,111 @@
import type { UIMessage } from 'ai'
type MessagePart = UIMessage['parts'][number]
const TIDBIT_SUFFIXES = ['...', '\u2026'] as const
const isTextPart = (
part: MessagePart,
): part is MessagePart & { type: 'text' } => part.type === 'text'
const isTidbitLine = (line: string): boolean => {
const trimmed = line.trim()
if (trimmed.length === 0) return false
return TIDBIT_SUFFIXES.some((suffix) => trimmed.endsWith(suffix))
}
const getNonEmptyLines = (text: string): string[] =>
text.split('\n').filter((line) => line.trim().length > 0)
const isAllTidbitText = (text: string): boolean => {
const lines = getNonEmptyLines(text)
return lines.length > 0 && lines.every((line) => isTidbitLine(line))
}
export const isWorkflowTidbitMessage = (message: UIMessage): boolean => {
if (message.role !== 'assistant') return false
if (message.parts.length === 0) return false
if (message.parts.some((part) => !isTextPart(part))) return false
const fullText = message.parts
.filter((part) => isTextPart(part))
.map((part) => part.text)
.join('')
return isAllTidbitText(fullText)
}
// within a text part that has multiple tidbit lines, keep only the last line
const compactTidbitLinesInPart = (part: MessagePart): MessagePart => {
if (!isTextPart(part)) return part
const lines = getNonEmptyLines(part.text)
if (lines.length <= 1) return part
if (!lines.every((line) => isTidbitLine(line))) return part
return { ...part, text: lines[lines.length - 1] }
}
// collapse consecutive tidbit text parts within a single message
const compactTidbitPartsInMessage = (message: UIMessage): UIMessage => {
if (message.role !== 'assistant') return message
// first compact multi-line tidbit text within each part
const lineCompactedParts = message.parts.map(compactTidbitLinesInPart)
// then collapse consecutive tidbit parts to just the last one
const compactedParts: UIMessage['parts'] = []
let pendingTidbitPart: (MessagePart & { type: 'text' }) | null = null
const flushPendingTidbitPart = () => {
if (!pendingTidbitPart) return
compactedParts.push(pendingTidbitPart)
pendingTidbitPart = null
}
for (const part of lineCompactedParts) {
if (isTextPart(part) && isAllTidbitText(part.text)) {
pendingTidbitPart = part
continue
}
flushPendingTidbitPart()
compactedParts.push(part)
}
flushPendingTidbitPart()
const partsChanged =
compactedParts.length !== message.parts.length ||
compactedParts.some((p, i) => p !== message.parts[i])
if (!partsChanged) return message
return { ...message, parts: compactedParts }
}
export const getWorkflowDisplayMessages = (
messages: UIMessage[],
): UIMessage[] => {
// first compact tidbit parts within each message
const normalizedMessages = messages.map(compactTidbitPartsInMessage)
const compactedMessages: UIMessage[] = []
// then collapse consecutive tidbit-only messages
for (const message of normalizedMessages) {
const previousMessage = compactedMessages[compactedMessages.length - 1]
const shouldReplacePreviousTidbit =
previousMessage &&
isWorkflowTidbitMessage(previousMessage) &&
isWorkflowTidbitMessage(message)
if (shouldReplacePreviousTidbit) {
compactedMessages[compactedMessages.length - 1] = message
continue
}
compactedMessages.push(message)
}
return compactedMessages
}

View File

@@ -4,8 +4,8 @@ import { MessageResponse } from '@/components/ai-elements/message'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import type { Message } from './useSurveyChat'
import { useVoiceInput } from './useVoiceInput'
import { VoiceInputButton } from './VoiceInputButton'
interface Props {
@@ -81,7 +81,6 @@ export const Chat: FC<Props> = ({
}, [messagesLength])
// Insert transcript into input when transcription completes
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setInput((prev) => {
@@ -90,7 +89,7 @@ export const Chat: FC<Props> = ({
})
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing])
}, [voice])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()

View File

@@ -1,37 +1,44 @@
import { useEffect, useRef, useState } from 'react'
import { transcribeAudio } from './transcribe-audio'
import { useCallback, useEffect, useRef, useState } from 'react'
const WAVEFORM_BAND_COUNT = 5
const GATEWAY_URL = 'https://llm.browseros.com'
export interface VoiceInputState {
isRecording: boolean
isTranscribing: boolean
audioLevels: number[]
error: string | null
onStartRecording: () => void
onStopRecording: () => void
}
export interface UseVoiceInputReturn {
interface UseVoiceInputReturn {
isRecording: boolean
isTranscribing: boolean
transcript: string
audioLevel: number
audioLevels: number[]
error: string | null
startRecording: () => Promise<boolean>
startRecording: () => Promise<void>
stopRecording: () => Promise<void>
clearTranscript: () => void
}
const EMPTY_LEVELS = Array(WAVEFORM_BAND_COUNT).fill(0)
async function transcribeAudio(audioBlob: Blob): Promise<string> {
const formData = new FormData()
formData.append('file', audioBlob, 'recording.webm')
formData.append('response_format', 'json')
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(error.error || `Transcription failed: ${response.status}`)
}
const result = await response.json()
return result.text || ''
}
export function useVoiceInput(): UseVoiceInputReturn {
const [isRecording, setIsRecording] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false)
const [transcript, setTranscript] = useState('')
const [audioLevel, setAudioLevel] = useState(0)
const [audioLevels, setAudioLevels] = useState<number[]>(EMPTY_LEVELS)
const [error, setError] = useState<string | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
@@ -41,7 +48,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
const analyserRef = useRef<AnalyserNode | null>(null)
const animationFrameRef = useRef<number | null>(null)
const stopAudioLevelMonitoring = () => {
const stopAudioLevelMonitoring = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
@@ -52,10 +59,8 @@ export function useVoiceInput(): UseVoiceInputReturn {
audioContextRef.current = null
analyserRef.current = null
setAudioLevel(0)
setAudioLevels(EMPTY_LEVELS)
}
}, [])
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
useEffect(() => {
return () => {
streamRef.current?.getTracks().forEach((track) => {
@@ -66,9 +71,9 @@ export function useVoiceInput(): UseVoiceInputReturn {
}
stopAudioLevelMonitoring()
}
}, [])
}, [stopAudioLevelMonitoring])
const startAudioLevelMonitoring = (stream: MediaStream) => {
const startAudioLevelMonitoring = useCallback((stream: MediaStream) => {
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
analyser.fftSize = 256
@@ -82,36 +87,20 @@ export function useVoiceInput(): UseVoiceInputReturn {
const updateLevel = () => {
if (!analyserRef.current) return
const dataArray = new Uint8Array(analyserRef.current.fftSize)
analyserRef.current.getByteTimeDomainData(dataArray)
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount)
analyserRef.current.getByteFrequencyData(dataArray)
const binCount = dataArray.length
const levels: number[] = []
let totalPeak = 0
for (let band = 0; band < WAVEFORM_BAND_COUNT; band++) {
const start = Math.floor((band / WAVEFORM_BAND_COUNT) * binCount)
const end = Math.floor(((band + 1) / WAVEFORM_BAND_COUNT) * binCount)
let peak = 0
for (let j = start; j < end; j++) {
const amplitude = Math.abs(dataArray[j] - 128)
if (amplitude > peak) peak = amplitude
}
const normalized = Math.round(Math.min(100, (peak / 50) * 100))
levels.push(normalized)
totalPeak += normalized
}
setAudioLevels(levels)
setAudioLevel(Math.round(totalPeak / WAVEFORM_BAND_COUNT))
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
const normalized = Math.min(100, (average / 128) * 100)
setAudioLevel(Math.round(normalized))
animationFrameRef.current = requestAnimationFrame(updateLevel)
}
updateLevel()
}
}, [])
const startRecording = async (): Promise<boolean> => {
const startRecording = useCallback(async () => {
try {
setError(null)
setTranscript('')
@@ -144,14 +133,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
mediaRecorder.start(250)
setIsRecording(true)
return true
} catch (err) {
streamRef.current?.getTracks().forEach((track) => {
track.stop()
})
streamRef.current = null
stopAudioLevelMonitoring()
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
setError('Microphone permission denied')
@@ -163,11 +145,10 @@ export function useVoiceInput(): UseVoiceInputReturn {
} else {
setError('Failed to start recording')
}
return false
}
}
}, [startAudioLevelMonitoring])
const stopRecording = async () => {
const stopRecording = useCallback(async () => {
const mediaRecorder = mediaRecorderRef.current
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
@@ -207,19 +188,18 @@ export function useVoiceInput(): UseVoiceInputReturn {
} finally {
setIsTranscribing(false)
}
}
}, [stopAudioLevelMonitoring])
const clearTranscript = () => {
const clearTranscript = useCallback(() => {
setTranscript('')
setError(null)
}
}, [])
return {
isRecording,
isTranscribing,
transcript,
audioLevel,
audioLevels,
error,
startRecording,
stopRecording,

View File

@@ -17,9 +17,12 @@ export const SettingsSidebarLayout: FC = () => {
useEffect(() => {
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
setMobileOpen(false)
}, [location.pathname])
useEffect(() => {
setMobileOpen(false)
}, [])
if (isMobile) {
return (
<RpcClientProvider>

View File

@@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { ShortcutsDialog } from '@/entrypoints/newtab/index/ShortcutsDialog'
import { useIsMobile } from '@/hooks/use-mobile'
import { SETTINGS_PAGE_VIEWED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
const COLLAPSE_DELAY = 150
@@ -23,6 +25,10 @@ export const SidebarLayout: FC = () => {
setShortcutsDialogOpen(true)
}, [])
useEffect(() => {
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
}, [location.pathname])
useEffect(() => {
setMobileOpen(false)
}, [])
@@ -97,17 +103,11 @@ export const SidebarLayout: FC = () => {
</div>
{/* Main content - full width, centered */}
{location.pathname === '/home/chat' ? (
<main className="relative h-dvh overflow-hidden">
<main className="min-h-screen overflow-y-auto">
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Outlet />
</main>
) : (
<main className="min-h-screen overflow-y-auto">
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Outlet />
</div>
</main>
)}
</div>
</main>
</div>
<ShortcutsDialog
open={shortcutsDialogOpen}

View File

@@ -2,6 +2,8 @@ import { Globe2, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import { cn } from '@/lib/utils'
import { getFaviconUrl, type LlmHubProvider } from './models'
interface HubProviderRowProps {
@@ -18,9 +20,20 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
onDelete,
}) => {
const iconUrl = useMemo(() => getFaviconUrl(provider.url), [provider.url])
const kimiLaunch = useKimiLaunch()
const normalizedName = provider.name.trim().toLowerCase()
const normalizedUrl = provider.url.trim().toLowerCase()
const isKimi = normalizedName === 'kimi' || normalizedUrl.includes('kimi.com')
const showKimiFlare = isKimi && kimiLaunch
return (
<div className="group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md">
<div
className={cn(
'group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
showKimiFlare &&
'border-orange-300/80 bg-orange-50/20 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5',
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{iconUrl ? (
<img
@@ -36,6 +49,16 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-center gap-2">
<span className="block truncate font-semibold">{provider.name}</span>
{showKimiFlare && (
<div className="flex flex-wrap items-center gap-1">
<span className="rounded-full border border-orange-300/60 bg-orange-100/70 px-2 py-0.5 font-semibold text-[11px] text-orange-700 dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
Recommended
</span>
<span className="rounded-full border border-orange-300/60 bg-orange-100/60 px-2.5 py-0.5 font-medium text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
Powered by Moonshot AI
</span>
</div>
)}
</div>
<p className="truncate text-muted-foreground/70 text-xs">
{provider.url}

View File

@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import { clear } from 'idb-keyval'
import localforage from 'localforage'
import { Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect } from 'react'
@@ -10,7 +10,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { resetIdentity } from '@/lib/analytics/identify'
import { signOut } from '@/lib/auth/auth-client'
import { providersStorage } from '@/lib/llm-providers/storage'
import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
@@ -25,9 +24,8 @@ export const LogoutPage: FC = () => {
await providersStorage.removeValue()
await scheduledJobStorage.removeValue()
queryClient.clear()
await clear()
await localforage.clear()
resetIdentity()
await signOut()
navigate('/home', { replace: true })
}

View File

@@ -1,40 +1,31 @@
import {
Check,
Copy,
ExternalLink,
Loader2,
RefreshCw,
Server,
} from 'lucide-react'
import { type FC, useCallback, useState } from 'react'
import { toast } from 'sonner'
import { Check, Copy, ExternalLink, Globe, Server } from 'lucide-react'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import { MCP_SERVER_RESTARTED_EVENT } from '@/lib/constants/analyticsEvents'
import { sendServerMessage } from '@/lib/messaging/server/serverMessages'
import { track } from '@/lib/metrics/track'
interface MCPServerHeaderProps {
serverUrl: string | null
isLoading: boolean
error: string | null
onServerRestart?: () => void
title?: string
description?: string
remoteAccessEnabled?: boolean
}
const DOCS_URL = 'https://docs.browseros.com/features/use-with-claude-code'
const HEALTH_CHECK_TIMEOUT_MS = 60_000
const HEALTH_CHECK_INTERVAL_MS = 2_000
export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
serverUrl,
isLoading,
error,
onServerRestart,
title = 'BrowserOS MCP Server',
description = 'Connect BrowserOS to MCP clients like claude code, gemini and others.',
remoteAccessEnabled = false,
}) => {
const [isCopied, setIsCopied] = useState(false)
const [isRestarting, setIsRestarting] = useState(false)
const handleCopy = async () => {
if (!serverUrl) return
try {
await navigator.clipboard.writeText(serverUrl)
setIsCopied(true)
@@ -44,57 +35,6 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
}
}
const checkServerHealth = useCallback(async (): Promise<boolean> => {
try {
const result = await sendServerMessage('checkHealth', undefined)
return result.healthy
} catch {
return false
}
}, [])
const handleRestart = async () => {
setIsRestarting(true)
try {
const { getBrowserOSAdapter } = await import('@/lib/browseros/adapter')
const { BROWSEROS_PREFS } = await import('@/lib/browseros/prefs')
const adapter = getBrowserOSAdapter()
await adapter.setPref(BROWSEROS_PREFS.RESTART_SERVER, true)
const startTime = Date.now()
const waitForHealth = (): Promise<boolean> =>
new Promise((resolve) => {
const check = async () => {
if (Date.now() - startTime >= HEALTH_CHECK_TIMEOUT_MS) {
resolve(false)
return
}
if (await checkServerHealth()) {
resolve(true)
return
}
setTimeout(check, HEALTH_CHECK_INTERVAL_MS)
}
setTimeout(check, HEALTH_CHECK_INTERVAL_MS)
})
const healthy = await waitForHealth()
if (healthy) {
track(MCP_SERVER_RESTARTED_EVENT)
toast.success('Server restarted successfully')
onServerRestart?.()
} else {
toast.error('Server did not respond. Try restarting the browser.')
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'Failed to restart server',
)
} finally {
setIsRestarting(false)
}
}
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">
@@ -103,21 +43,18 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
</div>
<div className="flex-1">
<div className="mb-1 flex items-center justify-between">
<h2 className="font-semibold text-xl">BrowserOS MCP Server</h2>
<h2 className="font-semibold text-xl">{title}</h2>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-[var(--accent-orange)]"
>
Docs
Setup a client
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<p className="mb-6 text-muted-foreground text-sm">
Connect BrowserOS to MCP clients like Claude Code, Gemini CLI and
others.
</p>
<p className="mb-6 text-muted-foreground text-sm">{description}</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<span className="whitespace-nowrap font-medium text-sm">
@@ -139,7 +76,6 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
onClick={handleCopy}
disabled={!serverUrl || isLoading}
className="shrink-0"
title="Copy URL"
>
{isCopied ? (
<Check className="h-4 w-4 text-green-600" />
@@ -147,22 +83,19 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleRestart}
disabled={isLoading || isRestarting}
className="shrink-0"
title="Restart server"
>
{isRestarting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
</div>
{remoteAccessEnabled && serverUrl && !isLoading && (
<div className="mt-3 flex items-start gap-2 rounded-lg bg-muted/50 px-3 py-2">
<Globe className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
External access is enabled. To connect from another device,
replace <span className="font-mono">127.0.0.1</span> with this
machine's IP address.
</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import type { McpTool } from '@/lib/mcp/client'
import { sendServerMessage } from '@/lib/messaging/server/serverMessages'
import { MCPServerHeader } from './MCPServerHeader'
import { MCPToolsSection } from './MCPToolsSection'
import { QuickSetupSection } from './QuickSetupSection'
import { ServerSettingsCard } from './ServerSettingsCard'
/** @public */
export const MCPSettingsPage: FC = () => {
@@ -12,6 +12,8 @@ export const MCPSettingsPage: FC = () => {
const [urlLoading, setUrlLoading] = useState(true)
const [urlError, setUrlError] = useState<string | null>(null)
const [remoteAccessEnabled, setRemoteAccessEnabled] = useState(false)
const [tools, setTools] = useState<McpTool[]>([])
const [toolsLoading, setToolsLoading] = useState(false)
const [toolsError, setToolsError] = useState<string | null>(null)
@@ -80,10 +82,13 @@ export const MCPSettingsPage: FC = () => {
serverUrl={serverUrl}
isLoading={urlLoading}
error={urlError}
onServerRestart={loadServerUrlAndTools}
remoteAccessEnabled={remoteAccessEnabled}
/>
<QuickSetupSection serverUrl={serverUrl} />
<ServerSettingsCard
onServerRestart={loadServerUrlAndTools}
onRemoteAccessChange={setRemoteAccessEnabled}
/>
<MCPToolsSection
tools={tools}

View File

@@ -1,162 +0,0 @@
import { Check, Copy, Terminal } from 'lucide-react'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface QuickSetupSectionProps {
serverUrl: string | null
}
interface ClientConfig {
id: string
name: string
type: 'command' | 'json'
getSnippet: (url: string) => string
fileName?: string
}
const clients: ClientConfig[] = [
{
id: 'claude-code',
name: 'Claude Code',
type: 'command',
getSnippet: (url) =>
`claude mcp add --transport http browseros ${url} --scope user`,
},
{
id: 'gemini-cli',
name: 'Gemini CLI',
type: 'command',
getSnippet: (url) =>
`gemini mcp add local-server ${url} --transport http --scope user`,
},
{
id: 'codex',
name: 'Codex',
type: 'command',
getSnippet: (url) => `codex mcp add browseros ${url}`,
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
type: 'json',
fileName: 'claude_desktop_config.json',
getSnippet: (url) =>
JSON.stringify(
{
mcpServers: {
browserOS: {
command: 'npx',
args: ['mcp-remote', url],
},
},
},
null,
2,
),
},
{
id: 'openclaw',
name: 'OpenClaw',
type: 'json',
fileName: 'openclaw.json',
getSnippet: (url) =>
JSON.stringify(
{
mcpServers: {
browseros: { url },
},
},
null,
2,
),
},
]
const CopyButton: FC<{ text: string }> = ({ text }) => {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Clipboard API failed
}
}
return (
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
)
}
export const QuickSetupSection: FC<QuickSetupSectionProps> = ({
serverUrl,
}) => {
if (!serverUrl) return null
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">
<Terminal className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<h2 className="mb-1 font-semibold text-xl">Quick Setup</h2>
<p className="mb-4 text-muted-foreground text-sm">
Copy and run the command for your tool
</p>
<Tabs defaultValue="claude-code">
<TabsList className="mb-3 flex-wrap">
{clients.map((client) => (
<TabsTrigger key={client.id} value={client.id}>
{client.name}
</TabsTrigger>
))}
</TabsList>
{clients.map((client) => {
const snippet = client.getSnippet(serverUrl)
return (
<TabsContent key={client.id} value={client.id}>
<div className="space-y-3">
{client.fileName && (
<p className="text-muted-foreground text-xs">
Add to{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
{client.fileName}
</code>
</p>
)}
<div className="flex items-start gap-2 rounded-lg border border-border bg-background px-3 py-2.5">
<pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs">
{client.type === 'command' && (
<span className="mr-1 text-muted-foreground">$</span>
)}
{snippet}
</pre>
<CopyButton text={snippet} />
</div>
</div>
</TabsContent>
)
})}
</Tabs>
</div>
</div>
</div>
)
}

View File

@@ -1,12 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod/v3'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -35,15 +31,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import {
defaultProviderIdStorage,
providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
import { track } from '@/lib/metrics/track'
import { refinePrompt } from '@/lib/schedules/refine-prompt'
import type { ScheduledJob } from './types'
const formSchema = z
@@ -56,7 +43,6 @@ const formSchema = z
scheduleType: z.enum(['daily', 'hourly', 'minutes']),
scheduleTime: z.string().optional(),
scheduleInterval: z.number().int().min(1).max(60).optional(),
providerId: z.string().optional(),
enabled: z.boolean(),
})
.superRefine((data, ctx) => {
@@ -95,8 +81,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
onSave,
}) => {
const isEditing = !!initialValues
const [providers, setProviders] = useState<LlmProviderConfig[]>([])
const [defaultProviderId, setDefaultProviderId] = useState<string>('')
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
@@ -106,36 +90,14 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleType: 'daily',
scheduleTime: '09:00',
scheduleInterval: 1,
providerId: undefined,
enabled: true,
},
})
const scheduleType = form.watch('scheduleType')
const selectedProviderId = form.watch('providerId')
const queryValue = form.watch('query')
const [isRefining, setIsRefining] = useState(false)
const originalPromptRef = useRef<string | null>(null)
const refineRequestIdRef = useRef(0)
const isProgrammaticChange = useRef(false)
// Load providers from storage
useEffect(() => {
if (!open) return
Promise.all([
providersStorage.getValue(),
defaultProviderIdStorage.getValue(),
]).then(([providerList, defId]) => {
setProviders(providerList ?? [])
setDefaultProviderId(defId ?? '')
})
}, [open])
useEffect(() => {
if (open) {
refineRequestIdRef.current++
originalPromptRef.current = null
setIsRefining(false)
if (initialValues) {
form.reset({
name: initialValues.name,
@@ -143,7 +105,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleType: initialValues.scheduleType,
scheduleTime: initialValues.scheduleTime || '09:00',
scheduleInterval: initialValues.scheduleInterval || 1,
providerId: initialValues.providerId,
enabled: initialValues.enabled,
})
} else {
@@ -153,87 +114,12 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleType: 'daily',
scheduleTime: '09:00',
scheduleInterval: 1,
providerId: undefined,
enabled: true,
})
}
}
}, [open, initialValues, form])
// Resolve the currently selected provider for the selector display
const resolvedProvider: Provider | null = (() => {
const id = selectedProviderId ?? defaultProviderId
const found = providers.find((p) => p.id === id)
if (found) return { id: found.id, name: found.name, type: found.type }
if (providers[0])
return {
id: providers[0].id,
name: providers[0].name,
type: providers[0].type,
}
return null
})()
const providerOptions: Provider[] = providers.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
}))
// Replace textarea content via execCommand so the browser's native undo
// stack (Cmd+Z / Ctrl+Z) records the change. Falls back to form.setValue
// if the textarea element can't be found.
const setQueryWithUndo = (value: string) => {
const textarea = document.querySelector(
'textarea[name="query"]',
) as HTMLTextAreaElement
if (textarea) {
isProgrammaticChange.current = true
textarea.focus()
textarea.select()
document.execCommand('insertText', false, value)
isProgrammaticChange.current = false
} else {
form.setValue('query', value)
}
}
const handleRefinePrompt = async () => {
const currentQuery = form.getValues('query').trim()
const currentName = form.getValues('name').trim()
if (!currentQuery) return
const requestId = ++refineRequestIdRef.current
setIsRefining(true)
originalPromptRef.current = currentQuery
try {
const refined = await refinePrompt({
prompt: currentQuery,
name: currentName || 'Untitled Task',
providerId: form.getValues('providerId'),
})
if (requestId !== refineRequestIdRef.current) return
setQueryWithUndo(refined)
track(SCHEDULED_TASK_PROMPT_REFINED_EVENT)
} catch {
if (requestId !== refineRequestIdRef.current) return
toast.error('Failed to rewrite prompt. Please try again.')
originalPromptRef.current = null
} finally {
if (requestId === refineRequestIdRef.current) {
setIsRefining(false)
}
}
}
const handleUndoRefine = () => {
if (originalPromptRef.current !== null) {
setQueryWithUndo(originalPromptRef.current)
originalPromptRef.current = null
}
}
const onSubmit = (values: FormValues) => {
onSave({
name: values.name.trim(),
@@ -243,11 +129,9 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
values.scheduleType === 'daily' ? values.scheduleTime : undefined,
scheduleInterval:
values.scheduleType !== 'daily' ? values.scheduleInterval : undefined,
providerId: values.providerId,
enabled: values.enabled,
})
form.reset()
originalPromptRef.current = null
onOpenChange(false)
}
@@ -285,96 +169,22 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
name="query"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Prompt</FormLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto gap-1 px-2 py-1 text-muted-foreground text-xs"
disabled={!queryValue?.trim() || isRefining}
onClick={handleRefinePrompt}
>
{isRefining ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Sparkles className="h-3 w-3" />
)}
{isRefining ? 'Rewriting...' : 'Rewrite with AI'}
</Button>
</div>
<FormLabel>Prompt</FormLabel>
<FormControl>
<Textarea
placeholder="What should the agent do? e.g., Check my email and summarize important messages"
className="min-h-[100px] resize-none"
{...field}
onChange={(e) => {
field.onChange(e)
if (
!isProgrammaticChange.current &&
originalPromptRef.current !== null
) {
originalPromptRef.current = null
}
}}
/>
</FormControl>
{!isRefining && originalPromptRef.current !== null ? (
<button
type="button"
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
onClick={handleUndoRefine}
>
<Undo2 className="h-3 w-3" />
Undo rewrite
</button>
) : (
<FormDescription>
The instruction that will be sent to the agent
</FormDescription>
)}
<FormDescription>
The instruction that will be sent to the agent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{providers.length > 0 && resolvedProvider && (
<FormItem>
<FormLabel>AI Provider</FormLabel>
<ChatProviderSelector
providers={providerOptions}
selectedProvider={resolvedProvider}
onSelectProvider={(provider) =>
form.setValue('providerId', provider.id)
}
>
<Button
type="button"
variant="outline"
className="w-full justify-between"
>
<span className="flex items-center gap-2">
<span className="text-muted-foreground">
{resolvedProvider.type === 'browseros' ? (
<BrowserOSIcon size={16} />
) : (
<ProviderIcon
type={resolvedProvider.type as ProviderType}
size={16}
/>
)}
</span>
{resolvedProvider.name}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</ChatProviderSelector>
<FormDescription>
The AI provider used to run this task
</FormDescription>
</FormItem>
)}
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}

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