Compare commits

..

2 Commits

Author SHA1 Message Date
Nikhil Sonti
4f23784028 fix: address review feedback for PR #490
- Remove --extension-port from test server helper (would break integration tests)
- Remove all extensionPort references from config.test.ts
- Remove dead _loadExtensions parameter from BrowserOSAppManager constructor
- Update parallel-executor caller to match new signature
- Clean stale JSDoc comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:56:37 -07:00
Nikhil Sonti
ea3f1080f3 fix: remove unused controller extension and controller backend
The controller-ext Chrome extension and its server-side ControllerBackend
are no longer used — all browser automation goes through CDP exclusively.

- Delete apps/controller-ext/ (72 files, ~9000 lines)
- Delete apps/server/src/browser/backends/controller.ts
- Delete scripts/build/controller-ext.ts
- Remove controller from Browser constructor, API types, status route
- Remove extensionPort from server config schema
- Clean up shared timeout constants (CONTROLLER_*, WS_*)
- Update CLAUDE.md, README.md, and test helpers
- Remove build:ext script and controller-ext build steps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:02:29 -07:00
251 changed files with 1591 additions and 17338 deletions

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- "packages/browseros-agent/**"
- 'packages/browseros-agent/**'
jobs:
biome:
@@ -50,9 +50,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

@@ -1,91 +0,0 @@
name: Weekly Eval
on:
schedule:
# Every Saturday at 06:00 UTC
- cron: '0 6 * * 6'
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 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 }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
BROWSEROS_BINARY: /usr/bin/browseros
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
echo "Running eval with config: $EVAL_CONFIG"
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()
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,44 +1,15 @@
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
on: []
jobs:
test:
name: Tests / ${{ matrix.suite }}
runs-on: ubuntu-latest
timeout-minutes: 20
name: Run Tests
runs-on: macos-latest
timeout-minutes: 10
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
@@ -50,91 +21,7 @@ jobs:
- 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
- name: Run all tests
run: bun test:all
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"
fi
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

View File

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

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

@@ -32,7 +32,6 @@ This single command handles everything:
- Builds the Go dev CLI tool
- Picks random available ports (avoids conflicts)
- Creates a fresh browser profile
- Builds controller-ext
- Runs GraphQL codegen if `apps/agent/generated/graphql/` doesn't exist
- Starts the agent extension with WXT HMR (hot module replacement)
- Waits for CDP to be ready

View File

@@ -187,10 +187,6 @@ log.txt
# Testing iteration temp files
tmp/
# CI artifacts
.ci/
test-results/
# Coding agent artifacts
.agent/
.llm/

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 | `controller-server/`, `rate-limiter/` |
| Folders | kebab-case | `rate-limiter/`, `console-collector/` |
Classes remain PascalCase in code, but live in kebab-case files:
```typescript
@@ -85,7 +85,7 @@ bun run dist:ext # Build extension for production
## Architecture
This is a monorepo with three packages in `apps/`:
This is a monorepo with packages in `apps/`:
### Server (`apps/server`)
The main MCP server that exposes browser automation tools via HTTP/SSE.
@@ -93,22 +93,13 @@ The main MCP server that exposes browser automation tools via HTTP/SSE.
**Entry point:** `apps/server/src/index.ts` → `apps/server/src/main.ts`
**Key components:**
- `src/tools/` - MCP tool definitions, split into:
- `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/tools/` - MCP tool definitions using Chrome DevTools Protocol
- `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 by both server and extension. Avoids magic numbers.
Shared constants, types, and configuration used across the monorepo. Avoids magic numbers.
**Structure:**
- `src/constants/` - Configuration values (ports, timeouts, limits, urls, paths)
@@ -116,22 +107,10 @@ Shared constants, types, and configuration used by both server and extension. Av
**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 (direct) ←── or ──→ WebSocket → Extension → Chrome APIs
AI Agent/MCP Client → HTTP Server (Hono) → Tool Handler → CDP (Chrome DevTools Protocol)
```
## Creating Packages

View File

@@ -1,6 +1,6 @@
# BrowserOS Agent
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).
Monorepo for the BrowserOS-agent -- contains the agent UI and server (which contains the agent loop).
> **⚠️ NOTE:** This is only a submodule, the main project is at -- https://github.com/browseros-ai/BrowserOS
@@ -10,7 +10,6 @@ Monorepo for the BrowserOS-agent -- contains 3 packages: agent-UI, server (which
apps/
server/ # Bun server - MCP endpoints + agent loop
agent/ # Agent UI (Chrome extension)
controller-ext/ # BrowserOS Controller (Chrome extension for chrome.* APIs)
packages/
shared/ # Shared constants (ports, timeouts, limits)
@@ -20,14 +19,12 @@ packages/
|---------|-------------|
| `apps/server` | Bun server exposing MCP tools and running the agent loop |
| `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/server`: Bun server which contains the agent loop and tools. All browser automation uses Chrome DevTools Protocol (CDP).
- `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.
```
┌──────────────────────────────────────────────────────────────────────────┐
@@ -44,20 +41,19 @@ packages/
│ /chat ────── Agent streaming │
│ /health ─── Health check │
│ │
│ Tools:
├── CDP Tools (console, network, input, screenshot, ...)
│ └── Controller Tools (tabs, navigation, clicks, bookmarks, history) │
│ Tools (all CDP-based):
navigation, input, snapshot, tabs, bookmarks, history, ... │
└──────────────────────────────────────────────────────────────────────────┘
│ 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 │
└─────────────────────┘ └─────────────────────────────────────┘
│ CDP (client)
┌─────────────────────┐
│ Chromium CDP │
│ (cdpPort: 9000) │
│ │
│ Server connects │
│ TO this as client │
└─────────────────────┘
```
### Ports
@@ -66,7 +62,6 @@ 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` | WebSocket server for controller extension |
## Development
@@ -90,9 +85,8 @@ process-compose up
The `process-compose up` command runs the following in order:
1. `bun install` — installs dependencies
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
2. `bun --cwd apps/agent codegen` — generates agent code
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
### Environment Variables
@@ -108,7 +102,6 @@ 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 | 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) |
@@ -157,15 +150,12 @@ bun run start:server # Start the server
bun run start:agent # Start agent extension (dev mode)
# Build
bun run build # Build server, agent, and controller extension
bun run build # Build server and agent
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

@@ -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

@@ -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 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,189 +0,0 @@
import {
ArrowLeft,
BookOpen,
Bot,
Compass,
CreditCard,
GitBranch,
MessageSquare,
Palette,
RotateCcw,
Search,
Server,
} from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { cn } from '@/lib/utils'
type BaseNavItem = {
name: string
icon: typeof Bot
feature?: Feature
}
type InternalNavItem = BaseNavItem & {
href?: never
to: string
}
type ExternalNavItem = BaseNavItem & {
href: string
to?: never
}
type NavItem = InternalNavItem | ExternalNavItem
type NavSection = {
label: string
items: NavItem[]
}
function isExternalNavItem(item: NavItem): item is ExternalNavItem {
return 'href' in item
}
const getNavLinkClassName = (isActive: boolean) =>
cn(
'flex h-9 items-center gap-2 overflow-hidden whitespace-nowrap rounded-md px-3 font-medium text-sm transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
)
const getSectionClassName = (index: number) =>
cn(index > 0 && 'mt-3 border-t pt-3')
const sectionLabelClassName =
'mb-2 px-3 font-semibold text-[10px] text-muted-foreground uppercase tracking-[0.18em]'
const primarySettingsSections: NavSection[] = [
{
label: 'Provider Settings',
items: [
{ name: 'BrowserOS AI', to: '/settings/ai', icon: Bot },
{
name: 'Chat & Council Provider',
to: '/settings/chat',
icon: MessageSquare,
},
{ name: 'Search Provider', to: '/settings/search', icon: Search },
],
},
{
label: 'Other',
items: [
{
name: 'Customize BrowserOS',
to: '/settings/customization',
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ 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,
},
],
},
]
const helpItems: NavItem[] = [
{ name: 'Docs', href: 'https://docs.browseros.com/', icon: BookOpen },
{ name: 'Features', to: '/onboarding/features', icon: Compass },
{ name: 'Revisit Onboarding', to: '/onboarding', icon: RotateCcw },
]
export const SettingsSidebar: FC = () => {
const { supports } = useCapabilities()
const filteredSections = primarySettingsSections
.map((section) => ({
...section,
items: section.items.filter(
(item) => !item.feature || supports(item.feature),
),
}))
.filter((section) => section.items.length > 0)
const filteredHelpItems = helpItems.filter(
(item) => !item.feature || supports(item.feature),
)
const renderNavItem = (item: NavItem) => {
const Icon = item.icon
if (isExternalNavItem(item)) {
return (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className={getNavLinkClassName(false)}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{item.name}</span>
</a>
)
}
return (
<NavLink
key={item.to}
to={item.to}
end
className={({ isActive }) => getNavLinkClassName(isActive)}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{item.name}</span>
</NavLink>
)
}
const renderSection = (section: NavSection, index: number) => (
<div key={section.label} className={getSectionClassName(index)}>
<div className={sectionLabelClassName}>{section.label}</div>
<nav className="space-y-1">{section.items.map(renderNavItem)}</nav>
</div>
)
return (
<div className="flex h-full w-64 flex-col border-r bg-sidebar text-sidebar-foreground">
<div className="flex h-14 items-center justify-between border-b px-2">
<NavLink
to="/home"
className="flex h-9 items-center gap-2 overflow-hidden whitespace-nowrap rounded-md px-3 font-medium text-sm transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<ArrowLeft className="size-4 shrink-0" />
<span className="truncate">Back</span>
</NavLink>
<ThemeToggle
className="mr-1 h-8 w-8 shrink-0"
iconClassName="h-4 w-4"
/>
</div>
<div className="flex flex-1 flex-col overflow-y-auto overflow-x-hidden p-2">
<div className="mb-2 px-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">
Settings
</div>
<div>{filteredSections.map(renderSection)}</div>
<div className="mt-auto pt-4">
<div className={sectionLabelClassName}>Help</div>
<nav className="space-y-1">
{filteredHelpItems.map(renderNavItem)}
</nav>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import {
Brain,
CalendarClock,
GitBranch,
Home,
PlugZap,
Settings,
@@ -17,6 +18,7 @@ import {
} from '@/components/ui/tooltip'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useOpenSettings } from '@/lib/settings/useOpenSettings'
import { cn } from '@/lib/utils'
interface SidebarNavigationProps {
@@ -25,9 +27,10 @@ interface SidebarNavigationProps {
type NavItem = {
name: string
to: string
to?: string
icon: typeof Home
feature?: Feature
action?: 'settings'
}
const primaryNavItems: NavItem[] = [
@@ -39,6 +42,12 @@ const primaryNavItems: NavItem[] = [
feature: Feature.MANAGED_MCP_SUPPORT,
},
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
{
name: 'Workflows',
to: '/workflows',
icon: GitBranch,
feature: Feature.WORKFLOW_SUPPORT,
},
{
name: 'Skills',
to: '/home/skills',
@@ -57,14 +66,19 @@ const primaryNavItems: NavItem[] = [
icon: Sparkles,
feature: Feature.SOUL_SUPPORT,
},
{ name: 'Settings', to: '/settings/ai', icon: Settings },
{ name: 'Settings', icon: Settings, action: 'settings' },
]
const navItemClassName =
'flex h-9 items-center gap-2 overflow-hidden whitespace-nowrap rounded-md px-3 font-medium text-sm transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
expanded = true,
}) => {
const location = useLocation()
const openSettings = useOpenSettings()
const { supports } = useCapabilities()
const isSettingsActive = location.pathname.startsWith('/settings')
const filteredItems = primaryNavItems.filter(
(item) => !item.feature || supports(item.feature),
@@ -76,16 +90,52 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
<nav className="space-y-1">
{filteredItems.map((item) => {
const Icon = item.icon
const isActive =
item.to === '/settings/ai'
? location.pathname.startsWith('/settings')
: location.pathname === item.to
// Settings is a button that opens the dialog
if (item.action === 'settings') {
const settingsButton = (
<button
type="button"
onClick={() => openSettings()}
className={cn(
navItemClassName,
'w-full',
isSettingsActive &&
'bg-sidebar-accent text-sidebar-accent-foreground',
)}
>
<Icon className="size-4 shrink-0" />
<span
className={cn(
'truncate transition-opacity duration-200',
expanded ? 'opacity-100' : 'opacity-0',
)}
>
{item.name}
</span>
</button>
)
if (!expanded) {
return (
<Tooltip key="settings">
<TooltipTrigger asChild>{settingsButton}</TooltipTrigger>
<TooltipContent side="right">{item.name}</TooltipContent>
</Tooltip>
)
}
return <div key="settings">{settingsButton}</div>
}
// Regular nav items use NavLink
const itemPath = item.to ?? '/home'
const isActive = location.pathname === itemPath
const navItem = (
<NavLink
to={item.to}
to={itemPath}
className={cn(
'flex h-9 items-center gap-2 overflow-hidden whitespace-nowrap rounded-md px-3 font-medium text-sm transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
navItemClassName,
isActive &&
'bg-sidebar-accent text-sidebar-accent-foreground',
)}

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

@@ -1,34 +1,35 @@
import type { FC } from 'react'
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
import {
HashRouter,
type Location,
Navigate,
Route,
Routes,
useLocation,
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'
import { FeaturesPage } from '../onboarding/features/Features'
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'
import { SettingsSidebarLayout } from './layout/SettingsSidebarLayout'
import { SidebarLayout } from './layout/SidebarLayout'
import { LlmHubPage } from './llm-hub/LlmHubPage'
import { LoginPage } from './login/LoginPage'
import { LogoutPage } from './login/LogoutPage'
import { MagicLinkCallback } from './login/MagicLinkCallback'
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
import { MemoryPage } from './memory/MemoryPage'
import { ProfilePage } from './profile/ProfilePage'
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SettingsDialog } from './settings-dialog/SettingsDialog'
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 } {
@@ -62,12 +63,29 @@ const OptionsRedirect: FC = () => {
return <Navigate to={newPath} replace />
}
export const App: FC = () => {
/** Redirect direct /settings/:tab visits so the dialog has a background page */
const SettingsRedirect: FC = () => {
const { tab } = useParams()
return (
<Navigate
to={`/settings/${tab || 'ai'}`}
state={{ backgroundLocation: { pathname: '/home' } }}
replace
/>
)
}
const AppRoutes: FC = () => {
const location = useLocation()
const surveyParams = getSurveyParams()
const backgroundLocation = (
location.state as { backgroundLocation?: Location } | null
)?.backgroundLocation
return (
<HashRouter>
<Routes>
<>
<Routes location={backgroundLocation || location}>
{/* Public auth routes */}
<Route element={<AuthLayout />}>
<Route path="login" element={<LoginPage />} />
@@ -81,7 +99,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 />} />
@@ -94,19 +111,14 @@ export const App: FC = () => {
<Route path="scheduled" element={<ScheduledTasksPage />} />
</Route>
{/* Settings with dedicated sidebar */}
<Route element={<SettingsSidebarLayout />}>
<Route path="settings">
<Route index element={<Navigate to="/settings/ai" replace />} />
<Route path="ai" element={<AISettingsPage key="ai" />} />
<Route path="chat" element={<LlmHubPage />} />
<Route path="mcp" element={<MCPSettingsPage />} />
<Route path="customization" element={<CustomizationPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
</Route>
</Route>
{/* Survey page - standalone */}
<Route
path="settings/survey"
element={<SurveyPage {...surveyParams} />}
/>
{/* Direct /settings/:tab access without background location — redirect with one */}
<Route path="settings/:tab?" element={<SettingsRedirect />} />
{/* Full-screen without sidebar */}
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
@@ -142,6 +154,19 @@ export const App: FC = () => {
{/* Fallback to home */}
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
</HashRouter>
{/* Modal overlay — renders settings dialog on top of background page */}
{backgroundLocation && (
<Routes>
<Route path="settings/:tab?" element={<SettingsDialog />} />
</Routes>
)}
</>
)
}
export const App: FC = () => (
<HashRouter>
<AppRoutes />
</HashRouter>
)

View File

@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import { type FC, useMemo, useState } from 'react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -17,25 +17,19 @@ 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'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import {
getProviderTemplate,
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 { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
import { track } from '@/lib/metrics/track'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import {
@@ -48,47 +42,6 @@ import { LlmProvidersHeader } from './LlmProvidersHeader'
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
@@ -135,7 +88,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))
@@ -156,47 +111,56 @@ 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,
)
// OAuth status for ChatGPT Plus/Pro
const {
status: chatgptProStatus,
startPolling: startChatGPTProPolling,
disconnect: disconnectChatGPTPro,
} = useOAuthStatus('chatgpt-pro')
const oauthFlows: Record<
string,
{
startOAuthFlow: (url: string | undefined) => Promise<void>
disconnect: () => Promise<void>
disconnectedEvent: string
// Track whether user explicitly started an OAuth flow this session
const oauthFlowStartedRef = useRef(false)
// Auto-create provider only when user actively completed OAuth,
// not on passive page load when server has old tokens
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!chatgptProStatus?.authenticated) return
if (!oauthFlowStartedRef.current) return
const exists = providers.some((p) => p.type === 'chatgpt-pro')
if (exists) return
const now = Date.now()
try {
const template = getProviderTemplate('chatgpt-pro')
saveProvider({
id: `chatgpt-pro-${now}`,
type: 'chatgpt-pro',
name: `ChatGPT Plus/Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`,
modelId: template?.defaultModelId ?? 'gpt-5.3-codex',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 400000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, {
email: chatgptProStatus.email,
})
toast.success('ChatGPT Plus/Pro Connected', {
description: chatgptProStatus.email
? `Authenticated as ${chatgptProStatus.email}`
: 'Successfully authenticated with ChatGPT Plus/Pro',
})
} catch (err) {
toast.error('Failed to create ChatGPT Plus/Pro provider', {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
oauthFlowStartedRef.current = false
}
> = {
'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,
},
}
}, [chatgptProStatus?.authenticated])
const handleAddProvider = () => {
setTemplateValues(undefined)
@@ -204,10 +168,9 @@ export const AISettingsPage: FC = () => {
}
const handleUseTemplate = (template: ProviderTemplate) => {
// OAuth providers: trigger OAuth flow
const oauthFlow = oauthFlows[template.id]
if (oauthFlow) {
oauthFlow.startOAuthFlow(agentServerUrl ?? undefined)
// OAuth providers: trigger OAuth flow instead of opening form dialog
if (template.id === 'chatgpt-pro') {
handleStartChatGPTProOAuth()
return
}
@@ -223,6 +186,27 @@ export const AISettingsPage: FC = () => {
setIsNewDialogOpen(true)
}
const handleStartChatGPTProOAuth = () => {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
oauthFlowStartedRef.current = true
const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings')
const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}`
window.open(startUrl, '_blank')
// Start polling for OAuth completion
startChatGPTProPolling()
track(CHATGPT_PRO_OAUTH_STARTED_EVENT)
toast.info('Authenticating with ChatGPT Plus/Pro', {
description: 'Complete the login in the opened tab.',
})
}
const handleEditProvider = (provider: LlmProviderConfig) => {
setEditingProvider(provider)
setIsEditDialogOpen(true)
@@ -233,18 +217,16 @@ 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) {
// Clear OAuth tokens on server for OAuth-based providers
if (providerToDelete.type === 'chatgpt-pro') {
await disconnectChatGPTPro()
track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT)
}
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) => {

View File

@@ -62,8 +62,6 @@ const providerTypeEnum = z.enum([
'bedrock',
'browseros',
'chatgpt-pro',
'github-copilot',
'qwen-code',
])
/**
@@ -133,12 +131,8 @@ 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'
) {
// ChatGPT Pro: no credentials needed (server-managed OAuth)
else if (data.type === 'chatgpt-pro') {
// No validation needed — OAuth tokens are on the server
}
// Other providers: require baseUrl
@@ -196,11 +190,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
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') {
@@ -388,13 +377,8 @@ 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
// ChatGPT Pro: always testable (server has the OAuth token)
if (watchedType === 'chatgpt-pro') return true
if (watchedType === 'azure') {
return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey
@@ -477,15 +461,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 (

View File

@@ -103,10 +103,10 @@ export const ProviderCard: FC<ProviderCardProps> = ({
for better performance.
</>
)
) : provider.baseUrl ? (
`${provider.modelId}${provider.baseUrl}`
) : (
provider.modelId
provider.baseUrl
? `${provider.modelId}${provider.baseUrl}`
: provider.modelId
)}
</p>
</div>

View File

@@ -26,11 +26,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
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)

View File

@@ -24,8 +24,6 @@ export interface ModelsData {
browseros: ModelInfo[]
moonshot: ModelInfo[]
'chatgpt-pro': ModelInfo[]
'github-copilot': ModelInfo[]
'qwen-code': ModelInfo[]
}
/**
@@ -103,38 +101,6 @@ export const MODELS_DATA: ModelsData = {
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
{ modelId: 'gpt-5.1', contextLength: 200000 },
],
'github-copilot': [
// Free tier (unlimited with Pro)
{ modelId: 'gpt-5-mini', contextLength: 128000 },
{ modelId: 'claude-haiku-4.5', contextLength: 128000 },
{ modelId: 'gpt-4o', contextLength: 64000 },
{ modelId: 'gpt-4.1', contextLength: 64000 },
// Premium models (Pro: 300/mo, Pro+: 1500/mo)
{ modelId: 'claude-sonnet-4.6', contextLength: 128000 },
{ modelId: 'claude-sonnet-4.5', contextLength: 128000 },
{ modelId: 'claude-sonnet-4', contextLength: 128000 },
{ modelId: 'claude-opus-4.6', contextLength: 128000 },
{ modelId: 'claude-opus-4.5', contextLength: 128000 },
{ modelId: 'gemini-2.5-pro', contextLength: 128000 },
{ modelId: 'gemini-3-pro-preview', contextLength: 128000 },
{ modelId: 'gemini-3-flash-preview', contextLength: 128000 },
{ modelId: 'gemini-3.1-pro-preview', contextLength: 128000 },
{ modelId: 'gpt-5.4', contextLength: 272000 },
{ modelId: 'gpt-5.4-mini', contextLength: 128000 },
{ modelId: 'gpt-5.3-codex', contextLength: 272000 },
{ modelId: 'gpt-5.2-codex', contextLength: 272000 },
{ modelId: 'gpt-5.2', contextLength: 128000 },
{ modelId: 'gpt-5.1-codex', contextLength: 128000 },
{ modelId: 'gpt-5.1-codex-max', contextLength: 128000 },
{ modelId: 'gpt-5.1', contextLength: 128000 },
{ modelId: 'grok-code-fast-1', 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 },
],
}
/**

View File

@@ -24,7 +24,6 @@ export const useGetUserMCPIntegrations = () => {
const query = useQuery({
queryKey: [INTEGRATIONS_QUERY_KEY, agentServerUrl],
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
queryFn: () => getUserMCPIntegrations(agentServerUrl!),
enabled: !!agentServerUrl,
refetchOnWindowFocus: true,

View File

@@ -1,65 +0,0 @@
import { Menu } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { Outlet, useLocation } from 'react-router'
import { SettingsSidebar } from '@/components/sidebar/SettingsSidebar'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent } from '@/components/ui/sheet'
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'
export const SettingsSidebarLayout: FC = () => {
const location = useLocation()
const isMobile = useIsMobile()
const [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => {
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
setMobileOpen(false)
}, [location.pathname])
if (isMobile) {
return (
<RpcClientProvider>
<div className="flex min-h-screen flex-col bg-background">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<Button
variant="ghost"
size="icon"
className="-ml-1 size-7"
onClick={() => setMobileOpen(true)}
>
<Menu className="size-4" />
</Button>
<span className="font-semibold">Settings</span>
</header>
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Outlet />
</div>
</main>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="w-72 p-0">
<SettingsSidebar />
</SheetContent>
</Sheet>
</div>
</RpcClientProvider>
)
}
return (
<RpcClientProvider>
<div className="flex h-screen bg-background">
<SettingsSidebar />
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Outlet />
</div>
</main>
</div>
</RpcClientProvider>
)
}

View File

@@ -1,7 +1,7 @@
import { Menu } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Outlet, useLocation } from 'react-router'
import { Outlet } from 'react-router'
import { AppSidebar } from '@/components/sidebar/AppSidebar'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent } from '@/components/ui/sheet'
@@ -12,7 +12,6 @@ import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
const COLLAPSE_DELAY = 150
export const SidebarLayout: FC = () => {
const location = useLocation()
const isMobile = useIsMobile()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
@@ -97,17 +96,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

@@ -3,7 +3,6 @@ import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useRef, useState } 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'
@@ -35,15 +34,16 @@ 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 { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { refinePrompt } from '@/lib/schedules/refine-prompt'
import { toast } from 'sonner'
import type { ScheduledJob } from './types'
const formSchema = z
@@ -291,7 +291,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
type="button"
variant="ghost"
size="sm"
className="h-auto gap-1 px-2 py-1 text-muted-foreground text-xs"
className="h-auto gap-1 px-2 py-1 text-xs text-muted-foreground"
disabled={!queryValue?.trim() || isRefining}
onClick={handleRefinePrompt}
>
@@ -322,7 +322,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
{!isRefining && originalPromptRef.current !== null ? (
<button
type="button"
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={handleUndoRefine}
>
<Undo2 className="h-3 w-3" />

View File

@@ -0,0 +1,225 @@
import {
BookOpen,
Bot,
Compass,
MessageSquare,
Palette,
RotateCcw,
Search,
Server,
X,
} from 'lucide-react'
import type { FC } from 'react'
import { useEffect } from 'react'
import {
type Location,
useLocation,
useNavigate,
useParams,
} from 'react-router'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { SETTINGS_PAGE_VIEWED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import { AISettingsPage } from '../ai-settings/AISettingsPage'
import { CustomizationPage } from '../customization/CustomizationPage'
import { LlmHubPage } from '../llm-hub/LlmHubPage'
import { MCPSettingsPage } from '../mcp-settings/MCPSettingsPage'
import { SearchProviderPage } from '../search-provider/SearchProviderPage'
type SettingsTab = {
id: string
name: string
icon: typeof Bot
feature?: Feature
component: FC
}
const settingsTabs: SettingsTab[] = [
{ id: 'ai', name: 'BrowserOS AI', icon: Bot, component: AISettingsPage },
{
id: 'chat',
name: 'Chat & Council Provider',
icon: MessageSquare,
component: LlmHubPage,
},
{
id: 'search',
name: 'Search Provider',
icon: Search,
component: SearchProviderPage,
},
{
id: 'customization',
name: 'Customize BrowserOS',
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
component: CustomizationPage,
},
{
id: 'mcp',
name: 'BrowserOS as MCP',
icon: Server,
component: MCPSettingsPage,
},
]
type HelpItem = {
name: string
icon: typeof Bot
href?: string
to?: string
}
const helpItems: HelpItem[] = [
{ name: 'Docs', href: 'https://docs.browseros.com/', icon: BookOpen },
{ name: 'Features', to: '/onboarding/features', icon: Compass },
{ name: 'Revisit Onboarding', to: '/onboarding', icon: RotateCcw },
]
export const SettingsDialog: FC = () => {
const { tab } = useParams<{ tab?: string }>()
const location = useLocation()
const navigate = useNavigate()
const { supports } = useCapabilities()
const backgroundLocation = (
location.state as { backgroundLocation?: Location } | null
)?.backgroundLocation
const visibleTabs = settingsTabs.filter(
(tabDef) => !tabDef.feature || supports(tabDef.feature),
)
const activeTab = visibleTabs.find((t) => t.id === tab) ? tab : 'ai'
useEffect(() => {
track(SETTINGS_PAGE_VIEWED_EVENT, { page: `settings/${activeTab}` })
}, [activeTab])
const handleClose = () => {
if (backgroundLocation) {
const target =
backgroundLocation.pathname +
(backgroundLocation.search || '') +
(backgroundLocation.hash || '')
navigate(target, { replace: true })
} else {
navigate('/home', { replace: true })
}
}
const handleTabChange = (tabId: string) => {
navigate(`/settings/${tabId}`, {
state: { backgroundLocation },
replace: true,
})
}
const handleHelpNavigation = (to: string) => {
navigate(to, { replace: true })
}
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab)
const ActiveComponent = activeTabConfig?.component ?? AISettingsPage
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) handleClose()
}}
>
<DialogContent
className="flex h-[85vh] max-h-[85vh] w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
showCloseButton={false}
>
<DialogTitle className="sr-only">Settings</DialogTitle>
<div className="flex h-full min-h-0">
{/* Left panel - tab navigation */}
<div className="flex w-52 shrink-0 flex-col border-r bg-muted/30">
<div className="px-4 pt-5 pb-3">
<span className="font-semibold text-muted-foreground text-xs uppercase tracking-wider">
Settings
</span>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2">
{visibleTabs.map((tabDef) => {
const Icon = tabDef.icon
return (
<button
key={tabDef.id}
type="button"
onClick={() => handleTabChange(tabDef.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
activeTab === tabDef.id &&
'bg-accent text-accent-foreground',
)}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{tabDef.name}</span>
</button>
)
})}
</nav>
{/* Help section */}
<div className="border-t px-2 py-2">
<div className="mb-1 px-3 font-semibold text-[10px] text-muted-foreground uppercase tracking-[0.18em]">
Help
</div>
{helpItems.map((item) => {
const Icon = item.icon
if (item.href) {
return (
<a
key={item.name}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2 rounded-md px-3 py-2 font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{item.name}</span>
</a>
)
}
return (
<button
key={item.name}
type="button"
onClick={() => handleHelpNavigation(item.to ?? '/home')}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{item.name}</span>
</button>
)
})}
</div>
</div>
{/* Right panel - settings content */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex justify-end px-4 pt-3">
<button
type="button"
onClick={handleClose}
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100"
>
<X className="size-4" />
<span className="sr-only">Close</span>
</button>
</div>
<div className="styled-scrollbar flex-1 overflow-y-auto px-6 pb-6">
<ActiveComponent />
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,5 @@
import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
import { AlertCircle, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import Markdown from 'react-markdown'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -109,19 +108,23 @@ export const SkillsPage: FC = () => {
) : null}
{!isLoading && !error && skills.length > 0 ? (
<SkillSections
skills={skills}
onEdit={handleEdit}
onDelete={(skill) => setSkillToDelete(skill)}
onToggle={handleToggle}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
onEdit={() => handleEdit(skill)}
onDelete={() => setSkillToDelete(skill)}
onToggle={(enabled) => handleToggle(skill, enabled)}
/>
))}
</div>
) : null}
<SkillDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
editingSkill={editingSkill}
readOnly={editingSkill?.builtIn}
onSave={async (data) => {
try {
if (editingSkill) {
@@ -248,50 +251,6 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
</Card>
)
const SkillGrid: FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{children}
</div>
)
const SkillSections: FC<{
skills: SkillMeta[]
onEdit: (skill: SkillMeta) => void
onDelete: (skill: SkillMeta) => void
onToggle: (skill: SkillMeta, enabled: boolean) => void
}> = ({ skills, onEdit, onDelete, onToggle }) => {
const userSkills = skills.filter((s) => !s.builtIn)
const builtInSkills = skills.filter((s) => s.builtIn)
const renderCard = (skill: SkillMeta) => (
<SkillCard
key={skill.id}
skill={skill}
onEdit={() => onEdit(skill)}
onDelete={() => onDelete(skill)}
onToggle={(enabled) => onToggle(skill, enabled)}
/>
)
return (
<div className="space-y-6">
{userSkills.length > 0 ? (
<div className="space-y-3">
<h3 className="font-semibold text-sm">My Skills</h3>
<SkillGrid>{userSkills.map(renderCard)}</SkillGrid>
</div>
) : null}
{builtInSkills.length > 0 ? (
<div className="space-y-3">
<h3 className="font-semibold text-sm">BrowserOS Skills</h3>
<SkillGrid>{builtInSkills.map(renderCard)}</SkillGrid>
</div>
) : null}
</div>
)
}
const SkillCard: FC<{
skill: SkillMeta
onEdit: () => void
@@ -301,14 +260,7 @@ const SkillCard: FC<{
<Card className="h-full py-0 shadow-sm">
<CardContent className="flex h-full flex-col p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
{skill.builtIn ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
Built-in
</Badge>
) : null}
</div>
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
<Switch
checked={skill.enabled}
onCheckedChange={onToggle}
@@ -329,29 +281,18 @@ const SkillCard: FC<{
onClick={onEdit}
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
{skill.builtIn ? (
<>
<Eye className="size-3.5" />
View
</>
) : (
<>
<Pencil className="size-3.5" />
Edit
</>
)}
<Pencil className="size-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
aria-label={`Delete ${skill.name}`}
>
<Trash2 className="size-4" />
</Button>
{!skill.builtIn ? (
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
aria-label={`Delete ${skill.name}`}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
</CardContent>
</Card>
@@ -361,13 +302,12 @@ const SkillDialog: FC<{
open: boolean
onOpenChange: (open: boolean) => void
editingSkill: SkillDetail | null
readOnly?: boolean
onSave: (data: {
name: string
description: string
content: string
}) => Promise<void>
}> = ({ open, onOpenChange, editingSkill, readOnly, onSave }) => {
}> = ({ open, onOpenChange, editingSkill, onSave }) => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [content, setContent] = useState('')
@@ -414,18 +354,12 @@ const SkillDialog: FC<{
<DialogContent className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle>
{readOnly
? 'View Skill'
: editingSkill
? 'Edit Skill'
: 'Create Skill'}
{editingSkill ? 'Edit Skill' : 'Create Skill'}
</DialogTitle>
<DialogDescription>
{readOnly
? 'This skill is managed by BrowserOS and updated automatically.'
: editingSkill
? 'Refine when the agent should use this skill and how it should execute it.'
: 'Define a reusable instruction set your agent can apply when a request matches.'}
{editingSkill
? 'Refine when the agent should use this skill and how it should execute it.'
: 'Define a reusable instruction set your agent can apply when a request matches.'}
</DialogDescription>
</DialogHeader>
@@ -439,7 +373,6 @@ const SkillDialog: FC<{
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={100}
readOnly={readOnly}
/>
<p className="text-muted-foreground text-xs leading-5">
Keep it short and recognizable in the skills list.
@@ -455,22 +388,19 @@ const SkillDialog: FC<{
onChange={(event) => setDescription(event.target.value)}
maxLength={500}
className="min-h-28 resize-none bg-background"
readOnly={readOnly}
/>
<p className="text-muted-foreground text-xs leading-5">
This is the trigger summary the agent uses to pick the skill.
</p>
</div>
{!readOnly ? (
<div className="mt-auto rounded-lg border border-border/60 border-dashed bg-muted/30 px-3 py-2.5">
<p className="font-medium text-muted-foreground text-xs">Tip</p>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-muted-foreground text-xs leading-5">
<li>List the ordered steps the agent should follow.</li>
<li>Close with the output or formatting you expect back.</li>
</ul>
</div>
) : null}
<div className="mt-auto rounded-lg border border-border/60 border-dashed bg-muted/30 px-3 py-2.5">
<p className="font-medium text-muted-foreground text-xs">Tip</p>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-muted-foreground text-xs leading-5">
<li>List the ordered steps the agent should follow.</li>
<li>Close with the output or formatting you expect back.</li>
</ul>
</div>
</div>
<div className="flex min-h-0 flex-col px-6 py-5">
@@ -481,52 +411,36 @@ const SkillDialog: FC<{
</Badge>
</div>
{readOnly ? (
<div className="prose prose-sm dark:prose-invert mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm">
<Markdown>{content}</Markdown>
</div>
) : (
<MarkdownEditor
id="skill-content"
value={content}
onChange={setContent}
onKeyDown={handleContentKeyDown}
placeholder="Write instructions for the agent. Use markdown for structure."
className="mt-4 min-h-[320px] flex-1 overflow-y-auto text-sm"
/>
)}
<MarkdownEditor
id="skill-content"
value={content}
onChange={setContent}
onKeyDown={handleContentKeyDown}
placeholder="Write instructions for the agent. Use markdown for structure."
className="mt-4 min-h-[320px] flex-1 overflow-y-auto text-sm"
/>
</div>
</div>
<div className="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs">
{readOnly
? 'This skill is managed by BrowserOS and updated automatically.'
: 'Saved locally and available to your agent immediately.'}
Saved locally and available to your agent immediately.
</p>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
{readOnly ? (
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || saving}>
{saving
? 'Saving...'
: editingSkill
? 'Update Skill'
: 'Create Skill'}
</Button>
</>
)}
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || saving}>
{saving
? 'Saving...'
: editingSkill
? 'Update Skill'
: 'Create Skill'}
</Button>
</div>
</div>
</DialogContent>

View File

@@ -7,7 +7,6 @@ export type SkillMeta = {
description: string
location: string
enabled: boolean
builtIn: boolean
}
export type SkillDetail = SkillMeta & {

View File

@@ -1,125 +0,0 @@
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
getCreditBarColor,
getCreditTextColor,
} from '@/lib/credits/credit-colors'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon } from '@/lib/llm-providers/providerIcons'
import { cn } from '@/lib/utils'
export const UsagePage: FC = () => {
const { data, isLoading, error } = useCredits()
if (isLoading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
Loading usage data...
</div>
)
}
if (error) {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="flex flex-col items-center gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-8">
<AlertCircle className="h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-sm">
Unable to load credit information
</p>
</div>
</div>
)
}
const credits = data?.credits ?? 0
const total = data?.dailyLimit ?? 100
const percentage = Math.min((credits / total) * 100, 100)
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 text-muted-foreground" />
<span className="font-semibold text-sm">Daily Credits</span>
</div>
<span
className={cn('font-bold text-2xl', getCreditTextColor(credits))}
>
{credits}
<span className="ml-1 font-normal text-muted-foreground text-sm">
/ {total}
</span>
</span>
</div>
<div className="mb-5 h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
getCreditBarColor(credits),
)}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Resets daily</p>
<p className="text-muted-foreground text-xs">Midnight UTC</p>
</div>
</div>
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Credits used today</p>
<p className="text-muted-foreground text-xs">
{total - credits} of {total}
</p>
</div>
</div>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-semibold text-sm">Need more credits?</p>
<p className="text-muted-foreground text-xs">
Additional credit packages coming soon
</p>
</div>
</div>
<Button variant="outline" size="sm" disabled className="opacity-50">
Add Credits
</Button>
</div>
</div>
</div>
)
}

View File

@@ -18,7 +18,6 @@ import {
syncScheduledJobs,
} from '@/lib/schedules/scheduleStorage'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
import { scheduledJobRuns } from './scheduledJobRuns'
@@ -67,12 +66,7 @@ export default defineBackground(() => {
}
})
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === 'get-tab-id') {
sendResponse({ tabId: sender.tab?.id })
return true
}
chrome.runtime.onMessage.addListener((message, sender) => {
if (message?.type === 'AUTH_SUCCESS' && sender.tab?.id) {
const tabId = sender.tab.id
authRedirectPathStorage
@@ -99,17 +93,6 @@ export default defineBackground(() => {
}
})
// Clean up selected text storage when a tab is closed
chrome.tabs.onRemoved.addListener((tabId) => {
const key = String(tabId)
selectedTextStorage.getValue().then((map) => {
if (map[key]) {
const { [key]: _, ...rest } = map
selectedTextStorage.setValue(rest)
}
})
})
sessionStorage.watch(async (newSession) => {
if (newSession?.user?.id) {
try {

View File

@@ -5,17 +5,12 @@ import {
Folder,
Globe,
Layers,
Loader2,
Mic,
PlugZap,
Search,
Square,
X,
} from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import { AppSelector } from '@/components/elements/AppSelector'
import {
GlowingBorder,
@@ -41,26 +36,20 @@ import {
import {
NEWTAB_AI_TRIGGERED_EVENT,
NEWTAB_APPS_OPENED_EVENT,
NEWTAB_CHAT_RESET_EVENT,
NEWTAB_CHAT_STARTED_EVENT,
NEWTAB_OPENED_EVENT,
NEWTAB_SEARCH_EXECUTED_EVENT,
NEWTAB_TAB_REMOVED_EVENT,
NEWTAB_TAB_TOGGLED_EVENT,
NEWTAB_TABS_OPENED_EVENT,
NEWTAB_VOICE_ERROR_EVENT,
NEWTAB_VOICE_RECORDING_STARTED_EVENT,
NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
NEWTAB_WORKSPACE_OPENED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ImportDataHint } from './ImportDataHint'
import type { SuggestionItem } from './lib/suggestions/types'
@@ -69,6 +58,7 @@ import {
useSuggestions,
} from './lib/suggestions/useSuggestions'
import { NewTabBranding } from './NewTabBranding'
import { NewTabChat } from './NewTabChat'
import { NewTabTip } from './NewTabTip'
import { ScheduleResults } from './ScheduleResults'
import { SearchSuggestions } from './SearchSuggestions'
@@ -88,13 +78,13 @@ interface MentionState {
*/
export const NewTab = () => {
const activeHint = useActiveHint()
const navigate = useNavigate()
const [inputValue, setInputValue] = useState('')
const [mounted, setMounted] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const tabsDropdownRef = useRef<HTMLDivElement>(null)
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
const [chatActive, setChatActive] = useState(false)
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
filterText: '',
@@ -102,41 +92,12 @@ export const NewTab = () => {
})
const { selectedFolder } = useWorkspace()
const { supports } = useCapabilities()
const { providers, selectedProvider, handleSelectProvider } =
useChatSessionContext()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
useSyncRemoteIntegrations()
const voice = useVoiceInput()
// Voice transcript → populate search input
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setComboboxInputValue(voice.transcript)
track(NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT)
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing])
useEffect(() => {
if (voice.error) {
track(NEWTAB_VOICE_ERROR_EVENT, { error: voice.error })
}
}, [voice.error])
const handleStartRecording = async () => {
const started = await voice.startRecording()
if (started) {
track(NEWTAB_VOICE_RECORDING_STARTED_EVENT)
}
}
const handleStopRecording = async () => {
await voice.stopRecording()
track(NEWTAB_VOICE_RECORDING_STOPPED_EVENT)
}
const { messages, sendMessage, setMode, resetConversation } =
useChatSessionContext()
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
@@ -314,28 +275,17 @@ export const NewTab = () => {
const startInlineChat = (
message: string,
chatMode: 'chat' | 'agent',
aiTab?: { name: string; description: string },
mode: 'chat' | 'agent',
action?: ReturnType<
typeof createBrowserOSAction | typeof createAITabAction
>,
) => {
track(NEWTAB_CHAT_STARTED_EVENT, {
mode: chatMode,
tabs_count: selectedTabs.length,
})
const tabIds = selectedTabs
.map((t) => t.id)
.filter((id): id is number => id !== undefined)
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
setMode(mode)
setChatActive(true)
sendMessage({ text: message, action })
reset()
setSelectedTabs([])
const params = new URLSearchParams({ q: message, mode: chatMode })
if (tabIds.length > 0) {
params.set('tabs', tabIds.join(','))
}
if (aiTab) {
params.set('actionType', 'ai-tab')
params.set('tabName', aiTab.name)
params.set('tabDescription', aiTab.description)
}
navigate(`/home/chat?${params.toString()}`)
}
const runSelectedAction = (item: SuggestionItem | undefined) => {
@@ -356,18 +306,15 @@ export const NewTab = () => {
mode: 'agent',
tabs_count: selectedTabs.length,
})
const action = createAITabAction({
name: item.name,
description: item.description,
tabs: selectedTabs,
})
const searchQuery = `${item.name}${item.description ? ` - ${item.description}` : ''}}`
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
startInlineChat(searchQuery, 'agent', {
name: item.name,
description: item.description,
})
startInlineChat(searchQuery, 'agent', action)
} else {
const action = createAITabAction({
name: item.name,
description: item.description,
tabs: selectedTabs,
})
openSidePanelWithSearch('open', {
query: searchQuery,
mode: 'agent',
@@ -383,14 +330,14 @@ export const NewTab = () => {
mode: item.mode,
tabs_count: selectedTabs.length,
})
const action = createBrowserOSAction({
mode: item.mode,
message: item.message,
tabs: selectedTabs,
})
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
startInlineChat(item.message, item.mode)
startInlineChat(item.message, item.mode, action)
} else {
const action = createBrowserOSAction({
mode: item.mode,
message: item.message,
tabs: selectedTabs,
})
openSidePanelWithSearch('open', {
query: item.message,
mode: item.mode,
@@ -404,6 +351,12 @@ export const NewTab = () => {
}
}
const handleBackToSearch = () => {
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
resetConversation()
setChatActive(false)
}
const isSuggestionsVisible =
!mentionState.isOpen &&
((isOpen && inputValue.length) ||
@@ -415,6 +368,10 @@ export const NewTab = () => {
track(NEWTAB_OPENED_EVENT)
}, [])
if (chatActive) {
return <NewTabChat onBackToSearch={handleBackToSearch} />
}
return (
<div className="pt-[max(25vh,16px)]">
{/* Main content */}
@@ -468,89 +425,32 @@ export const NewTab = () => {
anchorRef={inputRef}
side="bottom"
/>
{voice.isRecording ? (
<div className="flex min-h-[40px] flex-1 items-center justify-center gap-1.5">
{voice.audioLevels.map((level, i) => (
<div
key={i.toString()}
className="w-1.5 rounded-full bg-red-500 transition-all duration-75"
style={{
height: `${Math.max(6, Math.min(28, level * 0.7))}px`,
}}
/>
))}
</div>
) : (
<input
type="text"
placeholder={
voice.isTranscribing ? 'Transcribing...' : searchPlaceholder
}
disabled={voice.isTranscribing}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
{...getInputProps({
ref: inputRef,
onChange: (e) => handleInputChange(e.currentTarget.value),
onKeyDown: (e) => {
if (!mentionStateRef.current.isOpen) return
if (e.key === 'Tab') {
e.preventDefault()
closeMention()
}
},
})}
/>
)}
<input
type="text"
placeholder={searchPlaceholder}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
{...getInputProps({
ref: inputRef,
onChange: (e) => handleInputChange(e.currentTarget.value),
onKeyDown: (e) => {
if (!mentionStateRef.current.isOpen) return
if (e.key === 'Tab') {
e.preventDefault()
closeMention()
}
},
})}
/>
<div className="flex items-center gap-1.5">
{voice.isRecording ? (
<Button
type="button"
size="icon"
onClick={handleStopRecording}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-red-600 text-white hover:bg-red-700"
>
<Square className="h-4 w-4" />
</Button>
) : voice.isTranscribing ? (
<Button
type="button"
variant="ghost"
size="icon"
disabled
className="h-10 w-10 flex-shrink-0 rounded-xl"
>
<Loader2 className="h-5 w-5 animate-spin" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleStartRecording}
className="h-10 w-10 flex-shrink-0 rounded-xl text-muted-foreground transition-colors hover:text-foreground"
title="Voice input"
>
<Mic className="h-5 w-5" />
</Button>
)}
<Button
onClick={handleSend}
size="icon"
disabled={voice.isRecording || voice.isTranscribing}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
<ArrowRight className="h-5 w-5" />
</Button>
</div>
<Button
onClick={handleSend}
size="icon"
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
<ArrowRight className="h-5 w-5" />
</Button>
</div>
{voice.error && (
<div className="px-5 pb-2 text-destructive text-xs">
{voice.error}
</div>
)}
<AnimatePresence>
{selectedTabs.length > 0 && (
<motion.div
@@ -624,34 +524,6 @@ export const NewTab = () => {
{mounted && (
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
<div className="flex items-center gap-1">
{selectedProvider && (
<ChatProviderSelector
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={handleSelectProvider}
>
<Button
variant="ghost"
size="icon"
title={selectedProvider.name}
className={cn(
'h-8 w-8 rounded-lg transition-all',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={16} />
) : (
<ProviderIcon
type={selectedProvider.type as ProviderType}
size={16}
/>
)}
</Button>
</ChatProviderSelector>
)}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
<WorkspaceSelector>
<Button

View File

@@ -1,41 +1,35 @@
import { Loader2 } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router'
import { type FC, useEffect, useState } from 'react'
import { ChatEmptyState } from '@/entrypoints/sidepanel/index/ChatEmptyState'
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
import { ChatFooter } from '@/entrypoints/sidepanel/index/ChatFooter'
import { ChatHeader } from '@/entrypoints/sidepanel/index/ChatHeader'
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { createBrowserOSAction } from '@/lib/chat-actions/types'
import {
createAITabAction,
createBrowserOSAction,
} from '@/lib/chat-actions/types'
import { useChatActions } from '@/lib/chat-actions/useChatActions'
import {
NEWTAB_AI_TRIGGERED_EVENT,
NEWTAB_CHAT_MODE_CHANGED_EVENT,
NEWTAB_CHAT_RESET_EVENT,
NEWTAB_CHAT_STOPPED_EVENT,
NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
NEWTAB_TAB_REMOVED_EVENT,
NEWTAB_TAB_TOGGLED_EVENT,
NEWTAB_VOICE_ERROR_EVENT,
NEWTAB_VOICE_RECORDING_STARTED_EVENT,
NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { NewTabChatHeader } from './NewTabChatHeader'
export const NewTabChat: FC = () => {
const [searchParams, setSearchParams] = useSearchParams()
const hasSentInitialRef = useRef(false)
interface NewTabChatProps {
onBackToSearch: () => void
}
export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
const {
mode,
setMode,
messages,
sendMessage,
status,
stop,
agentUrlError,
chatError,
getActionForMessage,
@@ -48,81 +42,72 @@ export const NewTabChat: FC = () => {
selectedProvider,
handleSelectProvider,
resetConversation,
input,
setInput,
attachedTabs,
mounted,
voiceState,
handleModeChange,
handleStop,
toggleTabSelection,
removeTab,
handleSubmit,
handleSuggestionClick,
} = useChatActions({
events: {
modeChanged: NEWTAB_CHAT_MODE_CHANGED_EVENT,
stopClicked: NEWTAB_CHAT_STOPPED_EVENT,
suggestionClicked: NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
tabToggled: NEWTAB_TAB_TOGGLED_EVENT,
tabRemoved: NEWTAB_TAB_REMOVED_EVENT,
aiTriggered: NEWTAB_AI_TRIGGERED_EVENT,
voiceRecordingStarted: NEWTAB_VOICE_RECORDING_STARTED_EVENT,
voiceRecordingStopped: NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
voiceTranscriptionCompleted: NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
voiceError: NEWTAB_VOICE_ERROR_EVENT,
},
})
} = useChatSessionContext()
const [input, setInput] = useState('')
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
const [mounted, setMounted] = useState(false)
// Send the initial message from URL query params (from /home search bar).
// Guarded by ref to prevent double-fire in React Strict Mode.
// biome-ignore lint/correctness/useExhaustiveDependencies: must only run once on mount
useEffect(() => {
if (hasSentInitialRef.current) return
const query = searchParams.get('q')
const chatMode = searchParams.get('mode')
const tabIdsParam = searchParams.get('tabs')
if (!query) return
hasSentInitialRef.current = true
if (chatMode === 'chat' || chatMode === 'agent') {
setMode(chatMode)
}
setSearchParams({}, { replace: true })
const actionType = searchParams.get('actionType')
const tabName = searchParams.get('tabName')
const tabDescription = searchParams.get('tabDescription')
if (tabIdsParam) {
const tabIds = tabIdsParam.split(',').map(Number).filter(Boolean)
chrome.tabs.query({}).then((allTabs) => {
const matchedTabs = allTabs.filter(
(t) => t.id !== undefined && tabIds.includes(t.id),
)
if (matchedTabs.length > 0) {
const action =
actionType === 'ai-tab' && tabName
? createAITabAction({
name: tabName,
description: tabDescription ?? '',
tabs: matchedTabs,
})
: createBrowserOSAction({
mode: (chatMode as 'chat' | 'agent') ?? 'agent',
message: query,
tabs: matchedTabs,
})
sendMessage({ text: query, action })
} else {
sendMessage({ text: query })
}
})
} else {
sendMessage({ text: query })
}
setMounted(true)
}, [])
const handleModeChange = (newMode: ChatMode) => {
track(NEWTAB_CHAT_MODE_CHANGED_EVENT, { from: mode, to: newMode })
setMode(newMode)
}
const handleStop = () => {
track(NEWTAB_CHAT_STOPPED_EVENT)
stop()
}
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
setAttachedTabs((prev) => {
const isSelected = prev.some((t) => t.id === tab.id)
track(NEWTAB_TAB_TOGGLED_EVENT, {
action: isSelected ? 'removed' : 'added',
})
if (isSelected) {
return prev.filter((t) => t.id !== tab.id)
}
return [...prev, tab]
})
}
const removeTab = (tabId?: number) => {
track(NEWTAB_TAB_REMOVED_EVENT)
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
}
const executeMessage = (customMessageText?: string) => {
const messageText = customMessageText ? customMessageText : input.trim()
if (!messageText) return
if (attachedTabs.length) {
const action = createBrowserOSAction({
mode,
message: messageText,
tabs: attachedTabs,
})
sendMessage({ text: messageText, action })
} else {
sendMessage({ text: messageText })
}
setInput('')
setAttachedTabs([])
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
executeMessage()
}
const handleSuggestionClick = (suggestion: string) => {
track(NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT, { mode })
executeMessage(suggestion)
}
const handleNewConversation = () => {
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
resetConversation()
@@ -131,19 +116,17 @@ export const NewTabChat: FC = () => {
if (!selectedProvider) return null
return (
<div className="absolute inset-0 flex flex-col overflow-hidden">
<div className="mx-auto w-full max-w-3xl">
<ChatHeader
selectedProvider={selectedProvider}
providers={providers}
onSelectProvider={handleSelectProvider}
onNewConversation={handleNewConversation}
hasMessages={messages.length > 0}
hideHistory
/>
</div>
<div className="flex h-[calc(100vh-2rem)] flex-col">
<NewTabChatHeader
selectedProvider={selectedProvider}
providers={providers}
onSelectProvider={handleSelectProvider}
onNewConversation={handleNewConversation}
onBackToSearch={onBackToSearch}
hasMessages={messages.length > 0}
/>
<main className="styled-scrollbar [&_[data-streamdown='code-block']]:!max-w-full [&_[data-streamdown='code-block']]:!w-auto [&_[data-streamdown='table-wrapper']]:!max-w-full [&_[data-streamdown='table-wrapper']]:!w-auto mx-auto flex min-h-0 w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto overflow-x-hidden px-4 pt-4 [&_[data-streamdown='code-block']]:overflow-x-auto [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto px-4 pt-4">
{isRestoringConversation ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -173,7 +156,7 @@ export const NewTabChat: FC = () => {
{chatError && <ChatError error={chatError} />}
</main>
<div className="mx-auto w-full max-w-3xl flex-shrink-0 px-4 pb-2">
<div className="mx-auto w-full max-w-3xl px-4">
<ChatFooter
mode={mode}
onModeChange={handleModeChange}
@@ -185,7 +168,6 @@ export const NewTabChat: FC = () => {
attachedTabs={attachedTabs}
onToggleTab={toggleTabSelection}
onRemoveTab={removeTab}
voice={voiceState}
/>
</div>
</div>

View File

@@ -0,0 +1,78 @@
import { ArrowLeft, Plus } from 'lucide-react'
import type { FC } from 'react'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
interface NewTabChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
onSelectProvider: (provider: Provider) => void
onNewConversation: () => void
onBackToSearch: () => void
hasMessages: boolean
}
export const NewTabChatHeader: FC<NewTabChatHeaderProps> = ({
selectedProvider,
providers,
onSelectProvider,
onNewConversation,
onBackToSearch,
hasMessages,
}) => {
return (
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-2.5 backdrop-blur-md">
<div className="flex items-center gap-2">
{/* Back to search */}
<button
type="button"
onClick={onBackToSearch}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Back to search"
>
<ArrowLeft className="h-4 w-4" />
</button>
{/* Provider selector */}
<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={onNewConversation}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
</header>
)
}

View File

@@ -3,19 +3,14 @@ import { Outlet, useLocation } from 'react-router'
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { NewTabFocusGrid } from './NewTabFocusGrid'
const HIDE_FOCUS_GRID_PATHS = new Set([
'/home/soul',
'/home/memory',
'/home/skills',
'/home/chat',
])
export const NewTabLayout: FC = () => {
const location = useLocation()
return (
<ChatSessionProvider origin="newtab">
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
{location.pathname !== '/home/soul' &&
location.pathname !== '/home/memory' &&
location.pathname !== '/home/skills' && <NewTabFocusGrid />}
<Outlet />
</ChatSessionProvider>
)

View File

@@ -1,42 +0,0 @@
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
const MAX_SELECTED_TEXT_LENGTH = 5000
export default defineContentScript({
matches: ['*://*/*'],
runAt: 'document_idle',
async main() {
const response = await chrome.runtime.sendMessage({ type: 'get-tab-id' })
const tabId: number | undefined = response?.tabId
if (!tabId) return
const key = String(tabId)
document.addEventListener('mouseup', () => {
const text = window.getSelection()?.toString().trim()
if (text && text.length > 0) {
selectedTextStorage.getValue().then((map) => {
selectedTextStorage.setValue({
...map,
[key]: {
text: text.slice(0, MAX_SELECTED_TEXT_LENGTH),
pageUrl: window.location.href,
pageTitle: document.title,
tabId,
timestamp: Date.now(),
},
})
})
} else {
// User clicked without selecting — clear this tab's entry only
selectedTextStorage.getValue().then((map) => {
if (map[key]) {
const { [key]: _, ...rest } = map
selectedTextStorage.setValue(rest)
}
})
}
})
},
})

View File

@@ -30,7 +30,6 @@ function parseErrorMessage(message: string): {
text: string
url?: string
isRateLimit?: boolean
isCreditsExhausted?: boolean
isConnectionError?: boolean
} {
// Detect MCP server connection failures
@@ -45,19 +44,6 @@ function parseErrorMessage(message: string): {
}
}
// Detect credit exhaustion from gateway
if (
message.includes('CREDITS_EXHAUSTED') ||
message.includes('Daily credits exhausted')
) {
return {
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
url: '/app.html#/settings/usage',
isRateLimit: true,
isCreditsExhausted: true,
}
}
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
if (message.includes('BrowserOS LLM daily limit reached')) {
return {
@@ -84,8 +70,9 @@ function parseErrorMessage(message: string): {
}
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
parseErrorMessage(error.message)
const { text, url, isRateLimit, isConnectionError } = parseErrorMessage(
error.message,
)
// --- Commented out for Kimi partnership launch (restore after) ---
// const surveyUrl = useMemo(
@@ -141,17 +128,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
</p>
)}
--- End commented out survey code --- */}
{isCreditsExhausted && url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground text-xs underline hover:text-foreground"
>
View Usage & Billing
</a>
)}
{isRateLimit && !isCreditsExhausted && (
{isRateLimit && (
<div className="flex flex-col items-center gap-1">
<p className="text-muted-foreground text-xs">
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}

View File

@@ -8,17 +8,12 @@ import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetU
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import {
type SelectedTextData,
selectedTextStorage,
} from '@/lib/selected-text/selectedTextStorage'
import { cn } from '@/lib/utils'
import type { VoiceInputState } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ChatAttachedTabs } from './ChatAttachedTabs'
import { ChatInput, type ChatInputHandle } from './ChatInput'
import { ChatModeToggle } from './ChatModeToggle'
import { ChatSelectedText } from './ChatSelectedText'
import type { ChatMode } from './chatTypes'
interface ChatFooterProps {
@@ -53,33 +48,6 @@ export const ChatFooter: FC<ChatFooterProps> = ({
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
const chatInputRef = useRef<ChatInputHandle>(null)
const [selectionMap, setSelectionMap] = useState<
Record<string, SelectedTextData>
>({})
const [activeTabId, setActiveTabId] = useState<number | undefined>()
// Track active tab for tab-scoped selection display
useEffect(() => {
chrome.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => setActiveTabId(tabs[0]?.id))
const listener = (activeInfo: { tabId: number }) => {
setActiveTabId(activeInfo.tabId)
}
chrome.tabs.onActivated.addListener(listener)
return () => chrome.tabs.onActivated.removeListener(listener)
}, [])
// Watch selected text storage (per-tab map)
useEffect(() => {
selectedTextStorage.getValue().then(setSelectionMap)
const unwatch = selectedTextStorage.watch(setSelectionMap)
return () => unwatch()
}, [])
const visibleSelectedText = activeTabId
? (selectionMap[String(activeTabId)] ?? null)
: null
const [isTabMentionOpen, setIsTabMentionOpen] = useState(false)
useEffect(() => {
@@ -113,19 +81,6 @@ export const ChatFooter: FC<ChatFooterProps> = ({
return (
<footer className="border-border/40 border-t bg-background/80 backdrop-blur-md">
<ChatAttachedTabs tabs={attachedTabs} onRemoveTab={onRemoveTab} />
{visibleSelectedText && (
<ChatSelectedText
selectedText={visibleSelectedText}
onDismiss={() => {
if (!activeTabId) return
const key = String(activeTabId)
selectedTextStorage.getValue().then((map) => {
const { [key]: _, ...rest } = map
selectedTextStorage.setValue(rest)
})
}}
/>
)}
<div className="p-3">
<div className="flex items-center gap-2">

View File

@@ -3,34 +3,17 @@ import type { FC } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { CreditBadge } from '@/components/credits/CreditBadge'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { productRepositoryUrl } from '@/lib/constants/productUrls'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
const CreditsBadgeWrapper: FC = () => {
const { supports } = useCapabilities()
const { data } = useCredits()
if (!supports(Feature.CREDITS_SUPPORT) || data === undefined) return null
return (
<CreditBadge
credits={data.credits}
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
/>
)
}
interface ChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
onSelectProvider: (provider: Provider) => void
onNewConversation: () => void
hasMessages: boolean
hideHistory?: boolean
}
export const ChatHeader: FC<ChatHeaderProps> = ({
@@ -39,7 +22,6 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
onSelectProvider,
onNewConversation,
hasMessages,
hideHistory,
}) => {
const location = useLocation()
const navigate = useNavigate()
@@ -77,7 +59,6 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
</span>
</button>
</ChatProviderSelector>
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
</div>
<div className="flex items-center gap-1">
@@ -92,25 +73,24 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
</button>
)}
{!hideHistory &&
(isHistoryPage ? (
<button
type="button"
onClick={handleNewConversationFromHistory}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
) : (
<Link
to="/history"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Chat history"
>
<History className="h-4 w-4" />
</Link>
))}
{isHistoryPage ? (
<button
type="button"
onClick={handleNewConversationFromHistory}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
) : (
<Link
to="/history"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Chat history"
>
<History className="h-4 w-4" />
</Link>
)}
<a
href={productRepositoryUrl}

View File

@@ -280,11 +280,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
if (voice.isTranscribing) {
return (
<button
type="button"
disabled
className="rounded-full p-2 text-muted-foreground"
>
<button type="button" disabled className="rounded-full p-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="sr-only">Transcribing</span>
</button>
@@ -321,9 +317,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
return (
<button
type="submit"
disabled={
!input.trim() || voice?.isRecording || voice?.isTranscribing
}
disabled={!input.trim() || voice?.isRecording || voice?.isTranscribing}
className="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" />
@@ -347,10 +341,12 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
anchorRef={textareaRef}
/>
{voice?.isRecording ? (
<div className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]">
<div
className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]"
>
{voice.audioLevels.map((level, i) => (
<div
key={i.toString()}
key={i}
className="w-1 rounded-full bg-red-500 transition-all duration-75"
style={{
height: `${Math.max(4, Math.min(20, level * 0.6))}px`,

View File

@@ -1,46 +0,0 @@
import { FileText, X } from 'lucide-react'
import type { FC } from 'react'
import type { SelectedTextData } from '@/lib/selected-text/selectedTextStorage'
const MAX_DISPLAY_LENGTH = 200
interface ChatSelectedTextProps {
selectedText: SelectedTextData
onDismiss: () => void
}
export const ChatSelectedText: FC<ChatSelectedTextProps> = ({
selectedText,
onDismiss,
}) => {
const truncated =
selectedText.text.length > MAX_DISPLAY_LENGTH
? `${selectedText.text.slice(0, MAX_DISPLAY_LENGTH)}...`
: selectedText.text
return (
<div className="px-3 pt-2">
<div className="relative rounded-lg border border-[var(--accent-orange)]/30 bg-accent/30">
<div className="flex items-start gap-2 px-3 py-2">
<FileText className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-orange)]" />
<div className="min-w-0 flex-1">
<div className="mb-0.5 truncate font-medium text-[10px] text-muted-foreground">
{selectedText.pageTitle}
</div>
<div className="line-clamp-3 text-foreground text-xs leading-relaxed">
&ldquo;{truncated}&rdquo;
</div>
</div>
<button
type="button"
onClick={onDismiss}
className="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-background"
title="Remove selected text"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -21,14 +21,12 @@ import {
useConversations,
} from '@/lib/conversations/conversationStorage'
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
import { useInvalidateCredits } from '@/lib/credits/useCredits'
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { track } from '@/lib/metrics/track'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
@@ -87,7 +85,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
selectedLlmProvider,
isLoadingProviders,
} = useChatRefs()
const invalidateCredits = useInvalidateCredits()
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
@@ -168,34 +165,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const modeRef = useRef<ChatMode>(mode)
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
const workingDirRef = useRef<string | undefined>(undefined)
const selectionMapRef = useRef<
Record<string, { text: string; url: string; title: string }>
>({})
const pendingSelectionTabKeyRef = useRef<string | null>(null)
const messagesRef = useRef<UIMessage[]>([])
useEffect(() => {
const toRef = (
map: Record<string, { text: string; pageUrl: string; pageTitle: string }>,
) => {
const result: Record<
string,
{ text: string; url: string; title: string }
> = {}
for (const [k, v] of Object.entries(map)) {
result[k] = { text: v.text, url: v.pageUrl, title: v.pageTitle }
}
return result
}
selectedTextStorage.getValue().then((map) => {
selectionMapRef.current = toRef(map)
})
const unwatchText = selectedTextStorage.watch((map) => {
selectionMapRef.current = toRef(map)
})
return () => unwatchText()
}, [])
useEffect(() => {
selectedWorkspaceStorage.getValue().then((folder) => {
workingDirRef.current = folder?.path
@@ -239,9 +210,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
currentWindow: true,
})
const activeTab = activeTabsList?.[0] ?? undefined
const activeTabSelection = activeTab?.id
? (selectionMapRef.current[String(activeTab.id)] ?? null)
: null
const message = getLastMessageText(messages)
const provider =
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
@@ -319,7 +287,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
: undefined
const result = {
return {
api: `${agentUrlRef.current}/chat`,
body: {
message,
@@ -354,21 +322,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
supportsImages: provider?.supportsImages,
previousConversation,
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
selectedText: activeTabSelection?.text,
selectedTextSource: activeTabSelection
? {
url: activeTabSelection.url,
title: activeTabSelection.title,
}
: undefined,
},
}
// Track which tab's selection was sent so we can clear it on success
pendingSelectionTabKeyRef.current =
activeTabSelection && activeTab?.id ? String(activeTab.id) : null
return result
},
}),
})
@@ -462,19 +417,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (!justFinished) return
// Clear the selected text that was sent with this request
const tabKey = pendingSelectionTabKeyRef.current
if (tabKey) {
pendingSelectionTabKeyRef.current = null
delete selectionMapRef.current[tabKey]
selectedTextStorage.getValue().then((map) => {
if (map[tabKey]) {
const { [tabKey]: _, ...rest } = map
selectedTextStorage.setValue(rest)
}
})
}
const messagesToSave = messages.filter((m) => m.parts?.length > 0)
if (messagesToSave.length === 0) return
@@ -483,14 +425,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
} else {
saveLocalConversation(conversationIdRef.current, messagesToSave)
}
invalidateCredits()
}, [status])
useEffect(() => {
if (chatError) invalidateCredits()
}, [chatError, invalidateCredits])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{
@@ -510,7 +446,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (pending.action) {
setTextToAction((prev) => {
const next = new Map(prev)
// biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above
next.set(pending.text, pending.action!)
return next
})

View File

@@ -14,15 +14,10 @@ function extractTabId(toolPart: ToolUIPart | null): number | undefined {
)?.output
if (output?.metadata?.tabId) return output.metadata.tabId
// Legacy controller tools: tabId in input
const input = (toolPart as ToolUIPart & { input?: { tabId?: number } })?.input
return input?.tabId
}
function sendGlow(tabId: number, message: GlowMessage): void {
chrome.tabs.sendMessage(tabId, message).catch(() => {})
}
export const useNotifyActiveTab = ({
messages,
status,
@@ -32,10 +27,7 @@ export const useNotifyActiveTab = ({
status: ChatStatus
conversationId: string
}) => {
// Track the single tab currently glowing
const activeTabIdRef = useRef<number | null>(null)
// Track all tabs that have been glowed during this stream (for cleanup)
const allGlowedTabsRef = useRef<Set<number>>(new Set())
const lastTabIdRef = useRef<number | null>(null)
const lastMessage = messages?.[messages.length - 1]
@@ -48,35 +40,27 @@ export const useNotifyActiveTab = ({
useEffect(() => {
const isStreaming = status === 'streaming'
const previousTabId = lastTabIdRef.current
if (!isStreaming) {
// Deactivate ALL tabs that were glowed during this stream
const allGlowed = allGlowedTabsRef.current
if (allGlowed.size > 0) {
if (previousTabId) {
const deactivate = async () => {
// Capture tab IDs before any async work to avoid race with clear()
const tabIds = Array.from(allGlowed)
allGlowed.clear()
const alreadyShown = await firstRunConfettiShownStorage.getValue()
let showConfetti = !alreadyShown
for (const tabId of tabIds) {
sendGlow(tabId, {
conversationId,
isActive: false,
showConfetti,
})
showConfetti = false
const deactivateMessage: GlowMessage = {
conversationId,
isActive: false,
showConfetti: !alreadyShown,
}
chrome.tabs
.sendMessage(previousTabId, deactivateMessage)
.catch(() => {})
if (!alreadyShown) {
await firstRunConfettiShownStorage.setValue(true)
}
}
deactivate()
lastTabIdRef.current = null
}
activeTabIdRef.current = null
return
}
@@ -85,41 +69,34 @@ export const useNotifyActiveTab = ({
let cancelled = false
const activate = async () => {
let targetTabId = toolTabId ?? undefined
let targetTabId = toolTabId ?? previousTabId ?? undefined
if (!targetTabId) {
// Fallback: use the currently active tab, or query browser
if (activeTabIdRef.current) {
targetTabId = activeTabIdRef.current
} else {
const tabs = await chrome.tabs.query({
active: true,
currentWindow: true,
})
targetTabId = tabs[0]?.id
}
const tabs = await chrome.tabs.query({
active: true,
currentWindow: true,
})
targetTabId = tabs[0]?.id
}
if (cancelled || !targetTabId) return
const previousTabId = activeTabIdRef.current
// If the agent moved to a different tab, deactivate the previous one
if (previousTabId && previousTabId !== targetTabId) {
sendGlow(previousTabId, {
const deactivateMessage: GlowMessage = {
conversationId,
isActive: false,
})
}
chrome.tabs
.sendMessage(previousTabId, deactivateMessage)
.catch(() => {})
}
// Activate glow on the target tab
sendGlow(targetTabId, {
const activateMessage: GlowMessage = {
conversationId,
isActive: true,
})
activeTabIdRef.current = targetTabId
allGlowedTabsRef.current.add(targetTabId)
}
chrome.tabs.sendMessage(targetTabId, activateMessage).catch(() => {})
lastTabIdRef.current = targetTabId
}
activate()

View File

@@ -45,14 +45,6 @@ export enum Feature {
MEMORY_SUPPORT = 'MEMORY_SUPPORT',
// Skills page: agent skills viewer and editor
SKILLS_SUPPORT = 'SKILLS_SUPPORT',
// ChatGPT Pro OAuth LLM provider
CHATGPT_PRO_SUPPORT = 'CHATGPT_PRO_SUPPORT',
// GitHub Copilot OAuth LLM provider
GITHUB_COPILOT_SUPPORT = 'GITHUB_COPILOT_SUPPORT',
// Qwen Code OAuth LLM provider
QWEN_CODE_SUPPORT = 'QWEN_CODE_SUPPORT',
// Credit-based usage tracking
CREDITS_SUPPORT = 'CREDITS_SUPPORT',
}
/**
@@ -80,10 +72,6 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.VERTICAL_TABS_SUPPORT]: { minBrowserOSVersion: '0.42.0.0' },
[Feature.MEMORY_SUPPORT]: { minServerVersion: '0.0.73' },
[Feature.SKILLS_SUPPORT]: { minBrowserOSVersion: '0.43.0.0' },
[Feature.CHATGPT_PRO_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.GITHUB_COPILOT_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.QWEN_CODE_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.CREDITS_SUPPORT]: { minServerVersion: '0.0.78' },
}
function parseVersion(version: string): number[] {

View File

@@ -1,172 +0,0 @@
import { useEffect, useState } from 'react'
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { track } from '@/lib/metrics/track'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { createBrowserOSAction } from './types'
interface ChatActionsConfig {
/** Analytics event names scoped to the origin */
events: {
modeChanged: string
stopClicked: string
suggestionClicked: string
tabToggled: string
tabRemoved: string
aiTriggered: string
voiceRecordingStarted: string
voiceRecordingStopped: string
voiceTranscriptionCompleted: string
voiceError: string
}
/** Auto-attach current active tab on mount (sidepanel only) */
autoAttachActiveTab?: boolean
}
export function useChatActions(config: ChatActionsConfig) {
const session = useChatSessionContext()
const { mode, setMode, sendMessage, stop, messages } = session
const voice = useVoiceInput()
const [input, setInput] = useState('')
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Auto-attach current tab on mount (sidepanel)
useEffect(() => {
if (!config.autoAttachActiveTab) return
;(async () => {
const currentTab = (
await chrome.tabs.query({ active: true, currentWindow: true })
).filter((tab) => tab.url?.startsWith('http'))
setAttachedTabs(currentTab)
})()
}, [config.autoAttachActiveTab])
// Voice transcript → input
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setInput((prev) => {
const separator = prev.trim() ? ' ' : ''
return prev + separator + voice.transcript
})
track(config.events.voiceTranscriptionCompleted)
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing])
// Track voice errors
useEffect(() => {
if (voice.error) {
track(config.events.voiceError, { error: voice.error })
}
}, [voice.error, config.events.voiceError])
const handleModeChange = (newMode: ChatMode) => {
track(config.events.modeChanged, { from: mode, to: newMode })
setMode(newMode)
}
const handleStop = () => {
track(config.events.stopClicked)
stop()
}
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
setAttachedTabs((prev) => {
const isSelected = prev.some((t) => t.id === tab.id)
track(config.events.tabToggled, {
action: isSelected ? 'removed' : 'added',
})
if (isSelected) {
return prev.filter((t) => t.id !== tab.id)
}
return [...prev, tab]
})
}
const removeTab = (tabId?: number) => {
track(config.events.tabRemoved)
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
}
const executeMessage = (customMessageText?: string) => {
const messageText = customMessageText ? customMessageText : input.trim()
if (!messageText) return
if (attachedTabs.length) {
const action = createBrowserOSAction({
mode,
message: messageText,
tabs: attachedTabs,
})
sendMessage({ text: messageText, action })
} else {
sendMessage({ text: messageText })
}
setInput('')
setAttachedTabs([])
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (messages.length === 0) {
track(config.events.aiTriggered, {
mode,
tabs_count: attachedTabs.length,
})
}
executeMessage()
}
const handleSuggestionClick = (suggestion: string) => {
track(config.events.suggestionClicked, { mode })
executeMessage(suggestion)
}
const handleStartRecording = async () => {
const started = await voice.startRecording()
if (started) {
track(config.events.voiceRecordingStarted)
}
}
const handleStopRecording = async () => {
await voice.stopRecording()
track(config.events.voiceRecordingStopped)
}
const voiceState = {
isRecording: voice.isRecording,
isTranscribing: voice.isTranscribing,
audioLevels: voice.audioLevels,
error: voice.error,
onStartRecording: handleStartRecording,
onStopRecording: handleStopRecording,
}
const { stop: _stop, ...restSession } = session
return {
...restSession,
input,
setInput,
attachedTabs,
setAttachedTabs,
mounted,
voiceState,
handleModeChange,
handleStop,
toggleTabSelection,
removeTab,
executeMessage,
handleSubmit,
handleSuggestionClick,
}
}

View File

@@ -41,29 +41,6 @@ export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT =
export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT =
'settings.chatgpt_pro.oauth_disconnected'
/** @public */
export const GITHUB_COPILOT_OAUTH_STARTED_EVENT =
'settings.github_copilot.oauth_started'
/** @public */
export const GITHUB_COPILOT_OAUTH_COMPLETED_EVENT =
'settings.github_copilot.oauth_completed'
/** @public */
export const GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT =
'settings.github_copilot.oauth_disconnected'
/** @public */
export const QWEN_CODE_OAUTH_STARTED_EVENT = 'settings.qwen_code.oauth_started'
/** @public */
export const QWEN_CODE_OAUTH_COMPLETED_EVENT =
'settings.qwen_code.oauth_completed'
/** @public */
export const QWEN_CODE_OAUTH_DISCONNECTED_EVENT =
'settings.qwen_code.oauth_disconnected'
/** @public */
export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
@@ -153,21 +130,6 @@ export const NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT =
/** @public */
export const NEWTAB_CHAT_MODE_CHANGED_EVENT = 'newtab.chat.mode_changed'
/** @public */
export const NEWTAB_VOICE_RECORDING_STARTED_EVENT =
'newtab.voice.recording_started'
/** @public */
export const NEWTAB_VOICE_RECORDING_STOPPED_EVENT =
'newtab.voice.recording_stopped'
/** @public */
export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
'newtab.voice.transcription_completed'
/** @public */
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
/** @public */
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'

View File

@@ -1,13 +0,0 @@
const LOW_THRESHOLD = 30
export function getCreditTextColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= LOW_THRESHOLD) return 'text-yellow-500'
return 'text-green-500'
}
export function getCreditBarColor(credits: number): string {
if (credits <= 0) return 'bg-red-500'
if (credits <= LOW_THRESHOLD) return 'bg-yellow-500'
return 'bg-green-500'
}

View File

@@ -1,33 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
}
const CREDITS_QUERY_KEY = ['credits']
async function fetchCredits(): Promise<CreditsInfo> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(`${baseUrl}/credits`)
if (!response.ok)
throw new Error(`Failed to fetch credits: ${response.status}`)
return response.json()
}
export function useCredits() {
return useQuery<CreditsInfo>({
queryKey: CREDITS_QUERY_KEY,
queryFn: fetchCredits,
refetchOnWindowFocus: true,
staleTime: 30_000,
retry: 1,
})
}
export function useInvalidateCredits() {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
}

View File

@@ -1,169 +0,0 @@
/**
* Client-side OAuth Device Code flow.
* Used for providers where server-side fetch is blocked by WAF (e.g. Qwen).
* The extension makes requests using Chrome's network stack which bypasses
* TLS fingerprint-based WAF detection.
*/
export interface ClientAuthConfig {
deviceCodeEndpoint: string
tokenEndpoint: string
clientId: string
scopes: string
requiresPKCE: boolean
contentType: 'json' | 'form'
}
interface DeviceCodeData {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete?: string
expires_in: number
interval: number
}
export interface TokenResult {
accessToken: string
refreshToken: string
expiresIn: number
}
export async function requestDeviceCode(
auth: ClientAuthConfig,
): Promise<{ deviceData: DeviceCodeData; codeVerifier?: string }> {
let codeVerifier: string | undefined
const params: Record<string, string> = {
client_id: auth.clientId,
scope: auth.scopes,
}
if (auth.requiresPKCE) {
codeVerifier = generateCodeVerifier()
params.code_challenge = await generateCodeChallenge(codeVerifier)
params.code_challenge_method = 'S256'
}
const res = await authFetch(auth.deviceCodeEndpoint, params, auth.contentType)
// WAF captcha detected — open the site for user to solve, then retry
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('application/json')) {
const baseUrl = new URL(auth.deviceCodeEndpoint).origin
window.open(baseUrl, '_blank')
throw new Error(
'Please complete the verification in the opened tab, then click USE again.',
)
}
if (!res.ok) throw new Error(`Device code request failed: ${res.status}`)
const deviceData = (await res.json()) as DeviceCodeData
if (!deviceData.device_code || !deviceData.user_code) {
throw new Error('Invalid device code response')
}
return { deviceData, codeVerifier }
}
export function startTokenPolling(
auth: ClientAuthConfig,
deviceData: DeviceCodeData,
codeVerifier: string | undefined,
onToken: (token: TokenResult) => void,
): void {
let interval = deviceData.interval
const deadline = Date.now() + deviceData.expires_in * 1000
const safetyMargin = 3
const poll = async () => {
if (Date.now() > deadline) return
const params: Record<string, string> = {
client_id: auth.clientId,
device_code: deviceData.device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}
if (codeVerifier) params.code_verifier = codeVerifier
try {
const res = await authFetch(auth.tokenEndpoint, params, auth.contentType)
// WAF returned HTML — retry later
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('application/json')) {
setTimeout(poll, (interval + safetyMargin) * 1000)
return
}
const data = (await res.json()) as {
access_token?: string
refresh_token?: string
expires_in?: number
error?: string
interval?: number
}
if (data.access_token) {
onToken({
accessToken: data.access_token,
refreshToken: data.refresh_token ?? '',
expiresIn: data.expires_in ?? 0,
})
return
}
if (data.error === 'authorization_pending') {
setTimeout(poll, (interval + safetyMargin) * 1000)
return
}
if (data.error === 'slow_down') {
interval = (data.interval ?? interval) + 5
setTimeout(poll, (interval + safetyMargin) * 1000)
return
}
} catch {
setTimeout(poll, (interval + safetyMargin) * 1000)
}
}
setTimeout(poll, (interval + safetyMargin) * 1000)
}
function authFetch(
endpoint: string,
params: Record<string, string>,
contentType: 'json' | 'form',
): Promise<Response> {
return fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type':
contentType === 'form'
? 'application/x-www-form-urlencoded'
: 'application/json',
Accept: 'application/json',
},
body:
contentType === 'form'
? new URLSearchParams(params).toString()
: JSON.stringify(params),
})
}
function generateCodeVerifier(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return base64UrlEncode(bytes)
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier),
)
return base64UrlEncode(new Uint8Array(digest))
}
function base64UrlEncode(bytes: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...bytes))
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

View File

@@ -8,9 +8,8 @@ import {
Ollama,
OpenAI,
OpenRouter,
Qwen,
} from '@lobehub/icons'
import { Bot, Github } from 'lucide-react'
import { Bot } from 'lucide-react'
import type { FC, SVGProps } from 'react'
import ProductLogoSvg from '@/assets/product_logo.svg'
import type { ProviderType } from './types'
@@ -34,8 +33,6 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
browseros: null,
moonshot: Kimi,
'chatgpt-pro': OpenAI,
'github-copilot': Github,
'qwen-code': Qwen,
}
interface ProviderIconProps {

View File

@@ -29,24 +29,6 @@ export const providerTemplates: ProviderTemplate[] = [
contextWindow: 400000,
setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth',
},
{
id: 'github-copilot',
name: 'GitHub Copilot',
defaultBaseUrl: 'https://api.githubcopilot.com',
defaultModelId: 'gpt-5-mini',
supportsImages: true,
contextWindow: 128000,
setupGuideUrl: 'https://docs.browseros.com/features/github-copilot-oauth',
},
{
id: 'qwen-code',
name: 'Qwen Code',
defaultBaseUrl: 'https://portal.qwen.ai/v1',
defaultModelId: 'coder-model',
supportsImages: true,
contextWindow: 1000000,
setupGuideUrl: 'https://docs.browseros.com/features/qwen-code-oauth',
},
{
id: 'moonshot',
name: 'Moonshot AI',
@@ -157,8 +139,6 @@ export const providerTemplates: ProviderTemplate[] = [
*/
export const providerTypeOptions: { value: ProviderType; label: string }[] = [
{ value: 'chatgpt-pro', label: 'ChatGPT Plus/Pro' },
{ value: 'github-copilot', label: 'GitHub Copilot' },
{ value: 'qwen-code', label: 'Qwen Code' },
{ value: 'moonshot', label: 'Moonshot AI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
@@ -188,8 +168,6 @@ export const getProviderTemplate = (
*/
export const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
'chatgpt-pro': 'https://chatgpt.com/backend-api',
'github-copilot': 'https://api.githubcopilot.com',
'qwen-code': 'https://portal.qwen.ai/v1',
moonshot: 'https://api.moonshot.ai/v1',
anthropic: 'https://api.anthropic.com/v1',
openai: 'https://api.openai.com/v1',

View File

@@ -15,8 +15,6 @@ export type ProviderType =
| 'browseros'
| 'moonshot'
| 'chatgpt-pro'
| 'github-copilot'
| 'qwen-code'
/**
* LLM Provider configuration

View File

@@ -1,167 +0,0 @@
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { track } from '@/lib/metrics/track'
import {
type ClientAuthConfig,
requestDeviceCode,
startTokenPolling,
} from './client-oauth'
import { getProviderTemplate } from './providerTemplates'
import type { LlmProviderConfig, ProviderType } from './types'
import { useOAuthStatus } from './useOAuthStatus'
export interface OAuthProviderFlowConfig {
providerType: ProviderType
displayName: string
startedEvent: string
completedEvent: string
disconnectedEvent: string
/** Client-side auth for providers with WAF-protected endpoints */
clientAuth?: ClientAuthConfig
}
interface OAuthProviderFlowReturn {
status: { authenticated: boolean; email?: string } | null
disconnect: () => Promise<void>
startOAuthFlow: (agentServerUrl: string | undefined) => Promise<void>
}
export function useOAuthProviderFlow(
config: OAuthProviderFlowConfig,
providers: LlmProviderConfig[],
saveProvider: (provider: LlmProviderConfig) => Promise<void> | void,
): OAuthProviderFlowReturn {
const { status, startPolling, disconnect } = useOAuthStatus(
config.providerType,
)
const flowStartedRef = useRef(false)
// Auto-create provider when OAuth completes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!status?.authenticated) return
if (!flowStartedRef.current) return
if (providers.some((p) => p.type === config.providerType)) return
const now = Date.now()
try {
const template = getProviderTemplate(config.providerType)
saveProvider({
id: `${config.providerType}-${now}`,
type: config.providerType,
name: `${config.displayName}${status.email ? ` (${status.email})` : ''}`,
modelId: template?.defaultModelId ?? '',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 128000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
track(config.completedEvent, { email: status.email })
toast.success(`${config.displayName} Connected`, {
description: status.email
? `Authenticated as ${status.email}`
: `Successfully authenticated with ${config.displayName}`,
})
} catch (err) {
toast.error(`Failed to create ${config.displayName} provider`, {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
flowStartedRef.current = false
}
}, [status?.authenticated])
async function startOAuthFlow(agentServerUrl: string | undefined) {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
flowStartedRef.current = true
try {
if (config.clientAuth) {
await handleClientAuth(config.clientAuth, agentServerUrl)
} else {
await handleServerAuth(agentServerUrl)
}
} catch (err) {
flowStartedRef.current = false
toast.error(`Failed to start ${config.displayName} authentication`, {
description: err instanceof Error ? err.message : 'Unknown error',
})
}
}
// Client-side: extension handles device code + polling, sends token to server
async function handleClientAuth(auth: ClientAuthConfig, serverUrl: string) {
const { deviceData, codeVerifier } = await requestDeviceCode(auth)
const verificationUri =
deviceData.verification_uri_complete ?? deviceData.verification_uri
window.open(verificationUri, '_blank')
track(config.startedEvent)
toast.info(`Enter code: ${deviceData.user_code}`, {
description: `Paste this code on the ${config.displayName} page that just opened.`,
duration: 60_000,
})
startTokenPolling(auth, deviceData, codeVerifier, async (token) => {
await fetch(`${serverUrl}/oauth/${config.providerType}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(token),
})
startPolling()
})
}
// Server-side: server handles device code + polling
async function handleServerAuth(agentServerUrl: string) {
const res = await fetch(
`${agentServerUrl}/oauth/${config.providerType}/start`,
)
if (res.headers.get('content-type')?.includes('application/json')) {
const data = (await res.json()) as {
userCode?: string
verificationUri?: string
error?: string
}
if (!res.ok || data.error) {
throw new Error(data.error || `Server returned ${res.status}`)
}
if (!data.userCode || !data.verificationUri) {
throw new Error('Invalid response from server')
}
window.open(data.verificationUri, '_blank')
startPolling()
track(config.startedEvent)
toast.info(`Enter code: ${data.userCode}`, {
description: `Paste this code on the ${config.displayName} page that just opened.`,
duration: 60_000,
})
return
}
// PKCE redirect flow
if (!res.ok) throw new Error(`Server returned ${res.status}`)
window.open(res.url, '_blank')
startPolling()
track(config.startedEvent)
toast.info(`Authenticating with ${config.displayName}`, {
description: 'Complete the login in the opened tab.',
})
}
return {
status,
disconnect,
startOAuthFlow,
}
}

View File

@@ -74,7 +74,6 @@ export function useOAuthStatus(provider: string): UseOAuthStatusReturn {
}, [])
// Cleanup on unmount
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
useEffect(() => {
return () => stopPolling()
}, [])

View File

@@ -23,4 +23,4 @@ type ScheduleMessagesProtocol = {
const { sendMessage, onMessage } =
defineExtensionMessaging<ScheduleMessagesProtocol>()
export { onMessage as onScheduleMessage, sendMessage as sendScheduleMessage }
export { sendMessage as sendScheduleMessage, onMessage as onScheduleMessage }

View File

@@ -12,4 +12,4 @@ type ServerMessagesProtocol = {
const { sendMessage, onMessage } =
defineExtensionMessaging<ServerMessagesProtocol>()
export { onMessage as onServerMessage, sendMessage as sendServerMessage }
export { sendMessage as sendServerMessage, onMessage as onServerMessage }

View File

@@ -12,6 +12,6 @@ const { sendMessage, onMessage } =
defineExtensionMessaging<OpenSidePanelWithSearchParams>()
export {
onMessage as onOpenSidePanelWithSearch,
sendMessage as openSidePanelWithSearch,
onMessage as onOpenSidePanelWithSearch,
}

View File

@@ -1,14 +0,0 @@
import { storage } from '@wxt-dev/storage'
export interface SelectedTextData {
text: string
pageUrl: string
pageTitle: string
tabId: number
timestamp: number
}
/** Map of tabId → selected text. Each tab's selection is independent. */
export const selectedTextStorage = storage.defineItem<
Record<string, SelectedTextData>
>('local:selectedTextMap', { defaultValue: {} })

View File

@@ -0,0 +1,21 @@
import { useCallback } from 'react'
import { useLocation, useNavigate } from 'react-router'
/**
* Hook to open the settings dialog from anywhere in the app.
* Uses React Router's background location pattern so the dialog
* overlays the current page without unmounting it.
*/
export function useOpenSettings() {
const location = useLocation()
const navigate = useNavigate()
return useCallback(
(tab = 'ai') => {
navigate(`/settings/${tab}`, {
state: { backgroundLocation: location },
})
},
[location, navigate],
)
}

View File

@@ -1,29 +0,0 @@
const GATEWAY_URL = 'https://llm.browseros.com'
interface TranscribeResponse {
text: string
}
export 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,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(
errorBody.error || `Transcription failed: ${response.status}`,
)
}
const result: TranscribeResponse = await response.json()
return result.text || ''
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { transcribeAudio } from './transcribe-audio'
const GATEWAY_URL = 'https://llm.browseros.com'
const WAVEFORM_BAND_COUNT = 5
export interface VoiceInputState {
@@ -26,6 +26,32 @@ export interface UseVoiceInputReturn {
const EMPTY_LEVELS = Array(WAVEFORM_BAND_COUNT).fill(0)
interface TranscribeResponse {
text: string
}
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,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(errorBody.error || `Transcription failed: ${response.status}`)
}
const result: TranscribeResponse = await response.json()
return result.text || ''
}
export function useVoiceInput(): UseVoiceInputReturn {
const [isRecording, setIsRecording] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false)
@@ -55,7 +81,6 @@ export function useVoiceInput(): UseVoiceInputReturn {
setAudioLevels(EMPTY_LEVELS)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
useEffect(() => {
return () => {
streamRef.current?.getTracks().forEach((track) => {
@@ -146,9 +171,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
setIsRecording(true)
return true
} catch (err) {
streamRef.current?.getTracks().forEach((track) => {
track.stop()
})
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current = null
stopAudioLevelMonitoring()

View File

@@ -9,9 +9,9 @@
"build": "bun run codegen && wxt build",
"build:dev": "bun --env-file=.env.development wxt build --mode development",
"zip": "wxt zip",
"compile": "tsgo --noEmit",
"compile": "tsc --noEmit",
"lint": "bunx biome check",
"typecheck": "tsgo --noEmit",
"typecheck": "tsc --noEmit",
"lint:fix": "bunx biome check --write --unsafe",
"clean:cache": "rm -rf node_modules/.cache && rm -rf .output/ && rm -rf .wxt/",
"codegen": "bun --env-file=.env.development graphql-codegen --config codegen.ts",
@@ -79,7 +79,6 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.66.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.3.3",
"react-router": "^7.12.0",
"shiki": "^3.15.0",

View File

@@ -4,6 +4,7 @@
"types": ["chrome", "bun"],
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},

View File

@@ -1,19 +1,13 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineWebExtConfig } from 'wxt'
// biome-ignore lint/style/noProcessEnv: config file needs env access
const env = process.env
const MONOREPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..')
const CONTROLLER_EXT_DIR = join(MONOREPO_ROOT, 'apps/controller-ext/dist')
const chromiumArgs = [
'--use-mock-keychain',
'--show-component-extension-options',
'--disable-browseros-server',
'--disable-browseros-extensions',
`--load-extension=${CONTROLLER_EXT_DIR}`,
]
if (env.BROWSEROS_CDP_PORT) {

View File

@@ -55,7 +55,6 @@ export default defineConfig({
permissions: [
'topSites',
'tabs',
'tabGroups',
'storage',
'sidePanel',
'browserOS',

View File

@@ -1,10 +1,8 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -172,44 +170,11 @@ func defaultServerURL() string {
}
cfg, err := config.Load()
if err == nil {
if url := normalizeServerURL(cfg.ServerURL); url != "" {
return url
}
}
if url := loadBrowserosServerURL(); url != "" {
return url
}
return ""
}
type serverDiscoveryConfig struct {
ServerPort int `json:"server_port"`
URL string `json:"url"`
ServerVersion string `json:"server_version"`
BrowserOSVersion string `json:"browseros_version,omitempty"`
ChromiumVersion string `json:"chromium_version,omitempty"`
}
func loadBrowserosServerURL() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc serverDiscoveryConfig
if err := json.Unmarshal(data, &sc); err != nil {
return ""
}
return normalizeServerURL(sc.URL)
return normalizeServerURL(cfg.ServerURL)
}
func normalizeServerURL(raw string) string {

View File

@@ -1,32 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
# Build unpublished docs
docs/
# TypeScript
*.tsbuildinfo
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
# Claude
.claude

View File

@@ -1,430 +0,0 @@
# BrowserOS Controller
WebSocket-based Chrome Extension that exposes browser automation APIs for remote control.
**⚠️ IMPORTANT:** This extension ONLY works in **BrowserOS Chrome**, not regular Chrome!
---
## 🚀 Quick Start
### 1. Build the Extension
```bash
npm install
npm run build
```
### 2. Load Extension in BrowserOS Chrome
1. Open BrowserOS Chrome
2. Go to `chrome://extensions/`
3. Enable **"Developer mode"** (top-right toggle)
4. Click **"Load unpacked"**
5. Select the `dist/` folder
6. Verify extension is loaded (you should see "BrowserOS Controller")
### 3. Test the Extension
```bash
npm test
```
This starts an interactive test client. You should see:
```
🚀 Starting BrowserOS Controller Test Client
──────────────────────────────────────────────────────────
WebSocket Server Started
Listening on: ws://localhost:9224/controller
Waiting for extension to connect...
✅ Extension connected!
Running Diagnostic Test
============================================================
📤 Sending: checkBrowserOS
Request ID: test-1729012345678
📨 Response: test-1729012345678
Status: ✅ SUCCESS
Data: {
"available": true,
"apis": [
"captureScreenshot",
"clear",
"click",
...
]
}
```
**If you see "available": true**, you're all set! 🎉
**If you see "available": false**, you're not using BrowserOS Chrome.
---
## ⚙️ Configuration
The extension can be configured using environment variables. This is optional - sensible defaults are provided.
### Environment Variables
Create a `.env` file in the project root to customize configuration:
```bash
# Copy the example file
cp .env.example .env
# Edit .env with your values
```
### Available Configuration Options
#### WebSocket Configuration
```bash
WEBSOCKET_PROTOCOL=ws # ws or wss (default: ws)
WEBSOCKET_HOST=localhost # Server host (default: localhost)
WEBSOCKET_PORT=9224 # Server port (default: 9224)
WEBSOCKET_PATH=/controller # Server path (default: /controller)
```
#### Connection Settings
```bash
WEBSOCKET_RECONNECT_DELAY=1000 # Initial reconnect delay in ms (default: 1000)
WEBSOCKET_MAX_RECONNECT_DELAY=30000 # Max reconnect delay in ms (default: 30000)
WEBSOCKET_RECONNECT_MULTIPLIER=1.5 # Exponential backoff multiplier (default: 1.5)
WEBSOCKET_MAX_RECONNECT_ATTEMPTS=0 # Max reconnect attempts, 0 = infinite (default: 0)
WEBSOCKET_HEARTBEAT_INTERVAL=30000 # Heartbeat interval in ms (default: 30000)
WEBSOCKET_HEARTBEAT_TIMEOUT=5000 # Heartbeat timeout in ms (default: 5000)
WEBSOCKET_CONNECTION_TIMEOUT=10000 # Connection timeout in ms (default: 10000)
WEBSOCKET_REQUEST_TIMEOUT=30000 # Request timeout in ms (default: 30000)
```
#### Concurrency Settings
```bash
CONCURRENCY_MAX_CONCURRENT=100 # Max concurrent requests (default: 100)
CONCURRENCY_MAX_QUEUE_SIZE=1000 # Max queued requests (default: 1000)
```
#### Logging Settings
```bash
LOGGING_ENABLED=true # Enable/disable logging (default: true)
LOGGING_LEVEL=info # Log level: debug, info, warn, error (default: info)
LOGGING_PREFIX=[BrowserOS Controller] # Log message prefix (default: [BrowserOS Controller])
```
### Example: Custom Port Configuration
If you want to use a different port (e.g., 8080):
```bash
# .env
WEBSOCKET_PORT=8080
```
Then rebuild the extension:
```bash
npm run build
```
The extension will now connect to `ws://localhost:8080/controller` instead of the default port 9224.
---
## 📖 Architecture
See [ARCHITECTURE.md](./ARCHITECTURE.md) for complete system documentation including:
- High-level architecture diagram
- Request flow (step-by-step)
- Component details
- All 14 registered actions
- WebSocket protocol specification
- Debugging guide
---
## 🧪 Testing
The test client (`npm test`) provides an interactive menu:
```
Available Commands:
Tab Actions:
1. getActiveTab - Get currently active tab
2. getTabs - Get all tabs
Browser Actions:
3. getInteractiveSnapshot - Get page elements (requires tabId)
4. click - Click element (requires tabId, nodeId)
5. inputText - Type text (requires tabId, nodeId, text)
6. captureScreenshot - Take screenshot (requires tabId)
Diagnostic:
d. checkBrowserOS - Check if chrome.browserOS is available
Other:
h. Show this menu
q. Quit
```
### Example Usage:
1. Type `1` → Get active tab
2. Type `d` → Run diagnostic
3. Type `q` → Quit
---
## 🔧 Development
### Build Commands
```bash
npm run build # Production build
npm run build:dev # Development build (with source maps)
npm run watch # Watch mode for development
```
### Debug Extension
1. Go to `chrome://extensions/`
2. Click **"Inspect views service worker"** under "BrowserOS Controller"
3. Service worker console shows all logs
**Check extension status:**
```javascript
__browserosController.getStats();
```
**Expected output:**
```javascript
{
connection: "connected",
requests: { inFlight: 0, avgDuration: 0, errorRate: 0, totalRequests: 0 },
concurrency: { inFlight: 0, queued: 0, utilization: 0 },
validator: { activeIds: 0 },
responseQueue: { size: 0 }
}
```
**Check registered actions:**
Look for this log on extension load:
```
Registered 14 action(s): checkBrowserOS, getActiveTab, getTabs, ...
```
---
## 📋 Available Actions
| Action | Input | Output | Description |
| ------------------------ | --------------------------------- | ------------------------------- | -------------------------------------- |
| `checkBrowserOS` | `{}` | `{available, apis}` | Check if chrome.browserOS is available |
| `getActiveTab` | `{}` | `{tabId, url, title, windowId}` | Get currently active tab |
| `getTabs` | `{}` | `{tabs[]}` | Get all open tabs |
| `getInteractiveSnapshot` | `{tabId, options?}` | `InteractiveSnapshot` | Get all interactive elements on page |
| `click` | `{tabId, nodeId}` | `{success}` | Click element by nodeId |
| `inputText` | `{tabId, nodeId, text}` | `{success}` | Type text into element |
| `clear` | `{tabId, nodeId}` | `{success}` | Clear text from element |
| `scrollToNode` | `{tabId, nodeId}` | `{scrolled}` | Scroll element into view |
| `captureScreenshot` | `{tabId, size?, showHighlights?}` | `{dataUrl}` | Take screenshot |
| `sendKeys` | `{tabId, keys}` | `{success}` | Send keyboard keys |
| `getPageLoadStatus` | `{tabId}` | `PageLoadStatus` | Get page load status |
| `getSnapshot` | `{tabId, type, options?}` | `Snapshot` | Get text/links snapshot |
| `clickCoordinates` | `{tabId, x, y}` | `{success}` | Click at coordinates |
| `typeAtCoordinates` | `{tabId, x, y, text}` | `{success}` | Type at coordinates |
---
## 🔌 WebSocket Protocol
**Endpoint:** `ws://localhost:9224/controller`
**Request Format:**
```json
{
"id": "unique-request-id",
"action": "click",
"payload": {
"tabId": 12345,
"nodeId": 42
}
}
```
**Response Format:**
```json
{
"id": "unique-request-id",
"ok": true,
"data": {
"success": true
}
}
```
**Error Response:**
```json
{
"id": "unique-request-id",
"ok": false,
"error": "Element not found: nodeId 42"
}
```
---
## ⚠️ Common Issues
### Issue 1: "chrome.browserOS is undefined"
**Symptoms:**
- Diagnostic shows `"available": false`
- All browser actions fail
**Cause:** Not using BrowserOS Chrome
**Solution:**
- Download and use BrowserOS Chrome (not regular Chrome)
- Verify at `chrome://version` - should show "BrowserOS" in the name
---
### Issue 2: "Port 9224 is already in use"
**Symptoms:**
```
❌ Fatal Error: Port 9224 is already in use!
```
**Solution:**
```bash
lsof -ti:9224 | xargs kill -9
npm test
```
---
### Issue 3: Extension Not Connecting
**Symptoms:**
- Test client shows "Waiting for extension to connect..." forever
- Service worker console shows "Connection timeout"
**Checklist:**
1. ✅ Test server running (`npm test`)
2. ✅ Extension loaded in BrowserOS Chrome
3. ✅ Extension enabled (chrome://extensions/)
4. ✅ Service worker active (not suspended)
**Solution:**
1. Reload extension: chrome://extensions/ → "Reload" button
2. Restart test server: Ctrl+C, then `npm test`
---
### Issue 4: "Unknown action"
**Symptoms:**
```
Error: Unknown action: "click". Available actions: getActiveTab, getTabs, ...
```
**Cause:** Action not registered (extension didn't reload properly)
**Solution:**
1. Toggle extension OFF and ON at chrome://extensions/
2. Check service worker console for: `Registered 14 action(s): ...`
---
## 📁 Project Structure
```
browseros-controller/
├── README.md # This file
├── ARCHITECTURE.md # Complete architecture documentation
├── .env.example # Environment variable template
├── manifest.json # Extension manifest
├── package.json # Node dependencies
├── webpack.config.js # Build configuration
├── src/ # Source code
│ ├── background/ # Service worker entry point
│ ├── actions/ # Action handlers
│ │ ├── bookmark/ # Bookmark management actions
│ │ ├── browser/ # Browser interaction actions
│ │ ├── diagnostics/ # Diagnostic actions
│ │ ├── history/ # History management actions
│ │ └── tab/ # Tab management actions
│ ├── adapters/ # Chrome API wrappers
│ ├── config/ # Configuration management
│ │ ├── constants.ts # Application constants
│ │ └── environment.ts # Environment variable handling
│ ├── websocket/ # WebSocket client
│ ├── utils/ # Utilities
│ ├── protocol/ # Protocol types
│ └── types/ # TypeScript definitions
├── tests/ # Test files
│ ├── test-simple.js # Interactive test client
│ └── test-auto.js # Automated test client
└── dist/ # Built extension (generated)
├── background.js
└── manifest.json
```
---
## 🔗 Related Projects
- **BrowserOS-agent**: AI agent that uses this controller for browser automation
- **BrowserOS Chrome**: Custom Chrome build with `chrome.browserOS` APIs
---
## 📄 License
MIT
---
## 🆘 Support
For issues or questions:
1. Check [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed documentation
2. Review the "Common Issues" section above
3. Check service worker console for detailed error logs
4. Verify you're using BrowserOS Chrome (run diagnostic test)
---
**Happy automating! 🚀**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,38 +0,0 @@
{
"manifest_version": 3,
"name": "BrowserOS Controller",
"version": "1.0.0.8",
"description": "BrowserOS API bridge for BrowserOS Server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhlh9i/c2A3f0PL86hXhGPzguLIOQ+sPf3/Y8RD11gmdvoU6XqnUqv7GgBvm7SW7316uPnS58AYZY13jGtF4rFrscdda5H2CjZrtOyOycmKp2KzibJLwibXNm/JwKhZ3QEfgsW/orh1SMY2kNj62JemkWLcLyn3E1T+KTcTVyFOxiJS3hyQ+Y0/Jp1HOqGh5lYS58YYzwhId5rrJjfL7wFYtALgt2dEA2r7p4qpe+SW0QLA+ayjRAjS+yt+qitR0eWg+XgqcIk1f1KblN8/yDISssSD4LWiPofe5CmJPnqlHIuI0CpgvAFv9dvgR/w8OFkXxK5h06i6saum1xExj+IwIDAQAB",
"permissions": [
"tabs",
"activeTab",
"bookmarks",
"history",
"scripting",
"storage",
"tabGroups",
"webNavigation",
"downloads",
"browserOS",
"alarms"
],
"update_url": "https://cdn.browseros.com/extensions/update-manifest.xml",
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_icon": {
"16": "assets/icon16.png",
"48": "assets/icon48.png",
"128": "assets/icon128.png"
}
},
"icons": {
"16": "assets/icon16.png",
"48": "assets/icon48.png",
"128": "assets/icon128.png"
}
}

View File

@@ -1,39 +0,0 @@
{
"name": "browseros-controller",
"version": "1.0.0",
"description": "Chrome Extension API bridge for BrowserOS Server",
"directories": {
"doc": "docs"
},
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"watch": "webpack --mode development --watch",
"test": "node tests/test-simple.js",
"test:auto": "node tests/test-auto.js",
"typecheck": "tsc --noEmit"
},
"keywords": [
"browser-automation",
"chrome-extension",
"browseros"
],
"author": "BrowserOS Team",
"license": "MIT",
"type": "commonjs",
"dependencies": {
"@browseros/shared": "workspace:*",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/chrome": "^0.1.24",
"@types/node": "^24.7.1",
"copy-webpack-plugin": "^12.0.2",
"terser-webpack-plugin": "^5.3.11",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3",
"webpack": "^5.102.1",
"webpack-cli": "^6.0.1",
"ws": "^8.18.3"
}
}

View File

@@ -1,106 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import type { ActionResponse } from '@/protocol/types'
import { ActionResponseSchema } from '@/protocol/types'
import { logger } from '@/utils/logger'
// Re-export for convenience
export type { ActionResponse }
export { ActionResponseSchema }
/**
* ActionHandler - Abstract base class for all actions
*
* Responsibilities:
* - Define contract for all actions (must implement inputSchema + execute)
* - Validate input using Zod schemas
* - Handle validation and execution errors
* - Return standardized ActionResponse
*
* Usage:
* class MyAction extends ActionHandler<InputType, OutputType> {
* inputSchema = z.object({ ... });
* async execute(input: InputType): Promise<OutputType> { ... }
* }
*/
export abstract class ActionHandler<TInput = unknown, TOutput = unknown> {
/**
* Zod schema for input validation
* Must be implemented by concrete actions
*/
abstract readonly inputSchema: z.ZodSchema<TInput>
/**
* Execute the action logic
* Must be implemented by concrete actions
*
* @param input - Validated input (guaranteed to match inputSchema)
* @returns Action result
*/
abstract execute(input: TInput): Promise<TOutput>
/**
* Handle request with validation and error handling
* Called by ActionRegistry
*
* Flow:
* 1. Validate input with Zod schema
* 2. Execute action logic
* 3. Return standardized response (ok/error)
*
* @param payload - Raw payload from request (unvalidated)
* @returns Standardized action response
*/
async handle(payload: unknown): Promise<ActionResponse> {
const actionName = this.constructor.name
try {
// Step 1: Validate input
logger.debug(`[${actionName}] Validating input`)
const validatedInput = this.inputSchema.parse(payload)
// Step 2: Execute action
logger.debug(`[${actionName}] Executing action`)
const result = await this.execute(validatedInput)
// Step 3: Return success response
logger.debug(`[${actionName}] Action completed successfully`)
return { ok: true, data: result }
} catch (error) {
// Handle validation or execution errors
const errorMessage = this._formatError(error)
logger.error(`[${actionName}] Action failed: ${errorMessage}`)
return { ok: false, error: errorMessage }
}
}
/**
* Format error for user-friendly response
*
* @param error - Error from validation or execution
* @returns Formatted error message
*/
protected _formatError(error: unknown): string {
// Zod validation error
if (error instanceof z.ZodError) {
const errors = error.issues.map((e: z.ZodIssue) => {
const path = e.path.length > 0 ? `${e.path.join('.')}: ` : ''
return `${path}${e.message}`
})
return `Validation error: ${errors.join(', ')}`
}
// Standard Error
if (error instanceof Error) {
return error.message
}
// Unknown error
return String(error)
}
}

View File

@@ -1,148 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { logger } from '@/utils/logger'
import type { ActionHandler, ActionResponse } from './ActionHandler'
/**
* ActionRegistry - Central dispatcher for all actions
*
* Responsibilities:
* - Register action handlers by name
* - Dispatch requests to correct handler
* - Return error for unknown actions
* - Provide introspection (list available actions)
*
* Usage:
* const registry = new ActionRegistry();
* registry.register('getActiveTab', new GetActiveTabAction());
* const response = await registry.dispatch('getActiveTab', {});
*/
export class ActionRegistry {
private handlers = new Map<string, ActionHandler>()
/**
* Register an action handler
*
* @param actionName - Unique action name (e.g., "getActiveTab")
* @param handler - Action handler instance
*/
register(actionName: string, handler: ActionHandler): void {
if (this.handlers.has(actionName)) {
logger.warn(
`[ActionRegistry] Action "${actionName}" already registered, overwriting`,
)
}
this.handlers.set(actionName, handler)
logger.info(`[ActionRegistry] Registered action: ${actionName}`)
}
/**
* Dispatch request to appropriate action handler
*
* Flow:
* 1. Find handler for action name
* 2. If not found, return error
* 3. If found, delegate to handler.handle()
* 4. Handler validates input and executes
* 5. Return result
*
* @param actionName - Action to execute
* @param payload - Action payload (unvalidated)
* @returns Action response
*/
async dispatch(
actionName: string,
payload: unknown,
): Promise<ActionResponse> {
logger.debug(`[ActionRegistry] Dispatching action: ${actionName}`)
// Check if action exists
const handler = this.handlers.get(actionName)
if (!handler) {
const availableActions = Array.from(this.handlers.keys()).join(', ')
const errorMessage = `Unknown action: "${actionName}". Available actions: ${availableActions || 'none'}`
logger.error(`[ActionRegistry] ${errorMessage}`)
return {
ok: false,
error: errorMessage,
}
}
// Delegate to handler
try {
const response = await handler.handle(payload)
logger.debug(
`[ActionRegistry] Action "${actionName}" ${response.ok ? 'succeeded' : 'failed'}`,
)
return response
} catch (error) {
// Catch any unexpected errors from handler
const errorMessage =
error instanceof Error ? error.message : String(error)
logger.error(
`[ActionRegistry] Unexpected error in "${actionName}": ${errorMessage}`,
)
return {
ok: false,
error: `Action execution failed: ${errorMessage}`,
}
}
}
/**
* Get list of registered action names
*
* @returns Array of action names
*/
getAvailableActions(): string[] {
return Array.from(this.handlers.keys())
}
/**
* Check if action is registered
*
* @param actionName - Action name to check
* @returns True if action exists
*/
hasAction(actionName: string): boolean {
return this.handlers.has(actionName)
}
/**
* Get number of registered actions
*
* @returns Count of registered actions
*/
getActionCount(): number {
return this.handlers.size
}
/**
* Unregister an action (useful for testing)
*
* @param actionName - Action to remove
* @returns True if action was removed
*/
unregister(actionName: string): boolean {
const removed = this.handlers.delete(actionName)
if (removed) {
logger.info(`[ActionRegistry] Unregistered action: ${actionName}`)
}
return removed
}
/**
* Clear all registered actions (useful for testing)
*/
clear(): void {
const count = this.handlers.size
this.handlers.clear()
logger.info(`[ActionRegistry] Cleared ${count} registered actions`)
}
}

View File

@@ -1,81 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
// Input schema
const CreateBookmarkInputSchema = z.object({
title: z.string().describe('Bookmark title'),
url: z.string().url().describe('Bookmark URL'),
parentId: z
.string()
.optional()
.describe('Parent folder ID (optional, defaults to "Other Bookmarks")'),
})
// Output schema
const CreateBookmarkOutputSchema = z.object({
id: z.string().describe('Created bookmark ID'),
title: z.string().describe('Bookmark title'),
url: z.string().describe('Bookmark URL'),
dateAdded: z
.number()
.optional()
.describe('Timestamp when bookmark was created'),
})
type CreateBookmarkInput = z.infer<typeof CreateBookmarkInputSchema>
type CreateBookmarkOutput = z.infer<typeof CreateBookmarkOutputSchema>
/**
* CreateBookmarkAction - Create a new bookmark
*
* Creates a bookmark with the specified title and URL.
*
* Input:
* - title: Display title for the bookmark
* - url: Full URL to bookmark
* - parentId (optional): Parent folder ID
*
* Output:
* - id: Created bookmark ID
* - title: Bookmark title
* - url: Bookmark URL
* - dateAdded: Creation timestamp
*
* Usage:
* Create a bookmark in the default location (Other Bookmarks).
*
* Example:
* {
* "title": "Google",
* "url": "https://www.google.com"
* }
* // Returns: { id: "123", title: "Google", url: "https://www.google.com", dateAdded: 1729012345678 }
*/
export class CreateBookmarkAction extends ActionHandler<
CreateBookmarkInput,
CreateBookmarkOutput
> {
readonly inputSchema = CreateBookmarkInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(input: CreateBookmarkInput): Promise<CreateBookmarkOutput> {
const created = await this.bookmarkAdapter.createBookmark({
title: input.title,
url: input.url,
parentId: input.parentId,
})
return {
id: created.id,
title: created.title,
url: created.url || '',
dateAdded: created.dateAdded,
}
}
}

View File

@@ -1,52 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const CreateBookmarkFolderInputSchema = z.object({
title: z.string().describe('Folder name'),
parentId: z
.string()
.optional()
.describe('Parent folder ID (defaults to "1" = Bookmarks Bar)'),
})
const CreateBookmarkFolderOutputSchema = z.object({
id: z.string().describe('Created folder ID'),
title: z.string().describe('Folder name'),
parentId: z.string().optional().describe('Parent folder ID'),
dateAdded: z.number().optional().describe('Creation timestamp'),
})
type CreateBookmarkFolderInput = z.infer<typeof CreateBookmarkFolderInputSchema>
type CreateBookmarkFolderOutput = z.infer<
typeof CreateBookmarkFolderOutputSchema
>
export class CreateBookmarkFolderAction extends ActionHandler<
CreateBookmarkFolderInput,
CreateBookmarkFolderOutput
> {
readonly inputSchema = CreateBookmarkFolderInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(
input: CreateBookmarkFolderInput,
): Promise<CreateBookmarkFolderOutput> {
const created = await this.bookmarkAdapter.createBookmarkFolder({
title: input.title,
parentId: input.parentId,
})
return {
id: created.id,
title: created.title,
parentId: created.parentId,
dateAdded: created.dateAdded,
}
}
}

View File

@@ -1,59 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const GetBookmarkChildrenInputSchema = z.object({
folderId: z.string().describe('Folder ID to get children from'),
})
const GetBookmarkChildrenOutputSchema = z.object({
children: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().optional(),
parentId: z.string().optional(),
dateAdded: z.number().optional(),
isFolder: z.boolean(),
}),
),
count: z.number(),
})
type GetBookmarkChildrenInput = z.infer<typeof GetBookmarkChildrenInputSchema>
type GetBookmarkChildrenOutput = z.infer<typeof GetBookmarkChildrenOutputSchema>
export class GetBookmarkChildrenAction extends ActionHandler<
GetBookmarkChildrenInput,
GetBookmarkChildrenOutput
> {
readonly inputSchema = GetBookmarkChildrenInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(
input: GetBookmarkChildrenInput,
): Promise<GetBookmarkChildrenOutput> {
const results = await this.bookmarkAdapter.getBookmarkChildren(
input.folderId,
)
const children = results.map((node) => ({
id: node.id,
title: node.title,
url: node.url,
parentId: node.parentId,
dateAdded: node.dateAdded,
isFolder: !node.url,
}))
return {
children,
count: children.length,
}
}
}

View File

@@ -1,111 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
// Input schema
const GetBookmarksInputSchema = z.object({
query: z
.string()
.optional()
.describe(
'Search query to filter bookmarks (optional, returns all if not provided)',
),
limit: z
.number()
.int()
.positive()
.optional()
.default(20)
.describe('Maximum number of results (default: 20)'),
recent: z
.boolean()
.optional()
.default(false)
.describe('Get recent bookmarks instead of searching'),
})
// Output schema
const GetBookmarksOutputSchema = z.object({
bookmarks: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().optional(),
dateAdded: z.number().optional(),
parentId: z.string().optional(),
}),
),
count: z.number(),
})
type GetBookmarksInput = z.infer<typeof GetBookmarksInputSchema>
type GetBookmarksOutput = z.infer<typeof GetBookmarksOutputSchema>
/**
* GetBookmarksAction - Get or search bookmarks
*
* Retrieves bookmarks with optional filtering.
*
* Input:
* - query (optional): Search query to match title or URL
* - limit (optional): Maximum results (default: 20)
* - recent (optional): Get recent bookmarks instead (default: false)
*
* Output:
* - bookmarks: Array of bookmark objects
* - count: Number of bookmarks returned
*
* Usage:
* - Get recent: { "recent": true }
* - Search: { "query": "github" }
* - Get all (limited): { "limit": 50 }
*
* Example:
* {
* "query": "google",
* "limit": 10
* }
* // Returns: { bookmarks: [{id: "1", title: "Google", url: "https://google.com"}], count: 1 }
*/
export class GetBookmarksAction extends ActionHandler<
GetBookmarksInput,
GetBookmarksOutput
> {
readonly inputSchema = GetBookmarksInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(input: GetBookmarksInput): Promise<GetBookmarksOutput> {
let results: chrome.bookmarks.BookmarkTreeNode[]
if (input.recent) {
// Get recent bookmarks
results = await this.bookmarkAdapter.getRecentBookmarks(input.limit)
} else if (input.query) {
// Search bookmarks
results = await this.bookmarkAdapter.searchBookmarks(input.query)
results = results.slice(0, input.limit)
} else {
// Get recent by default
results = await this.bookmarkAdapter.getRecentBookmarks(input.limit)
}
// Map to output format
const bookmarks = results.map((b) => ({
id: b.id,
title: b.title,
url: b.url,
dateAdded: b.dateAdded,
parentId: b.parentId,
}))
return {
bookmarks,
count: bookmarks.length,
}
}
}

View File

@@ -1,49 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const MoveBookmarkInputSchema = z.object({
id: z.string().describe('Bookmark or folder ID to move'),
parentId: z.string().optional().describe('New parent folder ID'),
index: z.number().int().min(0).optional().describe('Position within parent'),
})
const MoveBookmarkOutputSchema = z.object({
id: z.string().describe('Moved bookmark ID'),
title: z.string().describe('Bookmark title'),
url: z.string().optional().describe('Bookmark URL (undefined if folder)'),
parentId: z.string().optional().describe('New parent folder ID'),
index: z.number().optional().describe('New position within parent'),
})
type MoveBookmarkInput = z.infer<typeof MoveBookmarkInputSchema>
type MoveBookmarkOutput = z.infer<typeof MoveBookmarkOutputSchema>
export class MoveBookmarkAction extends ActionHandler<
MoveBookmarkInput,
MoveBookmarkOutput
> {
readonly inputSchema = MoveBookmarkInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(input: MoveBookmarkInput): Promise<MoveBookmarkOutput> {
const destination: { parentId?: string; index?: number } = {}
if (input.parentId !== undefined) destination.parentId = input.parentId
if (input.index !== undefined) destination.index = input.index
const moved = await this.bookmarkAdapter.moveBookmark(input.id, destination)
return {
id: moved.id,
title: moved.title,
url: moved.url,
parentId: moved.parentId,
index: moved.index,
}
}
}

View File

@@ -1,62 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
// Input schema
const RemoveBookmarkInputSchema = z.object({
id: z.string().describe('Bookmark ID to remove'),
})
// Output schema
const RemoveBookmarkOutputSchema = z.object({
success: z
.boolean()
.describe('Whether the bookmark was successfully removed'),
message: z.string().describe('Confirmation message'),
})
type RemoveBookmarkInput = z.infer<typeof RemoveBookmarkInputSchema>
type RemoveBookmarkOutput = z.infer<typeof RemoveBookmarkOutputSchema>
/**
* RemoveBookmarkAction - Remove a bookmark
*
* Deletes a bookmark by its ID.
*
* Input:
* - id: Bookmark ID to remove
*
* Output:
* - success: true if removed
* - message: Confirmation message
*
* Usage:
* Get the bookmark ID from getBookmarks first, then remove it.
*
* Example:
* {
* "id": "123"
* }
* // Returns: { success: true, message: "Removed bookmark 123" }
*/
export class RemoveBookmarkAction extends ActionHandler<
RemoveBookmarkInput,
RemoveBookmarkOutput
> {
readonly inputSchema = RemoveBookmarkInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(input: RemoveBookmarkInput): Promise<RemoveBookmarkOutput> {
await this.bookmarkAdapter.removeBookmark(input.id)
return {
success: true,
message: `Removed bookmark ${input.id}`,
}
}
}

View File

@@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const RemoveBookmarkTreeInputSchema = z.object({
id: z.string().describe('Folder ID to remove'),
confirm: z.boolean().describe('Must be true to confirm recursive deletion'),
})
const RemoveBookmarkTreeOutputSchema = z.object({
success: z.boolean().describe('Whether the folder was removed'),
message: z.string().describe('Result message'),
})
type RemoveBookmarkTreeInput = z.infer<typeof RemoveBookmarkTreeInputSchema>
type RemoveBookmarkTreeOutput = z.infer<typeof RemoveBookmarkTreeOutputSchema>
export class RemoveBookmarkTreeAction extends ActionHandler<
RemoveBookmarkTreeInput,
RemoveBookmarkTreeOutput
> {
readonly inputSchema = RemoveBookmarkTreeInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(
input: RemoveBookmarkTreeInput,
): Promise<RemoveBookmarkTreeOutput> {
if (input.confirm !== true) {
return {
success: false,
message:
'Recursive deletion requires confirm: true. This will permanently delete the folder and all its contents.',
}
}
await this.bookmarkAdapter.removeBookmarkTree(input.id)
return {
success: true,
message: `Removed folder ${input.id} and all its contents`,
}
}
}

View File

@@ -1,82 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
// Input schema
const UpdateBookmarkInputSchema = z.object({
id: z.string().describe('Bookmark ID to update'),
title: z.string().optional().describe('New bookmark title'),
url: z.string().url().optional().describe('New bookmark URL'),
})
// Output schema
const UpdateBookmarkOutputSchema = z.object({
id: z.string().describe('Bookmark ID'),
title: z.string().describe('Updated bookmark title'),
url: z.string().optional().describe('Updated bookmark URL'),
})
type UpdateBookmarkInput = z.infer<typeof UpdateBookmarkInputSchema>
type UpdateBookmarkOutput = z.infer<typeof UpdateBookmarkOutputSchema>
/**
* UpdateBookmarkAction - Update a bookmark's title or URL
*
* Updates an existing bookmark with new title and/or URL.
*
* Input:
* - id: Bookmark ID to update
* - title (optional): New title for the bookmark
* - url (optional): New URL for the bookmark
*
* Output:
* - id: Bookmark ID
* - title: Updated title
* - url: Updated URL
*
* Usage:
* Update a bookmark's title or URL (at least one must be provided).
*
* Example:
* {
* "id": "123",
* "title": "New Title",
* "url": "https://www.example.com"
* }
* // Returns: { id: "123", title: "New Title", url: "https://www.example.com" }
*/
export class UpdateBookmarkAction extends ActionHandler<
UpdateBookmarkInput,
UpdateBookmarkOutput
> {
readonly inputSchema = UpdateBookmarkInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(input: UpdateBookmarkInput): Promise<UpdateBookmarkOutput> {
const changes: { title?: string; url?: string } = {}
if (input.title !== undefined) {
changes.title = input.title
}
if (input.url !== undefined) {
changes.url = input.url
}
if (Object.keys(changes).length === 0) {
throw new Error('At least one of title or url must be provided')
}
const updated = await this.bookmarkAdapter.updateBookmark(input.id, changes)
return {
id: updated.id,
title: updated.title,
url: updated.url,
}
}
}

View File

@@ -1,79 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import {
BrowserOSAdapter,
type ScreenshotSizeKey,
} from '@/adapters/BrowserOSAdapter'
import { ActionHandler } from '../ActionHandler'
// Input schema
const CaptureScreenshotInputSchema = z.object({
tabId: z.number().describe('The tab ID to capture'),
size: z
.enum(['small', 'medium', 'large'])
.optional()
.default('medium')
.describe('Screenshot size preset (default: medium)'),
showHighlights: z
.boolean()
.optional()
.default(true)
.describe('Show element highlights (default: true)'),
width: z.number().optional().describe('Exact width in pixels'),
height: z.number().optional().describe('Exact height in pixels'),
})
// Output schema
const CaptureScreenshotOutputSchema = z.object({
dataUrl: z.string().describe('Base64-encoded PNG data URL'),
})
type CaptureScreenshotInput = z.infer<typeof CaptureScreenshotInputSchema>
type CaptureScreenshotOutput = z.infer<typeof CaptureScreenshotOutputSchema>
/**
* CaptureScreenshotAction - Capture a screenshot of the page
*
* Captures a screenshot with configurable size and options.
*
* Size Options:
* - small (512px): Low detail, minimal tokens
* - medium (768px): Balanced quality/tokens (default)
* - large (1028px): High detail, maximum tokens
*
* Or specify exact dimensions with width/height.
*
* Returns:
* - dataUrl: PNG image as base64 data URL (data:image/png;base64,...)
*
* Usage:
* 1. For AI vision models: use 'medium' or 'large'
* 2. For debugging: use 'small'
* 3. For exact size: specify width and height
*
* Used by: ScreenshotTool, VisualClick, VisualType
*/
export class CaptureScreenshotAction extends ActionHandler<
CaptureScreenshotInput,
CaptureScreenshotOutput
> {
readonly inputSchema = CaptureScreenshotInputSchema
private browserOSAdapter = BrowserOSAdapter.getInstance()
async execute(
input: CaptureScreenshotInput,
): Promise<CaptureScreenshotOutput> {
const dataUrl = await this.browserOSAdapter.captureScreenshot(
input.tabId,
input.size as ScreenshotSizeKey | undefined,
input.showHighlights,
input.width,
input.height,
)
return { dataUrl }
}
}

View File

@@ -1,124 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import {
BrowserOSAdapter,
type ScreenshotSizeKey,
} from '@/adapters/BrowserOSAdapter'
import { logger } from '@/utils/logger'
import { PointerOverlay } from '@/utils/PointerOverlay'
import { SnapshotCache } from '@/utils/SnapshotCache'
import { ActionHandler } from '../ActionHandler'
// Input schema
const CaptureScreenshotPointerInputSchema = z.object({
tabId: z.number().describe('The tab ID to capture'),
nodeId: z
.number()
.int()
.positive()
.describe('The nodeId to show pointer over'),
size: z
.enum(['small', 'medium', 'large'])
.optional()
.default('medium')
.describe('Screenshot size preset (default: medium)'),
pointerLabel: z
.string()
.optional()
.describe('Optional label to show with pointer (e.g., "Click", "Type")'),
})
// Output schema
const CaptureScreenshotPointerOutputSchema = z.object({
dataUrl: z.string().describe('Base64-encoded PNG data URL'),
pointerPosition: z
.object({
x: z.number(),
y: z.number(),
})
.optional()
.describe('Coordinates where pointer was shown'),
})
type CaptureScreenshotPointerInput = z.infer<
typeof CaptureScreenshotPointerInputSchema
>
type CaptureScreenshotPointerOutput = z.infer<
typeof CaptureScreenshotPointerOutputSchema
>
/**
* CaptureScreenshotPointerAction - Show pointer over element and capture screenshot
*
* Shows a visual pointer overlay at the center of the specified element,
* then captures a screenshot with the pointer visible.
*
* Prerequisites:
* - Must call getInteractiveSnapshot first to populate the cache
* - NodeId must exist in the cached snapshot
*
* Usage:
* 1. Get snapshot to find elements and populate cache
* 2. Call captureScreenshotPointer with tabId and nodeId
* 3. Returns screenshot with pointer overlay visible
*
* Used by: Visual debugging, automation demos, step-by-step captures
*/
export class CaptureScreenshotPointerAction extends ActionHandler<
CaptureScreenshotPointerInput,
CaptureScreenshotPointerOutput
> {
readonly inputSchema = CaptureScreenshotPointerInputSchema
private browserOSAdapter = BrowserOSAdapter.getInstance()
async execute(
input: CaptureScreenshotPointerInput,
): Promise<CaptureScreenshotPointerOutput> {
const { tabId, nodeId, size, pointerLabel } = input
// Get element rect from cache
const rect = SnapshotCache.getNodeRect(tabId, nodeId)
let pointerPosition: { x: number; y: number } | undefined
if (rect) {
// Calculate center coordinates
const { x, y } = PointerOverlay.getCenterCoordinates(rect)
pointerPosition = { x, y }
// Show pointer
await PointerOverlay.showPointer(tabId, x, y, pointerLabel)
logger.debug(
`[CaptureScreenshotPointerAction] Showed pointer at (${x}, ${y}) for node ${nodeId}`,
)
} else {
logger.warn(
`[CaptureScreenshotPointerAction] No cached rect for node ${nodeId} in tab ${tabId}. Capturing without pointer.`,
)
}
// Small delay to ensure pointer is rendered
await this.delay(100)
// Capture screenshot with pointer visible
const dataUrl = await this.browserOSAdapter.captureScreenshot(
tabId,
size as ScreenshotSizeKey | undefined,
false, // Don't show highlights, we have the pointer
)
return {
dataUrl,
pointerPosition,
}
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}

View File

@@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
import { ActionHandler } from '../ActionHandler'
const ClearInputSchema = z.object({
tabId: z.number().describe('The tab ID containing the element'),
nodeId: z
.number()
.int()
.positive()
.describe('The nodeId from interactive snapshot'),
})
type ClearInput = z.infer<typeof ClearInputSchema>
interface ClearOutput {
success: boolean
}
/**
* ClearAction - Clear text from an input element
*
* Clears all text from an input field or textarea.
* Used before inputText or to reset form fields.
*/
export class ClearAction extends ActionHandler<ClearInput, ClearOutput> {
readonly inputSchema = ClearInputSchema
private browserOSAdapter = BrowserOSAdapter.getInstance()
async execute(input: ClearInput): Promise<ClearOutput> {
await this.browserOSAdapter.clear(input.tabId, input.nodeId)
return { success: true }
}
}

View File

@@ -1,62 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
import { PointerOverlay } from '@/utils/PointerOverlay'
import { SnapshotCache } from '@/utils/SnapshotCache'
import { ActionHandler } from '../ActionHandler'
// Input schema
const ClickInputSchema = z.object({
tabId: z.number().describe('The tab ID containing the element'),
nodeId: z
.number()
.int()
.positive()
.describe('The nodeId from interactive snapshot'),
})
// Output schema
const ClickOutputSchema = z.object({
success: z.boolean().describe('Whether the click succeeded'),
})
type ClickInput = z.infer<typeof ClickInputSchema>
type ClickOutput = z.infer<typeof ClickOutputSchema>
/**
* ClickAction - Click an element by its nodeId
*
* This action clicks an interactive element identified by its nodeId from getInteractiveSnapshot.
*
* Prerequisites:
* - Must call getInteractiveSnapshot first to get valid nodeIds
* - NodeIds are valid only for the current page state
* - NodeIds are invalidated on page navigation
*
* Usage:
* 1. Get snapshot to find clickable elements
* 2. Choose element by nodeId
* 3. Call click with tabId and nodeId
*
* Used by: ClickTool, all automation workflows
*/
export class ClickAction extends ActionHandler<ClickInput, ClickOutput> {
readonly inputSchema = ClickInputSchema
private browserOSAdapter = BrowserOSAdapter.getInstance()
async execute(input: ClickInput): Promise<ClickOutput> {
// Show pointer overlay before click
const rect = SnapshotCache.getNodeRect(input.tabId, input.nodeId)
if (rect) {
const { x, y } = PointerOverlay.getCenterCoordinates(rect)
await PointerOverlay.showPointerAndWait(input.tabId, x, y, 'Click')
}
await this.browserOSAdapter.click(input.tabId, input.nodeId)
return { success: true }
}
}

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