Compare commits

..

2 Commits

Author SHA1 Message Date
Nikhil Sonti
3bbffd3a8c refactor: rework 0331-use_hidden_pages_for_scheduled_tasks based on feedback 2026-03-31 15:48:25 -07:00
Nikhil Sonti
9615815ce6 feat: use hidden pages for scheduled tasks 2026-03-31 15:13:51 -07:00
222 changed files with 4152 additions and 15646 deletions

2
.gitattributes vendored
View File

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

View File

@@ -4,7 +4,6 @@ on:
pull_request:
branches:
- main
- dev
paths:
- "packages/browseros-agent/**"

View File

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

2
.gitignore vendored
View File

@@ -23,11 +23,9 @@ nxtscape-cli-access.json
gclient.json
.env
.grove/
AGENTS.md
**/resources/binaries/
packages/browseros/build/tools/
# AI SDK DevTools traces
.devtools/
.omc/

View File

@@ -192,7 +192,7 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
Copyright &copy; 2026 Felafax, Inc.
Copyright &copy; 2025 Felafax, Inc.
## Stargazers

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
CLAUDE.md
# Logs
logs
*.log

View File

@@ -148,7 +148,7 @@ When creating new packages in this monorepo:
## Test Organization
Tests are in `apps/server/tests/`:
- `tools/` - Tool tests (require BrowserOS running with CDP), plus ACL scorer tests (standalone)
- `tools/` - Tool tests (require BrowserOS running with CDP)
- `browser/` - Browser backend tests
- `agent/` - Agent tests (compaction, rate limiter)
- `sdk/` - Agent SDK tests

View File

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

View File

@@ -1,16 +1,5 @@
# BrowserOS Agent Extension
## v0.0.99 (2026-04-08)
## What's Changed
- chore: bump server and extension version (#659)
- chore(agent): remove workflows feature (#656)
- feat: replace model picker with shadcn Combobox + fuse.js fuzzy search (#617)
- feat: clean-up - remove obsolete controller extension (#610)
- docs: update agent extension changelog for v0.0.98 (#609)
## v0.0.98 (2026-03-27)
## What's Changed

View File

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

View File

@@ -1,143 +0,0 @@
import {
CheckCircle2,
ChevronDown,
CircleDotDashed,
Clock3,
ShieldAlert,
ShieldCheck,
XCircle,
} from 'lucide-react'
import { type FC, useState } from 'react'
import { ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import type { ExecutionStepRecord } from '@/lib/execution-history/types'
import { cn } from '@/lib/utils'
const formatToolName = (name: string) =>
name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, (value) => value.toUpperCase())
const formatStateLabel = (state: ExecutionStepRecord['state']) => {
if (state === 'input-streaming') return 'Preparing'
if (state === 'input-available') return 'Running'
if (state === 'approval-requested') return 'Approval Needed'
if (state === 'approval-responded') return 'Approval Responded'
if (state === 'output-available') return 'Completed'
if (state === 'output-denied') return 'Denied'
return 'Error'
}
const getStateIcon = (step: ExecutionStepRecord) => {
if (step.state === 'output-available') {
return <CheckCircle2 className="h-4 w-4 text-green-500" />
}
if (
step.state === 'input-streaming' ||
step.state === 'input-available' ||
step.state === 'approval-requested'
) {
return <Clock3 className="h-4 w-4 text-[var(--accent-orange)]" />
}
if (step.state === 'approval-responded') {
return <ShieldCheck className="h-4 w-4 text-blue-500" />
}
if (step.state === 'output-denied') {
return <ShieldAlert className="h-4 w-4 text-orange-500" />
}
if (step.state === 'output-error') {
return <XCircle className="h-4 w-4 text-destructive" />
}
return <CircleDotDashed className="h-4 w-4 text-muted-foreground" />
}
const isAclBlocked = (step: ExecutionStepRecord) =>
Boolean(
step.errorText?.includes('Action blocked by ACL rule') ||
step.approval?.reason?.includes('Action blocked by ACL rule') ||
step.previewText === 'Blocked by ACL rule',
)
const shouldShowPreview = (step: ExecutionStepRecord) =>
step.state === 'input-streaming' ||
step.state === 'input-available' ||
step.state === 'approval-requested' ||
step.state === 'approval-responded'
export const ExecutionStepItem: FC<{
step: ExecutionStepRecord
defaultOpen?: boolean
}> = ({ step, defaultOpen = false }) => {
const [open, setOpen] = useState(defaultOpen)
const deniedReason =
step.state === 'output-denied' ? step.approval?.reason : undefined
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-xl border border-border/60 bg-card/60">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-start gap-3 px-4 py-3 text-left"
>
<div className="mt-0.5 shrink-0">{getStateIcon(step)}</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-foreground text-sm">
{formatToolName(step.toolName)}
</p>
<Badge variant="secondary">
{formatStateLabel(step.state)}
</Badge>
{isAclBlocked(step) && (
<Badge variant="outline">ACL Blocked</Badge>
)}
</div>
{shouldShowPreview(step) && (
<p className="mt-1 text-muted-foreground text-xs">
{step.previewText}
</p>
)}
</div>
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
open && 'rotate-180',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 border-t">
{step.input !== undefined && <ToolInput input={step.input} />}
{step.state === 'output-denied' ? (
<div className="space-y-2 p-4">
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Result
</h4>
<div className="rounded-md bg-orange-500/10 p-3 text-orange-700 text-sm dark:text-orange-300">
{deniedReason ?? 'The requested action was denied.'}
</div>
</div>
) : (
<ToolOutput
output={step.output}
errorText={step.errorText}
className="pt-0"
/>
)}
</CollapsibleContent>
</div>
</Collapsible>
)
}

View File

@@ -1,168 +0,0 @@
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
CheckCircle2,
ChevronDown,
CircleDot,
CircleSlash2,
MessageSquareText,
Trash2,
XCircle,
} from 'lucide-react'
import { type FC, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import type { ExecutionTaskRecord } from '@/lib/execution-history/types'
import { cn } from '@/lib/utils'
import { ExecutionStepItem } from './ExecutionStepItem'
dayjs.extend(relativeTime)
dayjs.extend(duration)
function getTaskStatusIcon(status: ExecutionTaskRecord['status']) {
if (status === 'completed') {
return <CheckCircle2 className="h-4 w-4 text-green-500" />
}
if (status === 'running') {
return <CircleDot className="h-4 w-4 text-[var(--accent-orange)]" />
}
if (status === 'stopped') {
return <CircleSlash2 className="h-4 w-4 text-orange-500" />
}
return <XCircle className="h-4 w-4 text-destructive" />
}
function getTaskStatusLabel(status: ExecutionTaskRecord['status']) {
if (status === 'completed') return 'Completed'
if (status === 'running') return 'Running'
if (status === 'stopped') return 'Stopped'
if (status === 'interrupted') return 'Interrupted'
return 'Failed'
}
function formatDuration(task: ExecutionTaskRecord): string | null {
if (!task.completedAt) return null
const diff = dayjs(task.completedAt).diff(task.startedAt)
const parsed = dayjs.duration(diff)
const minutes = Math.floor(parsed.asMinutes())
const seconds = parsed.seconds()
if (minutes === 0) return `${seconds}s`
return `${minutes}m ${seconds}s`
}
export const ExecutionTaskCard: FC<{
task: ExecutionTaskRecord
defaultOpen?: boolean
onDelete?: (task: ExecutionTaskRecord) => void
}> = ({ task, defaultOpen = false, onDelete }) => {
const [open, setOpen] = useState(defaultOpen)
const startedAgo = useMemo(
() => dayjs(task.startedAt).fromNow(),
[task.startedAt],
)
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-2xl border border-border/60 bg-card shadow-sm">
<div className="flex items-start gap-2 px-5 py-5">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-3 text-left"
>
<div className="mt-0.5 shrink-0">
{getTaskStatusIcon(task.status)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="line-clamp-2 font-medium text-base text-foreground">
{task.promptText}
</p>
<Badge variant="secondary">
{getTaskStatusLabel(task.status)}
</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<span>{startedAgo}</span>
<span></span>
<span>
{task.actionCount} action{task.actionCount === 1 ? '' : 's'}
</span>
{formatDuration(task) && (
<>
<span></span>
<span>{formatDuration(task)}</span>
</>
)}
{task.deniedCount > 0 && (
<Badge variant="outline" className="h-5 rounded-full px-2">
{task.deniedCount} denied
</Badge>
)}
{task.errorCount > 0 && (
<Badge variant="outline" className="h-5 rounded-full px-2">
{task.errorCount} error
{task.errorCount === 1 ? '' : 's'}
</Badge>
)}
</div>
{task.responsePreview ? (
<div className="mt-4 flex items-start gap-2 rounded-xl bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
<MessageSquareText className="mt-0.5 h-4 w-4 shrink-0" />
<p className="line-clamp-2">{task.responsePreview}</p>
</div>
) : null}
</div>
<ChevronDown
className={cn(
'mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
open && 'rotate-180',
)}
/>
</button>
</CollapsibleTrigger>
{onDelete ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => onDelete(task)}
aria-label={`Delete ${task.promptText}`}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
<CollapsibleContent className="border-border/60 border-t px-5 py-5">
{task.steps.length === 0 ? (
<div className="rounded-xl border border-border/70 border-dashed bg-muted/30 px-4 py-6 text-center text-muted-foreground text-sm">
No tool actions were recorded for this task.
</div>
) : (
<div className="space-y-3">
{task.steps.map((step, index) => (
<ExecutionStepItem
key={step.id}
step={step}
defaultOpen={
task.status === 'running' && index === task.steps.length - 1
}
/>
))}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
)
}

View File

@@ -4,13 +4,12 @@ import {
Bot,
Compass,
CreditCard,
GitBranch,
MessageSquare,
Palette,
RotateCcw,
Search,
Server,
ShieldAlert,
ShieldCheck,
} from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
@@ -80,15 +79,19 @@ const primarySettingsSections: NavSection[] = [
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'Tool Approvals', to: '/settings/approvals', icon: ShieldCheck },
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{ name: 'ACL Rules', to: '/settings/acl', icon: ShieldAlert },
{
name: 'Usage & Billing',
to: '/settings/usage',
icon: CreditCard,
feature: Feature.CREDITS_SUPPORT,
},
{
name: 'Workflows',
to: '/workflows',
icon: GitBranch,
feature: Feature.WORKFLOW_SUPPORT,
},
],
},
]

View File

@@ -1,11 +1,9 @@
import {
Brain,
CalendarClock,
Cpu,
Home,
PlugZap,
Settings,
Shield,
Sparkles,
Wand2,
} from 'lucide-react'
@@ -41,7 +39,6 @@ const primaryNavItems: NavItem[] = [
feature: Feature.MANAGED_MCP_SUPPORT,
},
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
{ name: 'Agents', to: '/agents', icon: Cpu },
{
name: 'Skills',
to: '/home/skills',
@@ -60,7 +57,6 @@ const primaryNavItems: NavItem[] = [
icon: Sparkles,
feature: Feature.SOUL_SUPPORT,
},
{ name: 'Governance', to: '/admin', icon: Shield },
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]

View File

@@ -1,5 +1,7 @@
import type { FC } from 'react'
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
import { NewTab } from '../newtab/index/NewTab'
import { NewTabChat } from '../newtab/index/NewTabChat'
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
import { Personalize } from '../newtab/personalize/Personalize'
@@ -7,14 +9,9 @@ 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 { AclSettingsPage } from './acl-settings/AclSettingsPage'
import { AdminDashboardPage } from './admin-dashboard/AdminDashboardPage'
import { AgentCommandConversation } from './agent-command/AgentCommandConversation'
import { AgentCommandHome } from './agent-command/AgentCommandHome'
import { AgentCommandLayout } from './agent-command/agent-command-layout'
import { AgentsPage } from './agents/AgentsPage'
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'
@@ -31,8 +28,8 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
import { SoulPage } from './soul/SoulPage'
import { ToolApprovalsPage } from './tool-approvals/ToolApprovalsPage'
import { UsagePage } from './usage/UsagePage'
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
const params = new URLSearchParams(window.location.search)
@@ -56,7 +53,9 @@ const OptionsRedirect: FC = () => {
soul: '/home/soul',
skills: '/home/skills',
'jtbd-agent': '/settings/survey',
workflows: '/workflows',
scheduled: '/scheduled',
'create-graph': '/workflows/create-graph',
}
const newPath = routeMap[path] || '/settings/ai'
@@ -81,13 +80,7 @@ export const App: FC = () => {
<Route element={<SidebarLayout />}>
{/* Home routes */}
<Route path="home" element={<NewTabLayout />}>
<Route element={<AgentCommandLayout />}>
<Route index element={<AgentCommandHome />} />
<Route
path="agents/:agentId"
element={<AgentCommandConversation />}
/>
</Route>
<Route index element={<NewTab />} />
<Route path="chat" element={<NewTabChat />} />
<Route path="personalize" element={<Personalize />} />
<Route path="soul" element={<SoulPage />} />
@@ -97,9 +90,8 @@ export const App: FC = () => {
{/* Primary nav routes */}
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="workflows" element={<WorkflowsPageWrapper />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
<Route path="agents" element={<AgentsPage />} />
<Route path="admin" element={<AdminDashboardPage />} />
</Route>
{/* Settings with dedicated sidebar */}
@@ -113,11 +105,12 @@ export const App: FC = () => {
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
<Route path="acl" element={<AclSettingsPage />} />
<Route path="approvals" element={<ToolApprovalsPage />} />
</Route>
</Route>
{/* Full-screen without sidebar */}
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
{/* Onboarding routes - no sidebar, no auth required */}
<Route path="onboarding">
<Route index element={<Onboarding />} />
@@ -144,12 +137,6 @@ export const App: FC = () => {
path="/settings/skills"
element={<Navigate to="/home/skills" replace />}
/>
<Route path="/audit" element={<Navigate to="/admin" replace />} />
<Route
path="/observability"
element={<Navigate to="/admin" replace />}
/>
<Route path="/executions" element={<Navigate to="/admin" replace />} />
<Route path="/options/*" element={<OptionsRedirect />} />
{/* Fallback to home */}

View File

@@ -1,57 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { Globe, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
interface AclRuleCardProps {
rule: AclRule
onToggle: (id: string, enabled: boolean) => void
onDelete: (id: string) => void
}
export const AclRuleCard: FC<AclRuleCardProps> = ({
rule,
onToggle,
onDelete,
}) => {
const summary =
rule.description ?? rule.textMatch ?? rule.selector ?? 'Block actions'
return (
<div
className={cn(
'flex items-center gap-4 rounded-xl border p-4 transition-all',
rule.enabled
? 'border-red-300 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20'
: 'border-border bg-card opacity-60',
)}
>
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => onToggle(rule.id, checked)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium text-sm">{summary}</span>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1 font-mono text-xs">
<Globe className="size-3" />
{rule.sitePattern}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(rule.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
)
}

View File

@@ -1,85 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { Plus, ShieldAlert } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { aclRulesStorage } from '@/lib/acl/storage'
import { AclRuleCard } from './AclRuleCard'
import { NewAclRuleDialog } from './NewAclRuleDialog'
export const AclSettingsPage: FC = () => {
const [rules, setRules] = useState<AclRule[]>([])
useEffect(() => {
aclRulesStorage.getValue().then(setRules)
const unwatch = aclRulesStorage.watch(setRules)
return () => unwatch()
}, [])
const saveRules = (next: AclRule[]) => {
setRules(next)
aclRulesStorage.setValue(next)
}
const handleAddRule = (rule: AclRule) => {
saveRules([...rules, rule])
}
const handleToggle = (id: string, enabled: boolean) => {
saveRules(rules.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
const handleDelete = (id: string) => {
saveRules(rules.filter((r) => r.id !== id))
}
return (
<div className="mx-auto max-w-2xl p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-semibold text-xl">ACL Rules</h1>
<p className="mt-1 text-muted-foreground text-sm">
Describe what the agent should avoid on a site and BrowserOS will
block matching actions.
</p>
</div>
<NewAclRuleDialog onSave={handleAddRule}>
<Button size="sm">
<Plus className="mr-1 size-4" />
Add Rule
</Button>
</NewAclRuleDialog>
</div>
{rules.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed p-12 text-center">
<ShieldAlert className="size-10 text-muted-foreground" />
<div>
<p className="font-medium">No ACL rules defined</p>
<p className="mt-1 text-muted-foreground text-sm">
Add a plain-English rule like &ldquo;payments and checkout&rdquo;
or &ldquo;send email&rdquo; and BrowserOS will apply broad safety
blocking on that site.
</p>
</div>
<NewAclRuleDialog onSave={handleAddRule}>
<Button variant="outline" size="sm">
<Plus className="mr-1 size-4" />
Add your first rule
</Button>
</NewAclRuleDialog>
</div>
) : (
<div className="flex flex-col gap-3">
{rules.map((rule) => (
<AclRuleCard
key={rule.id}
rule={rule}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -1,98 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface NewAclRuleDialogProps {
onSave: (rule: AclRule) => void
children: React.ReactNode
}
export const NewAclRuleDialog: FC<NewAclRuleDialogProps> = ({
onSave,
children,
}) => {
const [open, setOpen] = useState(false)
const [sitePattern, setSitePattern] = useState('')
const [intent, setIntent] = useState('')
const reset = () => {
setSitePattern('')
setIntent('')
}
const handleSave = () => {
if (!sitePattern.trim() || !intent.trim()) return
onSave({
id: crypto.randomUUID(),
sitePattern: sitePattern.trim(),
description: intent.trim(),
enabled: true,
})
reset()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add ACL Rule</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="site-pattern">
Domain <span className="text-destructive">*</span>
</Label>
<Input
id="site-pattern"
placeholder="amazon.com"
value={sitePattern}
onChange={(e) => setSitePattern(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
Matches the domain and all subdomains.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="intent">
What should BrowserOS block?{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="intent"
placeholder="Payments and checkout"
value={intent}
onChange={(e) => setIntent(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
Use plain English. BrowserOS will block matching actions on this
site.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!sitePattern.trim() || !intent.trim()}
>
Add Rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,41 +0,0 @@
import { Shield } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
interface AdminDashboardHeaderProps {
pendingCount: number
runningCount: number
}
export const AdminDashboardHeader: FC<AdminDashboardHeaderProps> = ({
pendingCount,
runningCount,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Shield className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-xl">Governance</h2>
{pendingCount > 0 && (
<Badge className="gap-1.5 rounded-full bg-yellow-500/10 text-yellow-600">
{pendingCount} pending
</Badge>
)}
{runningCount > 0 && (
<Badge className="gap-1.5 rounded-full">
{runningCount} live
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
Control agent permissions and audit every action.
</p>
</div>
</div>
</div>
)
}

View File

@@ -1,199 +0,0 @@
import dayjs from 'dayjs'
import { Shield } from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { ExecutionTaskCard } from '@/components/execution-history/ExecutionTaskCard'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
removeConversationExecutionTask,
useExecutionHistoryByConversation,
} from '@/lib/execution-history/storage'
import type { ExecutionTaskRecord } from '@/lib/execution-history/types'
import { pendingToolApprovalsStorage } from '@/lib/tool-approvals/approval-sync-storage'
import { AdminDashboardHeader } from './AdminDashboardHeader'
import { PendingApprovals } from './PendingApprovals'
type TaskGroup = {
label: string
tasks: ExecutionTaskRecord[]
}
function getGroupLabel(date: string) {
const startedAt = dayjs(date)
if (startedAt.isSame(dayjs(), 'day')) return 'Today'
if (startedAt.isSame(dayjs().subtract(1, 'day'), 'day')) return 'Yesterday'
return startedAt.format('MMMM D, YYYY')
}
function groupTasks(tasks: ExecutionTaskRecord[]): TaskGroup[] {
const grouped = new Map<string, ExecutionTaskRecord[]>()
for (const task of tasks) {
const label = getGroupLabel(task.startedAt)
const existing = grouped.get(label) ?? []
grouped.set(label, [...existing, task])
}
return Array.from(grouped.entries()).map(([label, groupItems]) => ({
label,
tasks: groupItems,
}))
}
export const AdminDashboardPage: FC = () => {
const [pendingCount, setPendingCount] = useState(0)
const historyByConversation = useExecutionHistoryByConversation()
const [taskToDelete, setTaskToDelete] = useState<ExecutionTaskRecord | null>(
null,
)
useEffect(() => {
pendingToolApprovalsStorage
.getValue()
.then((v) => setPendingCount(v.length))
const unwatch = pendingToolApprovalsStorage.watch((v) =>
setPendingCount(v.length),
)
return () => unwatch()
}, [])
const historyList = useMemo(
() => Object.values(historyByConversation),
[historyByConversation],
)
const tasks = useMemo(() => {
return historyList
.flatMap((history) => history.tasks)
.sort(
(left, right) =>
new Date(right.startedAt).getTime() -
new Date(left.startedAt).getTime(),
)
}, [historyList])
const groupedTasks = useMemo(() => groupTasks(tasks), [tasks])
const runningCount = useMemo(
() => tasks.filter((task) => task.status === 'running').length,
[tasks],
)
const conversationCount = historyList.length
const handleDeleteTask = async () => {
if (!taskToDelete) return
try {
await removeConversationExecutionTask({
conversationId: taskToDelete.conversationId,
taskId: taskToDelete.id,
})
toast.success('Run removed')
} catch {
toast.error('Failed to remove run')
} finally {
setTaskToDelete(null)
}
}
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<AdminDashboardHeader
pendingCount={pendingCount}
runningCount={runningCount}
/>
<section className="space-y-3">
<h3 className="font-semibold text-sm">Approvals</h3>
<PendingApprovals />
</section>
<section className="space-y-4">
<div>
<h3 className="font-semibold text-sm">Audit Trail</h3>
{tasks.length > 0 && (
<p className="mt-1 text-muted-foreground text-sm">
{tasks.length} recorded run{tasks.length === 1 ? '' : 's'}
{conversationCount > 1
? ` across ${conversationCount} chats`
: ''}
. Newest first.
</p>
)}
</div>
{tasks.length === 0 ? (
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
<Shield className="size-5 text-[var(--accent-orange)]" />
</div>
<h3 className="mb-1 font-medium text-lg">No agent runs yet</h3>
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
Run a task in BrowserOS and the execution history will appear
here.
</p>
</div>
) : (
<div className="space-y-6">
{groupedTasks.map((group, groupIndex) => (
<section key={group.label} className="space-y-3">
<div className="flex items-center gap-3">
<h4 className="font-medium text-muted-foreground text-xs">
{group.label}
</h4>
<div className="h-px flex-1 bg-border/60" />
<span className="text-muted-foreground text-xs">
{group.tasks.length} run
{group.tasks.length === 1 ? '' : 's'}
</span>
</div>
<div className="space-y-3">
{group.tasks.map((task, index) => (
<ExecutionTaskCard
key={task.id}
task={task}
defaultOpen={
task.status === 'running' ||
(groupIndex === 0 && index === 0)
}
onDelete={setTaskToDelete}
/>
))}
</div>
</section>
))}
</div>
)}
</section>
<AlertDialog
open={taskToDelete !== null}
onOpenChange={(open) => !open && setTaskToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Run</AlertDialogTitle>
<AlertDialogDescription>
Remove "{taskToDelete?.promptText}" from local history? This only
clears the recorded run on this device.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteTask}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,103 +0,0 @@
import { Clock, ShieldCheck, ShieldX } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
type ApprovalResponse,
approvalResponsesStorage,
type PendingApproval,
pendingToolApprovalsStorage,
queueApprovalResponse,
} from '@/lib/tool-approvals/approval-sync-storage'
const formatToolName = (name: string) =>
name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, (s) => s.toUpperCase())
export const PendingApprovals: FC = () => {
const [pending, setPending] = useState<PendingApproval[]>([])
useEffect(() => {
pendingToolApprovalsStorage.getValue().then(setPending)
const unwatch = pendingToolApprovalsStorage.watch(setPending)
return () => unwatch()
}, [])
const respond = async (approvalId: string, approved: boolean) => {
const response: ApprovalResponse = {
approvalId,
approved,
timestamp: Date.now(),
}
const existing = (await approvalResponsesStorage.getValue()) ?? []
await approvalResponsesStorage.setValue(
queueApprovalResponse(existing, response),
)
}
if (pending.length === 0) {
return (
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
<ShieldCheck className="size-5 text-[var(--accent-orange)]" />
</div>
<h3 className="mb-1 font-medium text-lg">No pending approvals</h3>
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
When the agent needs permission to execute a tool, approval requests
will appear here.
</p>
</div>
)
}
return (
<div className="space-y-3">
{pending.map((item) => (
<div
key={item.approvalId}
className="flex items-start gap-4 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4"
>
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-yellow-500/10">
<Clock className="size-4 text-yellow-600" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{formatToolName(item.toolName)}
</span>
<Badge variant="outline" className="text-[10px]">
awaiting
</Badge>
</div>
{Object.keys(item.input).length > 0 && (
<pre className="mt-1 max-h-20 overflow-auto rounded bg-muted/50 p-2 font-mono text-muted-foreground text-xs">
{JSON.stringify(item.input, null, 2)}
</pre>
)}
<div className="mt-3 flex gap-2">
<Button
size="sm"
className="h-7 gap-1 px-3 text-xs"
onClick={() => respond(item.approvalId, true)}
>
<ShieldCheck className="size-3" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-3 text-xs"
onClick={() => respond(item.approvalId, false)}
>
<ShieldX className="size-3" />
Deny
</Button>
</div>
</div>
</div>
))}
</div>
)
}

View File

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

View File

@@ -1,71 +0,0 @@
import { Plus } from 'lucide-react'
import type { FC } from 'react'
import type { AgentCardData } from '@/lib/agent-conversations/types'
import { cn } from '@/lib/utils'
import { AgentCardCompact, AgentCardExpanded } from './AgentCard'
interface AgentCardDockProps {
agents: AgentCardData[]
activeAgentId?: string
onSelectAgent: (agentId: string) => void
onCreateAgent?: () => void
compact?: boolean
}
function CreateAgentButton({
compact,
onCreateAgent,
}: {
compact?: boolean
onCreateAgent: () => void
}) {
return (
<button
type="button"
onClick={onCreateAgent}
className={cn(
'flex shrink-0 items-center justify-center gap-2 border border-dashed text-muted-foreground transition-colors hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
compact
? 'rounded-full px-3 py-2 text-sm'
: 'min-h-32 rounded-2xl px-5 py-4',
)}
>
<Plus className={compact ? 'size-3.5' : 'size-5'} />
<span>{compact ? 'New' : 'Create agent'}</span>
</button>
)
}
export const AgentCardDock: FC<AgentCardDockProps> = ({
agents,
activeAgentId,
onSelectAgent,
onCreateAgent,
compact,
}) => {
if (agents.length === 0 && !onCreateAgent) return null
const Card = compact ? AgentCardCompact : AgentCardExpanded
return (
<div
className={cn(
compact
? 'flex items-center gap-2 overflow-x-auto pb-1'
: 'grid gap-4 md:grid-cols-3',
)}
>
{agents.map((agent) => (
<Card
key={agent.agentId}
agent={agent}
active={agent.agentId === activeAgentId}
onClick={() => onSelectAgent(agent.agentId)}
/>
))}
{onCreateAgent ? (
<CreateAgentButton compact={compact} onCreateAgent={onCreateAgent} />
) : null}
</div>
)
}

View File

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

View File

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

View File

@@ -1,132 +0,0 @@
import { Bot, Check, ChevronDown, Plus } from 'lucide-react'
import type { FC } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
type AgentEntry,
getModelDisplayName,
} from '@/entrypoints/app/agents/useOpenClaw'
import { cn } from '@/lib/utils'
interface AgentSelectorProps {
agents: AgentEntry[]
selectedAgentId: string | null
onSelectAgent: (agent: AgentEntry) => void
onCreateAgent?: () => void
status?: string
}
function getStatusDot(status?: string) {
if (status === 'running') return 'bg-emerald-500'
if (status === 'starting') return 'bg-amber-500 animate-pulse'
if (status === 'error') return 'bg-destructive'
return 'bg-muted-foreground/50'
}
export const AgentSelector: FC<AgentSelectorProps> = ({
agents,
selectedAgentId,
onSelectAgent,
onCreateAgent,
status,
}) => {
const [open, setOpen] = useState(false)
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Bot className="h-4 w-4" />
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
<span className="max-w-32 truncate">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-72 p-0">
<Command>
<CommandInput placeholder="Search agents..." className="h-9" />
<CommandList>
<CommandEmpty>No agents found</CommandEmpty>
<CommandGroup>
{agents.map((agent) => {
const isSelected = selectedAgentId === agent.agentId
const modelLabel = getModelDisplayName(agent.model)
return (
<CommandItem
key={agent.agentId}
value={`${agent.agentId} ${agent.name}`}
onSelect={() => {
onSelectAgent(agent)
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2',
isSelected && 'bg-[var(--accent-orange)]/10',
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]">
<Bot className="size-4" />
</div>
<div className="min-w-0 flex-1">
<span className="block truncate font-medium text-sm">
{agent.name}
</span>
{modelLabel ? (
<span className="block truncate text-muted-foreground text-xs">
{modelLabel}
</span>
) : null}
</div>
{isSelected ? (
<Check className="size-4 shrink-0 text-[var(--accent-orange)]" />
) : null}
</CommandItem>
)
})}
</CommandGroup>
{onCreateAgent ? (
<div className="border-border border-t p-1">
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
onCreateAgent()
setOpen(false)
}}
>
<Plus className="size-4" />
<span>Create agent</span>
</button>
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,371 +0,0 @@
import {
ArrowRight,
Bot,
ChevronDown,
Folder,
Layers,
Loader2,
Mic,
Square,
} from 'lucide-react'
import { type FC, type ReactNode, useEffect, useState } from 'react'
import { AppSelector } from '@/components/elements/AppSelector'
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
import { Button } from '@/components/ui/button'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import { cn } from '@/lib/utils'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { AgentSelector } from './AgentSelector'
interface ConversationInputProps {
agents: AgentEntry[]
selectedAgentId: string | null
onSelectAgent: (agent: AgentEntry) => void
onSend: (text: string) => void
onCreateAgent?: () => void
streaming: boolean
disabled?: boolean
status?: string
placeholder?: string
variant?: 'home' | 'conversation'
}
function InputActionButton({
disabled,
onClick,
streaming,
}: {
disabled: boolean
onClick: () => void
streaming: boolean
}) {
return (
<Button
onClick={onClick}
size="icon"
disabled={disabled}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
{streaming ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowRight className="h-5 w-5" />
)}
</Button>
)
}
function VoiceButton({
isRecording,
isTranscribing,
onStart,
onStop,
}: {
isRecording: boolean
isTranscribing: boolean
onStart: () => void
onStop: () => void
}) {
if (isRecording) {
return (
<Button
type="button"
size="icon"
onClick={onStop}
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>
)
}
if (isTranscribing) {
return (
<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>
)
}
return (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onStart}
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>
)
}
function ContextControls({
agents,
onCreateAgent,
onSelectAgent,
selectedAgentId,
selectedTabs,
onToggleTab,
showAgentSelector,
status,
}: {
agents: AgentEntry[]
onCreateAgent?: () => void
onSelectAgent: (agent: AgentEntry) => void
selectedAgentId: string | null
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
showAgentSelector: boolean
status?: string
}) {
const { supports } = useCapabilities()
const { selectedFolder } = useWorkspace()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
const connectedManagedServers = mcpServers.filter((server) => {
if (server.type !== 'managed' || !server.managedServerName) return false
return userMCPIntegrations?.integrations?.find(
(integration) => integration.name === server.managedServerName,
)?.is_authenticated
})
return (
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
<div className="flex items-center gap-1">
{showAgentSelector ? (
<AgentSelector
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={onSelectAgent}
onCreateAgent={onCreateAgent}
status={status}
/>
) : null}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
<WorkspaceSelector>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Folder className="h-4 w-4" />
<span>{selectedFolder?.name || 'Add workspace'}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</WorkspaceSelector>
) : null}
<TabPickerPopover
variant="selector"
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
>
<Button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
selectedTabs.length > 0
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Layers className="h-4 w-4" />
<span>Tabs</span>
</Button>
</TabPickerPopover>
</div>
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
<div className="ml-auto flex items-center gap-1.5">
<AppSelector side="bottom">
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<div className="flex items-center -space-x-1.5">
{connectedManagedServers.slice(0, 4).map((server) => (
<div
key={server.id}
className="rounded-full ring-2 ring-card"
>
<McpServerIcon
serverName={server.managedServerName ?? ''}
size={16}
/>
</div>
))}
</div>
{connectedManagedServers.length > 4 ? (
<span className="text-xs">
+{connectedManagedServers.length - 4}
</span>
) : null}
<span>Apps</span>
<ChevronDown className="h-3 w-3" />
</Button>
</AppSelector>
</div>
) : null}
</div>
)
}
function HomeShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
{children}
</div>
)
}
function ConversationShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
{children}
</div>
)
}
export const ConversationInput: FC<ConversationInputProps> = ({
agents,
selectedAgentId,
onSelectAgent,
onSend,
onCreateAgent,
streaming,
disabled,
status,
placeholder,
variant = 'conversation',
}) => {
const [input, setInput] = useState('')
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
const voice = useVoiceInput()
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setInput(voice.transcript)
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing, voice])
const toggleTab = (tab: chrome.tabs.Tab) => {
setSelectedTabs((prev) => {
const isSelected = prev.some((selected) => selected.id === tab.id)
if (isSelected) {
return prev.filter((selected) => selected.id !== tab.id)
}
return [...prev, tab]
})
}
const handleSend = () => {
const text = input.trim()
if (!text || streaming || disabled) return
onSend(text)
setInput('')
}
const shell = variant === 'home' ? HomeShell : ConversationShell
const Shell = shell
return (
<Shell>
<div className="flex items-center gap-3 px-5 py-4">
<BotInputIcon variant={variant} />
<input
type="text"
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleSend()
}
}}
placeholder={
voice.isTranscribing
? 'Transcribing...'
: (placeholder ?? `Message ${selectedAgent?.name ?? 'agent'}...`)
}
disabled={disabled || voice.isTranscribing}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
/>
<VoiceButton
isRecording={voice.isRecording}
isTranscribing={voice.isTranscribing}
onStart={() => {
void voice.startRecording()
}}
onStop={() => {
void voice.stopRecording()
}}
/>
<InputActionButton
disabled={
!input.trim() ||
streaming ||
!!disabled ||
voice.isRecording ||
voice.isTranscribing
}
onClick={handleSend}
streaming={streaming}
/>
</div>
{voice.error ? (
<div className="px-5 pb-2 text-destructive text-xs">{voice.error}</div>
) : null}
<ContextControls
agents={agents}
onCreateAgent={onCreateAgent}
onSelectAgent={onSelectAgent}
selectedAgentId={selectedAgentId}
selectedTabs={selectedTabs}
onToggleTab={toggleTab}
showAgentSelector={variant === 'home'}
status={status}
/>
</Shell>
)
}
function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
return (
<div
className={cn(
'flex items-center justify-center text-[var(--accent-orange)]',
variant === 'home'
? 'h-10 w-10 rounded-xl bg-[var(--accent-orange)]/10'
: 'h-9 w-9 rounded-xl bg-[var(--accent-orange)]/12',
)}
>
<Bot className="h-4 w-4" />
</div>
)
}

View File

@@ -1,105 +0,0 @@
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'
import type { FC } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
interface ConversationMessageProps {
turn: AgentConversationTurn
streaming: boolean
}
export const ConversationMessage: FC<ConversationMessageProps> = ({
turn,
streaming,
}) => (
<div className="space-y-3">
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">{tool.name}</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return <MessageResponse key={key}>{part.text}</MessageResponse>
default:
return null
}
})}
</MessageContent>
</Message>
)}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="size-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
)

View File

@@ -1,39 +0,0 @@
import type { FC } from 'react'
import { Outlet, useOutletContext } from 'react-router'
import {
type AgentEntry,
type OpenClawStatus,
useOpenClawAgents,
useOpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
interface AgentCommandContextValue {
agents: AgentEntry[]
agentsLoading: boolean
status: OpenClawStatus | null
statusLoading: boolean
}
export const AgentCommandLayout: FC = () => {
const { status, loading: statusLoading } = useOpenClawStatus(5000)
const { agents, loading: agentsLoading } = useOpenClawAgents(
status?.status === 'running' && status.controlPlaneStatus === 'connected',
)
return (
<Outlet
context={
{
agents,
agentsLoading,
status,
statusLoading,
} satisfies AgentCommandContextValue
}
/>
)
}
export function useAgentCommandData(): AgentCommandContextValue {
return useOutletContext<AgentCommandContextValue>()
}

View File

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

View File

@@ -1,256 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import {
chatWithAgent,
type OpenClawStreamEvent,
} from '@/entrypoints/app/agents/useOpenClaw'
import {
getLatestConversation,
saveConversation,
} from '@/lib/agent-conversations/storage'
import type {
AgentConversation,
AgentConversationTurn,
AssistantPart,
} from '@/lib/agent-conversations/types'
import { consumeSSEStream } from '@/lib/sse'
export function useAgentConversation(agentId: string, agentName: string) {
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
const [streaming, setStreaming] = useState(false)
const [loading, setLoading] = useState(true)
const sessionKeyRef = useRef('')
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const streamAbortRef = useRef<AbortController | null>(null)
useEffect(() => {
let active = true
getLatestConversation(agentId)
.then((conv) => {
if (!active) return
if (conv) {
setTurns(conv.turns)
sessionKeyRef.current = conv.sessionKey
} else {
sessionKeyRef.current = crypto.randomUUID()
}
setLoading(false)
})
.catch(() => {
if (active) {
sessionKeyRef.current = crypto.randomUUID()
setLoading(false)
}
})
return () => {
active = false
}
}, [agentId])
useEffect(() => {
return () => {
streamAbortRef.current?.abort()
}
}, [])
const persistTurns = (updatedTurns: AgentConversationTurn[]) => {
const conv: AgentConversation = {
agentId,
agentName,
sessionKey: sessionKeyRef.current,
turns: updatedTurns,
createdAt: updatedTurns[0]?.timestamp ?? Date.now(),
updatedAt: Date.now(),
}
saveConversation(conv).catch(() => {})
}
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running' as const,
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const toolStatus: 'completed' | 'error' =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId ? { ...t, status: toolStatus, durationMs } : t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
const updated = [...prev.slice(0, -1), { ...last, done: true }]
persistTurns(updated)
return updated
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
const send = async (text: string) => {
if (!text.trim() || streaming) return
const turn: AgentConversationTurn = {
id: crypto.randomUUID(),
userText: text.trim(),
parts: [],
done: false,
timestamp: Date.now(),
}
setTurns((prev) => [...prev, turn])
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text.trim(),
sessionKeyRef.current,
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
setStreaming(false)
}
}
const resetConversation = () => {
streamAbortRef.current?.abort()
streamAbortRef.current = null
setTurns([])
setStreaming(false)
sessionKeyRef.current = crypto.randomUUID()
}
return {
turns,
streaming,
loading,
sessionKey: sessionKeyRef.current,
send,
resetConversation,
}
}

View File

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

View File

@@ -1,280 +0,0 @@
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_TERMINAL_SHELL,
} from '@browseros/shared/constants/openclaw'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Terminal } from '@xterm/xterm'
import { ArrowLeft } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import '@xterm/xterm/css/xterm.css'
import { Button } from '@/components/ui/button'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface AgentTerminalProps {
onBack: () => void
}
type TerminalServerMessage =
| { type: 'output'; data: string }
| { type: 'exit'; exitCode: number }
| { type: 'error'; message: string }
const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
const TERMINAL_FONT_FAMILY =
'"Geist Mono", Menlo, Monaco, "Courier New", monospace'
function resolveCssColor(variableName: string): string {
const probe = document.createElement('div')
probe.style.position = 'fixed'
probe.style.visibility = 'hidden'
probe.style.pointerEvents = 'none'
probe.style.color = `var(${variableName})`
document.body.append(probe)
const color = window.getComputedStyle(probe).color
probe.remove()
return color
}
function withAlpha(color: string, alpha: number): string {
const channels = color.match(/[\d.]+/g)
if (!channels || channels.length < 3) return color
const [red, green, blue] = channels
return `rgb(${red} ${green} ${blue} / ${alpha})`
}
function createTerminalTheme() {
const isDark = document.documentElement.classList.contains('dark')
const background = resolveCssColor('--background')
const foreground = resolveCssColor('--foreground')
const muted = resolveCssColor('--muted-foreground')
const accent = resolveCssColor('--accent-orange')
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground: withAlpha(accent, isDark ? 0.3 : 0.2),
selectionForeground: foreground,
black: isDark ? '#16131a' : '#1f1b22',
red: isDark ? '#ef8c7c' : '#c25544',
green: isDark ? '#9ac67c' : '#5c8754',
yellow: isDark ? '#e5c07b' : '#b7791f',
blue: isDark ? '#8ba9ff' : '#4667d8',
magenta: isDark ? '#d2a8ff' : '#955ec7',
cyan: isDark ? '#7fd4d1' : '#0f766e',
white: isDark ? '#e8e0d9' : '#f7f1eb',
brightBlack: muted,
brightRed: isDark ? '#ffb0a4' : '#dc7b6d',
brightGreen: isDark ? '#b6d99e' : '#7bab74',
brightYellow: isDark ? '#f2d59b' : '#d49a44',
brightBlue: isDark ? '#b3c4ff' : '#6f8cf0',
brightMagenta: isDark ? '#e2c6ff' : '#b789dd',
brightCyan: isDark ? '#a6ece7' : '#3aa5a0',
brightWhite: isDark ? '#fff8f1' : '#ffffff',
}
}
function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
if (typeof data !== 'string') return null
let parsed: unknown
try {
parsed = JSON.parse(data) as unknown
} catch {
return null
}
if (
parsed &&
typeof parsed === 'object' &&
'type' in parsed &&
parsed.type === 'output' &&
'data' in parsed &&
typeof parsed.data === 'string'
) {
return { type: 'output', data: parsed.data }
}
if (
parsed &&
typeof parsed === 'object' &&
'type' in parsed &&
parsed.type === 'error' &&
'message' in parsed &&
typeof parsed.message === 'string'
) {
return { type: 'error', message: parsed.message }
}
if (
parsed &&
typeof parsed === 'object' &&
'type' in parsed &&
parsed.type === 'exit' &&
'exitCode' in parsed &&
typeof parsed.exitCode === 'number'
) {
return { type: 'exit', exitCode: parsed.exitCode }
}
return null
}
export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const terminal = new Terminal({
fontSize: 14,
fontFamily: TERMINAL_FONT_FAMILY,
cursorBlink: true,
cursorStyle: 'block',
lineHeight: 1.25,
scrollback: 8000,
theme: createTerminalTheme(),
})
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
terminal.open(containerRef.current)
let ws: WebSocket | null = null
let sawExit = false
const applyTheme = (): void => {
terminal.options.theme = createTerminalTheme()
}
const sendMessage = (
message:
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number },
): void => {
if (ws?.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify(message))
}
const sendResize = (cols = terminal.cols, rows = terminal.rows): void => {
sendMessage({ type: 'resize', cols, rows })
}
const connect = async () => {
const baseUrl = await getAgentServerUrl()
const wsUrl = new URL('/terminal/ws', baseUrl)
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(wsUrl)
ws.onopen = () => {
fitAddon.fit()
terminal.focus()
sendResize()
}
ws.onmessage = (event) => {
const message = parseTerminalMessage(event.data)
if (!message) return
if (message.type === 'output') {
terminal.write(message.data)
} else if (message.type === 'error') {
terminal.write(`\r\n\x1b[31m${message.message}\x1b[0m\r\n`)
} else {
sawExit = true
terminal.write(
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
)
}
}
ws.onclose = () => {
if (sawExit) return
terminal.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n')
}
ws.onerror = () => {
terminal.write('\r\n\x1b[31m[connection error]\x1b[0m\r\n')
}
const inputDisposable = terminal.onData((data) => {
sendMessage({ type: 'input', data })
})
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
sendResize(cols, rows)
})
return () => {
inputDisposable.dispose()
resizeDisposable.dispose()
}
}
let disposeSocketBindings: (() => void) | undefined
void connect().then((disposeBindings) => {
disposeSocketBindings = disposeBindings
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
sendResize()
})
resizeObserver.observe(containerRef.current)
const themeObserver = new MutationObserver(() => {
applyTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
return () => {
resizeObserver.disconnect()
themeObserver.disconnect()
disposeSocketBindings?.()
ws?.close()
terminal.dispose()
}
}, [])
return (
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-border border-b px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">
Container Terminal
</div>
<div className="truncate text-muted-foreground text-sm">
OpenClaw shell in {TERMINAL_HOME_DIR}
</div>
</div>
</div>
</div>
<div className="min-h-0 flex-1 p-4 sm:p-6">
<div className="agent-terminal-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background">
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-2.5">
<div className="truncate font-mono text-muted-foreground text-xs">
{TERMINAL_HOME_DIR}
</div>
<div className="font-mono text-[11px] text-muted-foreground">
{OPENCLAW_TERMINAL_SHELL.split('/').pop()}
</div>
</div>
<div className="min-h-0 flex-1 px-4 py-4 sm:px-5 sm:py-5">
<div ref={containerRef} className="h-full w-full" />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,330 +0,0 @@
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
export interface AgentEntry {
agentId: string
name: string
workspace: string
model?: unknown
role?: {
roleSource: 'builtin' | 'custom'
roleId?: BrowserOSAgentRoleId
roleName: string
shortDescription: string
}
}
export interface RoleTemplateSummary {
id: BrowserOSAgentRoleId
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
defaultAgentName: string
boundaries: Array<{
key: string
label: string
description: string
defaultMode: 'allow' | 'ask' | 'block'
}>
}
export interface OpenClawStatus {
status: 'uninitialized' | 'starting' | 'running' | 'stopped' | 'error'
podmanAvailable: boolean
machineReady: boolean
port: number | null
agentCount: number
error: string | null
controlPlaneStatus:
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'recovering'
| 'failed'
lastGatewayError: string | null
lastRecoveryReason:
| 'transient_disconnect'
| 'signature_expired'
| 'pairing_required'
| 'token_mismatch'
| 'container_not_ready'
| 'unknown'
| null
}
export interface OpenClawAgentMutationInput {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}
export interface OpenClawSetupInput {
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}
export function getModelDisplayName(model: unknown): string | undefined {
if (typeof model === 'string') return model.split('/').pop()
return undefined
}
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
} as const
async function clawFetch<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(`${baseUrl}/claw${path}`, init)
if (!res.ok) {
let message = `Request failed with status ${res.status}`
try {
const body = (await res.json()) as { error?: string }
if (body.error) {
message = body.error
}
} catch {}
throw new Error(message)
}
return res.json() as Promise<T>
}
async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
return clawFetch<OpenClawStatus>(baseUrl, '/status')
}
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
return data.agents ?? []
}
async function fetchOpenClawRoles(
baseUrl: string,
): Promise<RoleTemplateSummary[]> {
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
baseUrl,
'/roles',
)
return data.roles ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.status] }),
queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.agents] }),
])
}
export function useOpenClawStatus(pollMs = 5000) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<OpenClawStatus, Error>({
queryKey: [OPENCLAW_QUERY_KEYS.status, baseUrl],
queryFn: () => fetchOpenClawStatus(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
refetchInterval: pollMs,
})
return {
status: query.data ?? null,
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawAgents(enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.agents, baseUrl],
queryFn: () => fetchOpenClawAgents(baseUrl as string),
enabled: !!baseUrl && !urlLoading && enabled,
})
return {
agents: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawRoles() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<RoleTemplateSummary[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
queryFn: () => fetchOpenClawRoles(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
roles: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
const ensureBaseUrl = () => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
return baseUrl
}
const onSuccess = () => invalidateOpenClawQueries(queryClient)
const setupMutation = useMutation({
mutationFn: async (input: OpenClawSetupInput) =>
clawFetch<{ status: string; agents: AgentEntry[] }>(
ensureBaseUrl(),
'/setup',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess,
})
const createMutation = useMutation({
mutationFn: async (input: OpenClawAgentMutationInput) =>
clawFetch<{ agent: AgentEntry }>(ensureBaseUrl(), '/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}),
onSuccess,
})
const deleteMutation = useMutation({
mutationFn: async (id: string) =>
clawFetch<{ success: boolean }>(ensureBaseUrl(), `/agents/${id}`, {
method: 'DELETE',
}),
onSuccess,
})
const startMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/start', {
method: 'POST',
}),
onSuccess,
})
const stopMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/stop', {
method: 'POST',
}),
onSuccess,
})
const restartMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/restart', {
method: 'POST',
}),
onSuccess,
})
const reconnectMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/reconnect', {
method: 'POST',
}),
onSuccess,
})
return {
setupOpenClaw: setupMutation.mutateAsync,
createAgent: createMutation.mutateAsync,
deleteAgent: deleteMutation.mutateAsync,
startOpenClaw: startMutation.mutateAsync,
stopOpenClaw: stopMutation.mutateAsync,
restartOpenClaw: restartMutation.mutateAsync,
reconnectOpenClaw: reconnectMutation.mutateAsync,
actionInProgress:
setupMutation.isPending ||
createMutation.isPending ||
deleteMutation.isPending ||
startMutation.isPending ||
stopMutation.isPending ||
restartMutation.isPending ||
reconnectMutation.isPending,
settingUp: setupMutation.isPending,
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
}
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}
export async function chatWithAgent(
agentId: string,
message: string,
sessionKey?: string,
signal?: AbortSignal,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, sessionKey }),
signal,
})
}

View File

@@ -8,7 +8,7 @@ import {
Loader2,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
import { Button } from '@/components/ui/button'
@@ -61,6 +61,7 @@ import {
KIMI_API_KEY_GUIDE_CLICKED_EVENT,
MODEL_SELECTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import {
getDefaultBaseUrlForProviders,
getProviderTemplate,
@@ -222,9 +223,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [modelPickerOpen, setModelPickerOpen] = useState(false)
const [modelSearch, setModelSearch] = useState('')
const modelListRef = useRef<HTMLDivElement>(null)
const { supports } = useCapabilities()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const kimiLaunch = useKimiLaunch()
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
if (opt.value === 'chatgpt-pro')
@@ -232,6 +233,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
if (opt.value === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
if (opt.value === 'moonshot')
return kimiLaunch || initialValues?.type === 'moonshot'
if (opt.value === 'openai-compatible') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
}
@@ -306,9 +309,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
? modelFuse.search(modelSearch).map((r) => r.item)
: modelInfoList
const showCustomEntry =
modelSearch && !filteredModels.some((m) => m.modelId === modelSearch)
// Handle provider type change (user-initiated via Select)
const handleTypeChange = (newType: ProviderType) => {
form.setValue('type', newType)
@@ -894,96 +894,59 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={(v) => {
setModelSearch(v)
requestAnimationFrame(() => {
modelListRef.current?.scrollTo(0, 0)
})
}}
onValueChange={setModelSearch}
onKeyDown={(e) => {
if (e.key === 'Enter' && modelSearch) {
if (
e.key === 'Enter' &&
modelSearch &&
filteredModels.length === 0
) {
e.preventDefault()
e.stopPropagation()
form.setValue('modelId', modelSearch)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: modelSearch,
is_custom_model: !modelInfoList.some(
(m) => m.modelId === modelSearch,
),
is_custom_model: true,
})
setModelPickerOpen(false)
setModelSearch('')
}
}}
/>
<CommandList ref={modelListRef}>
<CommandList>
<CommandEmpty>
No models found. Press Enter to use &quot;
{modelSearch}&quot;
</CommandEmpty>
{showCustomEntry && (
<CommandGroup forceMount>
<CommandGroup>
{filteredModels.map((model) => (
<CommandItem
forceMount
value={`custom:${modelSearch}`}
key={model.modelId}
value={model.modelId}
onSelect={() => {
form.setValue('modelId', modelSearch)
form.setValue('modelId', model.modelId)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: modelSearch,
is_custom_model: true,
model_id: model.modelId,
context_window: model.contextLength,
is_custom_model: false,
})
setModelPickerOpen(false)
setModelSearch('')
}}
>
<span className="flex-1 truncate">
{modelSearch}
{model.modelId}
</span>
{field.value === modelSearch && (
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(model.contextLength)}
</span>
{field.value === model.modelId && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</CommandItem>
</CommandGroup>
)}
{filteredModels.length > 0 && (
<CommandGroup>
{filteredModels.map((model) => (
<CommandItem
key={model.modelId}
value={model.modelId}
onSelect={() => {
form.setValue('modelId', model.modelId)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: model.modelId,
context_window: model.contextLength,
is_custom_model: !modelInfoList.some(
(m) => m.modelId === model.modelId,
),
})
setModelPickerOpen(false)
setModelSearch('')
}}
>
<span className="flex-1 truncate">
{model.modelId}
</span>
{model.contextLength > 0 && (
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(
model.contextLength,
)}
</span>
)}
{field.value === model.modelId && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
)}
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>

View File

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

View File

@@ -7,6 +7,7 @@ import {
} from '@/components/ui/collapsible'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import {
type ProviderTemplate,
providerTemplates,
@@ -22,6 +23,7 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
onUseTemplate,
}) => {
const { supports } = useCapabilities()
const kimiLaunch = useKimiLaunch()
const filteredTemplates = providerTemplates.filter((template) => {
if (template.id === 'chatgpt-pro')
@@ -29,6 +31,7 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
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)
}
@@ -64,6 +67,7 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
<ProviderTemplateCard
key={template.id}
template={template}
highlighted={template.id === 'moonshot'}
isNew={isNew}
onUseTemplate={onUseTemplate}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="rounded-lg border border-border border-dashed py-8 text-center">
<p className="text-muted-foreground text-sm">
No scheduled tasks yet. Create one to automate recurring tasks.
No scheduled tasks yet. Create one to automate recurring workflows.
</p>
</div>
</div>

View File

@@ -238,7 +238,7 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
<h3 className="mb-1 font-medium text-lg">No skills yet</h3>
<p className="mb-5 max-w-sm text-muted-foreground text-sm leading-6">
Skills teach your agent how to handle repeatable tasks like research,
extraction, and repeatable browser tasks.
extraction, and structured workflows.
</p>
<Button onClick={onCreateClick} size="sm">
<Plus className="mr-1.5 size-4" />

View File

@@ -1,120 +0,0 @@
import {
Bot,
Camera,
Code,
Database,
Eye,
Hand,
MousePointerClick,
Navigation,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import {
normalizeToolApprovalConfig,
toolApprovalConfigStorage,
} from '@/lib/tool-approvals/storage'
import {
TOOL_CATEGORIES,
type ToolApprovalConfig,
} from '@/lib/tool-approvals/types'
const CATEGORY_ICONS: Record<string, typeof Hand> = {
input: MousePointerClick,
navigation: Navigation,
observation: Eye,
screenshots: Camera,
scripts: Code,
'data-modification': Database,
assistant: Bot,
}
export const ToolApprovalsPage: FC = () => {
const [config, setConfig] = useState<ToolApprovalConfig>({ categories: {} })
useEffect(() => {
const applyConfig = (value: ToolApprovalConfig) =>
setConfig(normalizeToolApprovalConfig(value))
toolApprovalConfigStorage.getValue().then(applyConfig)
const unwatch = toolApprovalConfigStorage.watch(applyConfig)
return () => unwatch()
}, [])
const allEnabled =
TOOL_CATEGORIES.length > 0 &&
TOOL_CATEGORIES.every((category) => config.categories[category.id] === true)
const toggleCategory = (categoryId: string, enabled: boolean) => {
const next = {
...config,
categories: { ...config.categories, [categoryId]: enabled },
}
setConfig(next)
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
}
const toggleAll = (enabled: boolean) => {
const categories: Record<string, boolean> = {}
for (const cat of TOOL_CATEGORIES) {
categories[cat.id] = enabled
}
const next = { ...config, categories }
setConfig(next)
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
}
return (
<div className="space-y-6">
<div>
<h2 className="font-semibold text-xl tracking-tight">Tool Approvals</h2>
<p className="text-muted-foreground text-sm">
Require human approval before the agent executes certain actions.
Changes apply immediately.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="space-y-0.5">
<div className="font-medium text-sm">Require approval for all</div>
<div className="text-muted-foreground text-xs">
Toggle all categories at once
</div>
</div>
<Switch checked={allEnabled} onCheckedChange={toggleAll} />
</div>
<div className="space-y-3">
{TOOL_CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.id] ?? Hand
const enabled = config.categories[category.id] ?? false
return (
<div
key={category.id}
className="flex items-start gap-4 rounded-lg border bg-card p-4 transition-colors"
>
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{category.name}</span>
</div>
<p className="text-muted-foreground text-xs">
{category.description}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
toggleCategory(category.id, checked)
}
/>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import type { UIMessage } from 'ai'
import { Loader2, RotateCcw, Square, X } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface RunWorkflowDialogProps {
open: boolean
workflowName: string
messages: UIMessage[]
status: 'streaming' | 'submitted' | 'ready' | 'error'
wasCancelled: boolean
error: Error | undefined
onStop: () => void
onRetry: () => void
onClose: () => void
}
export const RunWorkflowDialog: FC<RunWorkflowDialogProps> = ({
open,
workflowName,
messages,
status,
wasCancelled,
error,
onStop,
onRetry,
onClose,
}) => {
const isProcessing = status === 'streaming' || status === 'submitted'
const _isComplete = !isProcessing
const getStatusText = () => {
if (status === 'submitted') return 'Starting workflow...'
if (status === 'streaming') return 'Running...'
if (wasCancelled) return 'Execution cancelled'
if (status === 'error') return 'Error occurred'
return 'Completed'
}
const getMessageContent = (message: UIMessage) => {
return message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
}
const assistantMessages = messages.filter((m) => m.role === 'assistant')
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="max-h-[80vh] max-w-2xl overflow-hidden [&>button]:hidden"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader className="flex-row items-center justify-between space-y-0">
<DialogTitle className="flex items-center gap-2">
{isProcessing && (
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-orange)]" />
)}
Running: {workflowName}
</DialogTitle>
<div className="flex items-center gap-2">
{isProcessing ? (
<Button variant="destructive" size="sm" onClick={onStop}>
<Square className="mr-1.5 h-3 w-3" />
Stop
</Button>
) : (
<>
<Button variant="secondary" size="sm" onClick={onRetry}>
<RotateCcw className="mr-1.5 h-3 w-3" />
Retry
</Button>
<Button variant="outline" size="sm" onClick={onClose}>
<X className="mr-1.5 h-3 w-3" />
Close
</Button>
</>
)}
</div>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="text-muted-foreground text-sm">{getStatusText()}</div>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
<div className="font-medium">Error Details</div>
<div className="mt-1 whitespace-pre-wrap font-mono text-xs">
{error.message}
</div>
</div>
)}
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border bg-muted/30 p-4">
{assistantMessages.length === 0 ? (
<div className="text-muted-foreground text-sm">
{isProcessing
? 'Waiting for response...'
: 'No output available.'}
</div>
) : (
<div className="space-y-4">
{assistantMessages.map((message) => (
<div key={message.id} className="whitespace-pre-wrap text-sm">
{getMessageContent(message)}
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,51 @@
import { Pencil, Play, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
import { Button } from '@/components/ui/button'
import type { Workflow } from '@/lib/workflows/workflowStorage'
interface WorkflowCardProps {
workflow: Workflow
onDelete: () => void
onRun: () => void
}
export const WorkflowCard: FC<WorkflowCardProps> = ({
workflow,
onDelete,
onRun,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:border-[var(--accent-orange)]/50 hover:shadow-sm">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<span className="truncate font-semibold">
{workflow.workflowName}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={onRun}>
<Play className="mr-1.5 h-3 w-3" />
Run
</Button>
<Button asChild variant="outline" size="sm">
<NavLink to={`/workflows/create-graph?workflowId=${workflow.id}`}>
<Pencil className="mr-1.5 h-3 w-3" />
Edit
</NavLink>
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${workflow.workflowName}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { HelpCircle, Plus, Workflow } from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { workflowsHelpUrl } from '@/lib/constants/productUrls'
export const WorkflowsHeader: FC = () => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Workflow className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<h2 className="font-semibold text-xl">Workflows</h2>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<a
href={workflowsHelpUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</a>
</TooltipTrigger>
<TooltipContent>Learn more about workflows</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-muted-foreground text-sm">
Create and manage browser automation workflows
</p>
</div>
<Button
asChild
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
variant="outline"
>
<NavLink to="/workflows/create-graph">
<Plus className="mr-1.5 h-4 w-4" />
New Workflow
</NavLink>
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import type { FC } from 'react'
import type { Workflow } from '@/lib/workflows/workflowStorage'
import { WorkflowCard } from './WorkflowCard'
interface WorkflowsListProps {
workflows: Workflow[]
onDelete: (workflowId: string) => void
onRun: (workflowId: string) => void
}
export const WorkflowsList: FC<WorkflowsListProps> = ({
workflows,
onDelete,
onRun,
}) => {
if (workflows.length === 0) {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="rounded-lg border border-border border-dashed py-8 text-center">
<p className="text-muted-foreground text-sm">
No workflows yet. Create one to automate browser tasks.
</p>
</div>
</div>
)
}
return (
<div className="space-y-3">
{workflows.map((workflow) => (
<WorkflowCard
key={workflow.id}
workflow={workflow}
onDelete={() => onDelete(workflow.id)}
onRun={() => onRun(workflow.id)}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,127 @@
import { type FC, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
WORKFLOW_DELETED_EVENT,
WORKFLOW_RUN_STARTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
import { sentry } from '@/lib/sentry/sentry'
import { useWorkflows } from '@/lib/workflows/workflowStorage'
import { RunWorkflowDialog } from './RunWorkflowDialog'
import { useRunWorkflow } from './useRunWorkflow'
import { WorkflowsHeader } from './WorkflowsHeader'
import { WorkflowsList } from './WorkflowsList'
export const WorkflowsPage: FC = () => {
const { workflows, removeWorkflow } = useWorkflows()
const rpcClient = useRpcClient()
const [deleteWorkflowId, setDeleteWorkflowId] = useState<string | null>(null)
const {
isRunning,
runningWorkflowName,
messages,
status,
wasCancelled,
error,
runWorkflow,
stopRun,
retry,
closeDialog,
} = useRunWorkflow()
const handleDelete = (workflowId: string) => {
setDeleteWorkflowId(workflowId)
}
const confirmDelete = async () => {
if (!deleteWorkflowId) return
const workflow = workflows.find((w) => w.id === deleteWorkflowId)
if (!workflow) return
try {
await rpcClient.graph[':id'].$delete({ param: { id: workflow.codeId } })
} catch (error) {
sentry.captureException(error, {
extra: {
message: 'Failed to delete graph from server',
codeId: workflow.codeId,
workflowId: deleteWorkflowId,
},
})
}
await removeWorkflow(deleteWorkflowId)
setDeleteWorkflowId(null)
track(WORKFLOW_DELETED_EVENT)
}
const handleRun = (workflowId: string) => {
const workflow = workflows.find((w) => w.id === workflowId)
if (workflow) {
track(WORKFLOW_RUN_STARTED_EVENT)
runWorkflow(workflow.codeId, workflow.workflowName)
}
}
const workflowToDelete = deleteWorkflowId
? workflows.find((w) => w.id === deleteWorkflowId)
: null
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<WorkflowsHeader />
<WorkflowsList
workflows={workflows}
onDelete={handleDelete}
onRun={handleRun}
/>
<AlertDialog
open={deleteWorkflowId !== null}
onOpenChange={(open) => !open && setDeleteWorkflowId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
<AlertDialogDescription>
Delete "{workflowToDelete?.workflowName}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<RunWorkflowDialog
open={isRunning}
workflowName={runningWorkflowName}
messages={messages}
status={status}
wasCancelled={wasCancelled}
error={error}
onStop={stopRun}
onRetry={retry}
onClose={closeDialog}
/>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { type FC, Suspense } from 'react'
import { WorkflowsPage } from './WorkflowsPage'
export const WorkflowsPageWrapper: FC = () => {
return (
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
<WorkflowsPage />
</Suspense>
)
}

View File

@@ -0,0 +1,167 @@
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { compact } from 'es-toolkit/array'
import { useEffect, useRef, useState } from 'react'
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import {
WORKFLOW_RUN_COMPLETED_EVENT,
WORKFLOW_RUN_RETRIED_EVENT,
WORKFLOW_RUN_STOPPED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
type WorkflowMessageMetadata = {
window?: chrome.windows.Window
}
export const useRunWorkflow = () => {
const [isRunning, setIsRunning] = useState(false)
const [runningWorkflowName, setRunningWorkflowName] = useState<string>('')
const [wasCancelled, setWasCancelled] = useState(false)
const codeIdRef = useRef<string | undefined>(undefined)
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const {
selectedLlmProviderRef,
enabledMcpServersRef,
enabledCustomServersRef,
personalizationRef,
} = useChatRefs()
const agentUrlRef = useRef(agentServerUrl)
useEffect(() => {
agentUrlRef.current = agentServerUrl
}, [agentServerUrl])
const { sendMessage, stop, status, messages, setMessages, error } = useChat({
transport: new DefaultChatTransport({
prepareSendMessagesRequest: async ({ messages }) => {
const lastMessage = messages[messages.length - 1]
const metadata = lastMessage.metadata as
| WorkflowMessageMetadata
| undefined
const provider = selectedLlmProviderRef.current
const enabledMcpServers = enabledMcpServersRef.current
const customMcpServers = enabledCustomServersRef.current
return {
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
body: {
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
model: provider?.modelId ?? 'browseros',
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
resourceName: provider?.resourceName,
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
browserContext: {
windowId: metadata?.window?.id,
activeTab: metadata?.window?.tabs?.[0],
enabledMcpServers: compact(enabledMcpServers),
customMcpServers,
},
userSystemPrompt: personalizationRef.current,
supportsImages: provider?.supportsImages,
},
}
},
}),
})
const previousStatus = useRef(status)
useEffect(() => {
const wasProcessing =
previousStatus.current === 'streaming' ||
previousStatus.current === 'submitted'
const justFinished =
wasProcessing && (status === 'ready' || status === 'error')
if (justFinished && isRunning) {
track(WORKFLOW_RUN_COMPLETED_EVENT, {
status: wasCancelled
? 'cancelled'
: status === 'error'
? 'failed'
: 'completed',
})
}
previousStatus.current = status
}, [status, isRunning, wasCancelled])
const startWorkflowRun = async () => {
setMessages([])
setWasCancelled(false)
let backgroundWindow: chrome.windows.Window | undefined
try {
backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
} catch {
// Fallback when no window context is available (e.g. all windows closed)
const tab = await chrome.tabs.create({
url: 'chrome://newtab',
active: true,
})
if (tab.windowId) {
backgroundWindow = await chrome.windows.get(tab.windowId)
}
}
sendMessage({
text: 'Run the workflow.',
metadata: {
window: backgroundWindow,
},
})
}
const runWorkflow = async (codeId: string, workflowName: string) => {
codeIdRef.current = codeId
setRunningWorkflowName(workflowName)
setIsRunning(true)
await startWorkflowRun()
}
const stopRun = () => {
track(WORKFLOW_RUN_STOPPED_EVENT)
setWasCancelled(true)
stop()
}
const retry = async () => {
track(WORKFLOW_RUN_RETRIED_EVENT)
await startWorkflowRun()
}
const closeDialog = () => {
setIsRunning(false)
setRunningWorkflowName('')
setWasCancelled(false)
setMessages([])
}
return {
isRunning,
runningWorkflowName,
messages,
status,
wasCancelled,
error,
runWorkflow,
stopRun,
retry,
closeDialog,
}
}

View File

@@ -45,7 +45,7 @@ export const TIPS: Tip[] = [
},
{
id: 'mcp-servers',
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to power multi-service automations.',
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to build multi-service workflows.',
},
{
id: 'skills',
@@ -75,6 +75,10 @@ export const TIPS: Tip[] = [
id: 'at-mention-tabs',
text: 'Type @ in the search bar to mention and attach open tabs as context for your AI queries.',
},
{
id: 'workflows',
text: 'For complex repeatable tasks, build visual Workflows instead of one-off prompts for consistent results.',
},
{
id: 'mode-selection',
text: 'Use Chat mode for read-only operations like questions and summaries, and Agent mode for multi-step browser tasks.',

View File

@@ -2,20 +2,21 @@ import type { FC } from 'react'
import { Outlet, useLocation } from 'react-router'
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { NewTabFocusGrid } from './NewTabFocusGrid'
import { shouldHideFocusGrid, shouldUseChatSession } from './route-utils'
const HIDE_FOCUS_GRID_PATHS = new Set([
'/home/soul',
'/home/memory',
'/home/skills',
'/home/chat',
])
export const NewTabLayout: FC = () => {
const location = useLocation()
const hideGrid = shouldHideFocusGrid(location.pathname)
const useChatSession = shouldUseChatSession(location.pathname)
const content = (
<>
{!hideGrid && <NewTabFocusGrid />}
return (
<ChatSessionProvider origin="newtab">
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
<Outlet />
</>
</ChatSessionProvider>
)
if (!useChatSession) return content
return <ChatSessionProvider origin="newtab">{content}</ChatSessionProvider>
}

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
isAgentCommandPath,
isAgentConversationPath,
shouldHideFocusGrid,
shouldUseChatSession,
} from './route-utils'
describe('route-utils', () => {
it('treats command center routes as non-chat-session paths', () => {
expect(isAgentCommandPath('/home')).toBe(true)
expect(isAgentCommandPath('/home/agents/main')).toBe(true)
expect(isAgentConversationPath('/home')).toBe(false)
expect(isAgentConversationPath('/home/agents/main')).toBe(true)
expect(shouldUseChatSession('/home')).toBe(false)
expect(shouldUseChatSession('/home/agents/main')).toBe(false)
expect(shouldUseChatSession('/home/chat')).toBe(true)
})
it('keeps the focus grid on home while hiding it on dedicated full-screen routes', () => {
expect(shouldHideFocusGrid('/home')).toBe(false)
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
expect(shouldHideFocusGrid('/home/personalize')).toBe(false)
})
})

View File

@@ -1,24 +0,0 @@
const HIDE_FOCUS_GRID_PATHS = new Set([
'/home/soul',
'/home/memory',
'/home/skills',
'/home/chat',
])
export function isAgentCommandPath(pathname: string): boolean {
return pathname === '/home' || isAgentConversationPath(pathname)
}
export function isAgentConversationPath(pathname: string): boolean {
return pathname.startsWith('/home/agents/')
}
export function shouldHideFocusGrid(pathname: string): boolean {
return (
HIDE_FOCUS_GRID_PATHS.has(pathname) || isAgentConversationPath(pathname)
)
}
export function shouldUseChatSession(pathname: string): boolean {
return pathname === '/home/chat'
}

View File

@@ -5,6 +5,7 @@ import {
Bot,
Code2,
FolderOpen,
GitBranch,
LinkIcon,
Plug,
SplitSquareHorizontal,
@@ -22,6 +23,7 @@ import {
COWORK_DEMO_URL,
MCP_SERVER_DEMO_URL,
SPLIT_VIEW_GIF_URL,
WORKFLOWS_DEMO_URL,
} from '@/lib/constants/mediaUrls'
import {
discordUrl,
@@ -42,7 +44,7 @@ const features: Feature[] = [
description:
'Describe any task and watch BrowserOS execute it—clicking, typing, and navigating for you.',
detailedDescription:
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step browser tasks automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step workflows automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
highlights: [
'Multi-tab execution — run agents in multiple tabs simultaneously',
'Smart navigation — automatically finds and interacts with page elements',
@@ -73,6 +75,24 @@ const features: Feature[] = [
gridClass: 'md:col-span-1',
videoUrl: MCP_SERVER_DEMO_URL,
},
{
id: 'workflows',
Icon: GitBranch,
tag: 'AUTOMATION',
title: 'Visual Workflows',
description:
'Build reliable, repeatable automations with a visual graph builder.',
detailedDescription:
'Workflows turn complex browser tasks into reliable, reusable automations. Instead of hoping the agent figures out the right steps each time, you define the exact sequence in a visual graph. Describe what you want in chat, and the workflow agent generates the graph. Add loops, conditionals, and parallel branches. Save workflows and run them on-demand whenever you need.',
highlights: [
'Chat-to-graph — describe your automation and get a visual workflow',
'Parallel execution — run multiple branches simultaneously',
'Loops & conditionals — handle complex logic with flow control',
'Save & reuse — run saved workflows on-demand, daily, or weekly',
],
gridClass: 'md:col-span-1',
videoUrl: WORKFLOWS_DEMO_URL || undefined,
},
{
id: 'cowork',
Icon: FolderOpen,

View File

@@ -43,7 +43,6 @@ export const Chat = () => {
disliked,
onClickDislike,
isRestoringConversation,
addToolApprovalResponse,
} = useChatSessionContext()
const {
@@ -223,12 +222,6 @@ export const Chat = () => {
showDontShowAgain={showDontShowAgain}
onTakeSurvey={onTakeSurvey}
onDismissJtbdPopup={onDismissJtbdPopup}
onToolApprove={(id) =>
addToolApprovalResponse({ id, approved: true })
}
onToolDeny={(id) =>
addToolApprovalResponse({ id, approved: false })
}
/>
)}
{agentUrlError && (

View File

@@ -1,18 +1,20 @@
import { AlertCircle, RefreshCw } from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
// import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
const SURVEY_DIRECTIONS = [
'competitor',
'switching',
'workflow',
'activation',
] as const
function pickRandomDirection(): string {
return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
}
// --- Commented out for Kimi partnership launch (restore after) ---
// const SURVEY_DIRECTIONS = [
// 'competitor',
// 'switching',
// 'workflow',
// 'activation',
// ] as const
//
// function pickRandomDirection(): string {
// return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
// }
// --- End commented out survey code ---
interface ChatErrorProps {
error: Error
@@ -93,11 +95,13 @@ export const ChatError: FC<ChatErrorProps> = ({
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
parseErrorMessage(error.message, providerType)
const surveyUrl = useMemo(
() =>
`/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
[],
)
// --- Commented out for Kimi partnership launch (restore after) ---
// const surveyUrl = useMemo(
// () =>
// `/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
// [],
// )
// --- End commented out survey code ---
const getTitle = () => {
if (isRateLimit) return 'Daily limit reached'
@@ -122,17 +126,8 @@ export const ChatError: FC<ChatErrorProps> = ({
View troubleshooting guide
</a>
)}
{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 && (
{/* --- Commented out for Kimi partnership launch (restore after) ---
{isRateLimit && (
<p className="text-muted-foreground text-xs">
<a
href={url}
@@ -153,6 +148,27 @@ export const ChatError: FC<ChatErrorProps> = ({
</a>
</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 && providerType === 'browseros' && (
<a
href="/app.html#/settings/ai"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
>
Add your own provider for unlimited usage
</a>
)}
{onRetry && (
<Button
variant="outline"

View File

@@ -37,8 +37,6 @@ interface ChatMessagesProps {
showDontShowAgain: boolean
onTakeSurvey: (opts?: { dontShowAgain?: boolean }) => void
onDismissJtbdPopup: (dontShowAgain: boolean) => void
onToolApprove?: (approvalId: string) => void
onToolDeny?: (approvalId: string) => void
}
export const ChatMessages: FC<ChatMessagesProps> = ({
@@ -53,8 +51,6 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
showDontShowAgain,
onTakeSurvey,
onDismissJtbdPopup,
onToolApprove,
onToolDeny,
}) => {
const isStreaming = status === 'streaming' || status === 'submitted'
@@ -118,8 +114,6 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
isLastBatch={segment.key === lastToolBatchKey}
isLastMessage={isLastMessage}
isStreaming={isStreaming}
onApprove={onToolApprove}
onDeny={onToolDeny}
/>
)
case 'nudge':

View File

@@ -2,20 +2,16 @@ import {
BotIcon,
CheckCircle2,
CircleDashed,
Clock,
Loader2,
ShieldCheck,
ShieldX,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import type { FC } from 'react'
import {
Task,
TaskContent,
TaskItem,
TaskTrigger,
} from '@/components/ai-elements/task'
import { Button } from '@/components/ui/button'
import type {
ToolInvocationInfo,
ToolInvocationState,
@@ -26,8 +22,6 @@ interface ToolBatchProps {
isLastBatch: boolean
isLastMessage: boolean
isStreaming: boolean
onApprove?: (approvalId: string) => void
onDeny?: (approvalId: string) => void
}
export const ToolBatch: FC<ToolBatchProps> = ({
@@ -35,20 +29,12 @@ export const ToolBatch: FC<ToolBatchProps> = ({
isLastBatch,
isLastMessage,
isStreaming,
onApprove,
onDeny,
}) => {
const hasPendingApproval = tools.some((t) => t.state === 'approval-requested')
const shouldBeOpen =
(isLastMessage && isLastBatch && isStreaming) || hasPendingApproval
const shouldBeOpen = isLastMessage && isLastBatch && isStreaming
const [isOpen, setIsOpen] = useState(shouldBeOpen)
const [hasUserInteracted, setHasUserInteracted] = useState(false)
useEffect(() => {
if (hasPendingApproval) {
setIsOpen(true)
return
}
if (isLastMessage && !hasUserInteracted) {
if (isLastBatch) {
setIsOpen(isStreaming)
@@ -56,18 +42,9 @@ export const ToolBatch: FC<ToolBatchProps> = ({
setIsOpen(false)
}
}
}, [
isStreaming,
isLastMessage,
isLastBatch,
hasUserInteracted,
hasPendingApproval,
])
}, [isStreaming, isLastMessage, isLastBatch, hasUserInteracted])
const completedCount = tools.filter((t) => isToolCompleted(t.state)).length
const triggerTitle = hasPendingApproval
? 'Waiting for approval...'
: `${completedCount}/${tools.length} actions completed`
const onManualToggle = (newState: boolean) => {
setHasUserInteracted(true)
@@ -76,23 +53,16 @@ export const ToolBatch: FC<ToolBatchProps> = ({
return (
<Task open={isOpen} onOpenChange={onManualToggle}>
<TaskTrigger title={triggerTitle} TriggerIcon={BotIcon} />
<TaskTrigger
title={`${completedCount}/${tools.length} actions completed`}
TriggerIcon={BotIcon}
/>
<TaskContent>
{tools.map((tool) => (
<div key={tool.toolCallId}>
<TaskItem className="flex items-center gap-2">
<ToolStatusIcon state={tool.state} />
<span className="flex-1">{formatToolName(tool.toolName)}</span>
</TaskItem>
{tool.state === 'approval-requested' &&
tool.approval?.id != null && (
<ApprovalButtons
approvalId={tool.approval.id}
onApprove={onApprove}
onDeny={onDeny}
/>
)}
</div>
<TaskItem key={tool.toolCallId} className="flex items-center gap-2">
<ToolStatusIcon state={tool.state} />
<span>{formatToolName(tool.toolName)}</span>
</TaskItem>
))}
</TaskContent>
</Task>
@@ -114,47 +84,10 @@ const isToolInProgress = (state: ToolInvocationState) =>
const isToolError = (state: ToolInvocationState) => state === 'output-error'
const isToolDenied = (state: ToolInvocationState) => state === 'output-denied'
const isToolApprovalPending = (state: ToolInvocationState) =>
state === 'approval-requested'
const ApprovalButtons: FC<{
approvalId: string
onApprove?: (id: string) => void
onDeny?: (id: string) => void
}> = ({ approvalId, onApprove, onDeny }) => (
<div className="mt-1 mb-2 ml-6 flex items-center gap-2">
<Button
size="sm"
className="h-7 gap-1 px-2.5 text-xs"
onClick={() => onApprove?.(approvalId)}
>
<ShieldCheck className="size-3" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2.5 text-xs"
onClick={() => onDeny?.(approvalId)}
>
<ShieldX className="size-3" />
Deny
</Button>
</div>
)
const ToolStatusIcon: FC<{ state: ToolInvocationState }> = ({ state }) => {
if (isToolCompleted(state)) {
return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
}
if (isToolApprovalPending(state)) {
return <Clock className="h-3.5 w-3.5 text-yellow-500" />
}
if (isToolDenied(state)) {
return <ShieldX className="h-3.5 w-3.5 text-red-400" />
}
if (isToolInProgress(state)) {
return (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--accent-orange)]" />

View File

@@ -8,9 +8,6 @@ export type ToolInvocationState =
| 'input-available'
| 'output-available'
| 'output-error'
| 'approval-requested'
| 'approval-responded'
| 'output-denied'
export interface ToolInvocationInfo {
state: ToolInvocationState
@@ -18,7 +15,6 @@ export interface ToolInvocationInfo {
toolName: string
input: Record<string, unknown>
output: unknown[]
approval?: { id: string; approved?: boolean; reason?: string }
}
export type NudgeType = 'schedule_suggestion' | 'app_connection'
@@ -110,7 +106,6 @@ export const getMessageSegments = (
state: ToolInvocationState
input: Record<string, unknown>
output: unknown
approval?: { id: string; approved?: boolean; reason?: string }
}
const toolName = toolPart.type?.replace('tool-', '')
@@ -132,7 +127,6 @@ export const getMessageSegments = (
toolName,
input: toolPart?.input ?? {},
output: (toolPart?.output as unknown[]) ?? [],
approval: toolPart?.approval,
})
}
}

View File

@@ -1,11 +1,10 @@
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { compact } from 'es-toolkit/array'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { aclRulesStorage } from '@/lib/acl/storage'
import { Capabilities, Feature } from '@/lib/browseros/capabilities'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type { ChatAction } from '@/lib/chat-actions/types'
@@ -27,59 +26,17 @@ import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import {
type ApprovalResponseData,
buildChatRequestBody,
type ChatRequestBrowserContext,
} from '@/lib/messaging/server/buildChatRequestBody'
import { track } from '@/lib/metrics/track'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
import {
type ApprovalResponse,
approvalResponsesStorage,
extractPendingApprovals,
pendingToolApprovalsStorage,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from '@/lib/tool-approvals/approval-sync-storage'
import {
normalizeToolApprovalConfig,
toolApprovalConfigStorage,
} from '@/lib/tool-approvals/storage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
import { useChatRefs } from './useChatRefs'
import { useExecutionHistoryTracker } from './useExecutionHistoryTracker'
import { useNotifyActiveTab } from './useNotifyActiveTab'
import { useRemoteConversationSave } from './useRemoteConversationSave'
const extractApprovalResponses = (
messages: UIMessage[],
): ApprovalResponseData[] | null => {
const lastMsg = messages[messages.length - 1]
if (lastMsg?.role !== 'assistant') return null
const approvals: ApprovalResponseData[] = []
for (const part of lastMsg.parts) {
const p = part as {
state?: string
approval?: { id: string; approved?: boolean; reason?: string }
}
if (p.state === 'approval-responded' && p.approval?.approved != null) {
approvals.push({
approvalId: p.approval.id,
approved: p.approval.approved,
reason: p.approval.reason,
})
}
}
return approvals.length > 0 ? approvals : null
}
const getLastMessageText = (messages: UIMessage[]) => {
const lastMessage = messages[messages.length - 1]
if (!lastMessage) return ''
@@ -89,15 +46,6 @@ const getLastMessageText = (messages: UIMessage[]) => {
.join('')
}
const getLastUserMessageText = (messages: UIMessage[]) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role === 'user') {
return getLastMessageText([messages[i]])
}
}
return ''
}
export const getResponseAndQueryFromMessageId = (
messages: UIMessage[],
messageId: string,
@@ -128,61 +76,6 @@ export interface ChatSessionOptions {
isIntegrationsSynced?: boolean
}
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
const getUserSystemPrompt = (
origin: ChatOrigin | undefined,
personalization: string,
) =>
origin === 'newtab'
? [personalization, NEWTAB_SYSTEM_PROMPT].filter(Boolean).join('\n\n')
: personalization
const buildRequestBrowserContext = ({
activeTab,
action,
enabledMcpServers,
customMcpServers,
}: {
activeTab?: chrome.tabs.Tab
action?: ChatAction
enabledMcpServers: Array<string | undefined>
customMcpServers: {
name: string
url?: string
}[]
}): ChatRequestBrowserContext | undefined => {
const browserContext: ChatRequestBrowserContext = {}
if (activeTab) {
browserContext.windowId = activeTab.windowId
browserContext.activeTab = {
id: activeTab.id,
url: activeTab.url,
title: activeTab.title,
}
}
if (action?.tabs?.length) {
browserContext.selectedTabs = action.tabs.map((tab) => ({
id: tab.id,
url: tab.url,
title: tab.title,
}))
}
const managedMcpServers = compact(enabledMcpServers)
if (managedMcpServers.length) {
browserContext.enabledMcpServers = managedMcpServers
}
if (customMcpServers.length) {
browserContext.customMcpServers = customMcpServers
}
return Object.keys(browserContext).length ? browserContext : undefined
}
export const useChatSession = (options?: ChatSessionOptions) => {
const {
selectedLlmProviderRef,
@@ -237,12 +130,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
conversationIdRef.current = conversationId
}, [conversationId])
const {
startTask: startExecutionTask,
syncFromMessages: syncExecutionHistory,
finishTask: finishExecutionTask,
} = useExecutionHistoryTracker()
const onClickLike = (messageId: string) => {
const { responseText, queryText } = getResponseAndQueryFromMessageId(
messages,
@@ -277,7 +164,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
}
const modeRef = useRef<ChatMode>(mode)
const approvalJustRespondedRef = useRef(false)
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
const workingDirRef = useRef<string | undefined>(undefined)
const selectionMapRef = useRef<
@@ -342,12 +228,10 @@ export const useChatSession = (options?: ChatSessionOptions) => {
status,
stop,
error: chatError,
addToolApprovalResponse,
} = useChat({
transport: new DefaultChatTransport({
// Important: this chat logic is also used in apps/agent/lib/schedules/getChatServerResponse.ts for scheduled jobs. Make sure to keep them in sync for any future changes.
prepareSendMessagesRequest: async ({ messages }) => {
const provider =
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
const activeTabsList = await chrome.tabs.query({
active: true,
currentWindow: true,
@@ -356,24 +240,67 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const activeTabSelection = activeTab?.id
? (selectionMapRef.current[String(activeTab.id)] ?? null)
: null
const message = getLastMessageText(messages)
const provider =
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
const currentMode = modeRef.current
const enabledMcpServers = enabledMcpServersRef.current
const customMcpServers = enabledCustomServersRef.current
const lastUserMessage = getLastUserMessageText(messages)
const action = textToActionRef.current.get(lastUserMessage)
const requestBrowserContext = buildRequestBrowserContext({
activeTab,
action,
enabledMcpServers,
customMcpServers,
})
const getActionForMessage = (messageText: string) => {
return textToActionRef.current.get(messageText)
}
const action = getActionForMessage(message)
const browserContext: {
windowId?: number
activeTab?: {
id?: number
url?: string
title?: string
}
selectedTabs?: {
id?: number
url?: string
title?: string
}[]
enabledMcpServers?: string[]
customMcpServers?: {
name: string
url: string
}[]
} = {}
if (activeTab) {
browserContext.windowId = activeTab.windowId
browserContext.activeTab = {
id: activeTab.id,
url: activeTab.url,
title: activeTab.title,
}
}
if (action?.tabs?.length) {
browserContext.selectedTabs = action?.tabs?.map((tab) => ({
id: tab.id,
url: tab.url,
title: tab.title,
}))
}
if (enabledMcpServers.length) {
browserContext.enabledMcpServers = compact(enabledMcpServers)
}
if (customMcpServers.length) {
browserContext.customMcpServers = customMcpServers as {
name: string
url: string
}[]
}
const declinedApps = await declinedAppsStorage.getValue()
const allAclRules = await aclRulesStorage.getValue()
const enabledAclRules = allAclRules.filter((r) => r.enabled)
const approvalConfig = normalizeToolApprovalConfig(
await toolApprovalConfigStorage.getValue(),
)
const supportsArrayConversation = await Capabilities.supports(
Feature.PREVIOUS_CONVERSATION_ARRAY,
@@ -390,46 +317,37 @@ export const useChatSession = (options?: ChatSessionOptions) => {
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
: undefined
const userSystemPrompt = getUserSystemPrompt(
options?.origin,
personalizationRef.current,
)
const approvalResponses = extractApprovalResponses(messages)
if (approvalResponses) {
return {
api: `${agentUrlRef.current}/chat`,
body: buildChatRequestBody({
conversationId: conversationIdRef.current,
provider,
mode: currentMode,
browserContext: requestBrowserContext,
userSystemPrompt,
userWorkingDir: workingDirRef.current,
previousConversation,
declinedApps,
aclRules: enabledAclRules,
toolApprovalConfig: approvalConfig,
toolApprovalResponses: approvalResponses,
}),
}
}
const message = getLastMessageText(messages)
const result = {
api: `${agentUrlRef.current}/chat`,
body: buildChatRequestBody({
body: {
message,
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
conversationId: conversationIdRef.current,
provider,
model: provider?.modelId ?? 'default',
mode: currentMode,
browserContext: requestBrowserContext,
userSystemPrompt,
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
// Azure-specific
resourceName: provider?.resourceName,
// Bedrock-specific
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
// ChatGPT Pro (Codex)
reasoningEffort: provider?.reasoningEffort,
reasoningSummary: provider?.reasoningSummary,
browserContext,
origin: options?.origin ?? 'sidepanel',
userSystemPrompt: personalizationRef.current,
userWorkingDir: workingDirRef.current,
supportsImages: provider?.supportsImages,
previousConversation,
declinedApps,
aclRules: enabledAclRules,
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
selectedText: activeTabSelection?.text,
selectedTextSource: activeTabSelection
? {
@@ -437,8 +355,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
title: activeTabSelection.title,
}
: undefined,
toolApprovalConfig: approvalConfig,
}),
},
}
// Track which tab's selection was sent so we can clear it on success
@@ -448,20 +365,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return result
},
}),
sendAutomaticallyWhen: () => {
if (approvalJustRespondedRef.current) {
approvalJustRespondedRef.current = false
return true
}
return false
},
onFinish: async ({ message, isAbort, isError }) => {
await finishExecutionTask({
responseText: getLastMessageText([message]),
isAbort,
isError,
})
},
})
// Remove messages with empty parts (e.g. interrupted assistant responses)
@@ -539,8 +442,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
// Keep messagesRef in sync on every change (cheap ref assignment)
useEffect(() => {
messagesRef.current = messages
syncExecutionHistory(messages, status)
}, [messages, status, syncExecutionHistory])
}, [messages])
// Save conversation only after streaming completes — not on every token
const previousStatusRef = useRef(status)
@@ -583,69 +485,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (chatError) invalidateCredits()
}, [chatError, invalidateCredits])
// Sync pending tool approvals to shared storage for the admin dashboard
useEffect(() => {
let isCancelled = false
const syncPendingApprovals = async () => {
const pending = extractPendingApprovals(
messages,
conversationIdRef.current,
)
const current = (await pendingToolApprovalsStorage.getValue()) ?? []
if (isCancelled) return
await pendingToolApprovalsStorage.setValue(
replacePendingApprovalsForConversation(
current,
conversationIdRef.current,
pending,
),
)
}
syncPendingApprovals()
return () => {
isCancelled = true
}
}, [messages])
// Watch for approval responses from the admin dashboard
// biome-ignore lint/correctness/useExhaustiveDependencies: only set up once
useEffect(() => {
const handleResponses = async (responses: ApprovalResponse[]) => {
if (!responses?.length) return
try {
for (const resp of responses) {
respondToToolApproval({
id: resp.approvalId,
approved: resp.approved,
reason: resp.reason,
})
}
const approvalIds = responses.map((resp) => resp.approvalId)
const currentResponses =
(await approvalResponsesStorage.getValue()) ?? []
const currentPending =
(await pendingToolApprovalsStorage.getValue()) ?? []
await approvalResponsesStorage.setValue(
removeApprovalResponsesById(currentResponses, approvalIds),
)
await pendingToolApprovalsStorage.setValue(
removePendingApprovalsById(currentPending, approvalIds),
)
} catch {
// Leave storage intact so the dashboard can retry
}
}
approvalResponsesStorage.getValue().then(handleResponses)
const unwatch = approvalResponsesStorage.watch(handleResponses)
return () => unwatch()
}, [])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{
@@ -653,17 +492,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
action?: ChatAction
} | null>(null)
const dispatchMessage = useCallback(
(text: string) => {
startExecutionTask({
conversationId: conversationIdRef.current,
promptText: text,
})
baseSendMessage({ text })
},
[baseSendMessage, startExecutionTask],
)
useEffect(() => {
isIntegrationsSyncedRef.current = isIntegrationsSynced
}, [isIntegrationsSynced])
@@ -681,9 +509,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return next
})
}
dispatchMessage(pending.text)
baseSendMessage({ text: pending.text })
}
}, [dispatchMessage, isIntegrationsSynced])
}, [isIntegrationsSynced, baseSendMessage])
const sendMessage = (params: { text: string; action?: ChatAction }) => {
track(MESSAGE_SENT_EVENT, {
@@ -706,7 +534,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return next
})
}
dispatchMessage(params.text)
baseSendMessage({ text: params.text })
}
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this once
@@ -732,15 +560,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return () => unwatch()
}, [])
const respondToToolApproval = (params: {
id: string
approved: boolean
reason?: string
}) => {
approvalJustRespondedRef.current = true
addToolApprovalResponse(params)
}
const handleSelectProvider = (provider: Provider) => {
const fullProvider = llmProviders.find((p) => p.id === provider.id)
track(PROVIDER_SELECTED_EVENT, {
@@ -763,7 +582,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const resetConversation = () => {
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
stop()
void finishExecutionTask({ isAbort: true })
setConversationId(crypto.randomUUID())
setMessages([])
setTextToAction(new Map())
@@ -798,6 +616,5 @@ export const useChatSession = (options?: ChatSessionOptions) => {
disliked,
onClickDislike,
conversationId,
addToolApprovalResponse: respondToToolApproval,
}
}

View File

@@ -1,164 +0,0 @@
import type { ChatStatus, UIMessage } from 'ai'
import { useCallback, useRef } from 'react'
import {
getResponsePreview,
normalizeExecutionSteps,
} from '@/lib/execution-history/normalize'
import { upsertConversationExecutionTask } from '@/lib/execution-history/storage'
import type {
ExecutionTaskRecord,
ExecutionTaskStatus,
} from '@/lib/execution-history/types'
import { sentry } from '@/lib/sentry/sentry'
interface StartExecutionTaskInput {
conversationId: string
promptText: string
}
interface FinishExecutionTaskInput {
responseText?: string
isAbort?: boolean
isError?: boolean
}
function createTask(input: StartExecutionTaskInput): ExecutionTaskRecord {
return {
id: crypto.randomUUID(),
conversationId: input.conversationId,
promptText: input.promptText,
startedAt: new Date().toISOString(),
status: 'running',
actionCount: 0,
approvalCount: 0,
deniedCount: 0,
errorCount: 0,
steps: [],
}
}
function getLastUserMessage(messages: UIMessage[]): UIMessage | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.role === 'user') {
return messages[index]
}
}
}
function getLastAssistantMessage(messages: UIMessage[]): UIMessage | undefined {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'assistant') {
return lastMessage
}
}
function getFinishedStatus(
input: FinishExecutionTaskInput,
): ExecutionTaskStatus {
if (input.isError) return 'failed'
if (input.isAbort) return 'stopped'
return 'completed'
}
export function useExecutionHistoryTracker() {
const activeTaskRef = useRef<ExecutionTaskRecord | null>(null)
const lastSavedHashRef = useRef('')
const writeQueueRef = useRef(Promise.resolve())
const persistTask = useCallback((task: ExecutionTaskRecord) => {
const taskHash = JSON.stringify(task)
if (taskHash === lastSavedHashRef.current) return
activeTaskRef.current = task
writeQueueRef.current = writeQueueRef.current
.then(async () => {
await upsertConversationExecutionTask(task)
lastSavedHashRef.current = taskHash
})
.catch((error) => {
sentry.captureException(error, {
extra: {
message: 'Failed to persist execution history task',
conversationId: task.conversationId,
taskId: task.id,
},
})
})
}, [])
const startTask = useCallback(
(input: StartExecutionTaskInput) => {
const task = createTask(input)
lastSavedHashRef.current = ''
persistTask(task)
return task.id
},
[persistTask],
)
const syncFromMessages = useCallback(
(messages: UIMessage[], _status: ChatStatus) => {
const activeTask = activeTaskRef.current
if (!activeTask) return
const promptMessage = getLastUserMessage(messages)
const assistantMessage = getLastAssistantMessage(messages)
const normalized = normalizeExecutionSteps({
assistantMessage,
previousSteps: activeTask.steps,
nowIso: new Date().toISOString(),
})
persistTask({
...activeTask,
promptMessageId: activeTask.promptMessageId ?? promptMessage?.id,
assistantMessageId:
normalized.assistantMessageId ?? activeTask.assistantMessageId,
responsePreview:
getResponsePreview(assistantMessage) || activeTask.responsePreview,
actionCount: normalized.actionCount,
approvalCount: normalized.approvalCount,
deniedCount: normalized.deniedCount,
errorCount: normalized.errorCount,
steps: normalized.steps,
})
},
[persistTask],
)
const finishTask = useCallback(
async (input: FinishExecutionTaskInput) => {
const activeTask = activeTaskRef.current
if (!activeTask) return
const responseText = input.responseText?.trim() || activeTask.responseText
const nextTask: ExecutionTaskRecord = {
...activeTask,
completedAt: new Date().toISOString(),
status: getFinishedStatus(input),
responseText,
responsePreview: responseText
? getResponsePreview({
parts: [{ type: 'text', text: responseText }],
} as Pick<UIMessage, 'parts'>)
: activeTask.responsePreview,
}
persistTask(nextTask)
activeTaskRef.current = null
},
[persistTask],
)
const clearActiveTask = useCallback(() => {
activeTaskRef.current = null
lastSavedHashRef.current = ''
}, [])
return {
startTask,
syncFromMessages,
finishTask,
clearActiveTask,
}
}

View File

@@ -1,7 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { storage } from '#imports'
export const aclRulesStorage = storage.defineItem<AclRule[]>(
'local:acl-rules',
{ fallback: [] },
)

View File

@@ -1,31 +0,0 @@
import { del, get, keys, set } from 'idb-keyval'
import type { AgentConversation } from './types'
const PREFIX = 'agent-conv:'
export async function saveConversation(conv: AgentConversation): Promise<void> {
await set(`${PREFIX}${conv.agentId}:${conv.sessionKey}`, conv)
}
export async function getLatestConversation(
agentId: string,
): Promise<AgentConversation | undefined> {
const allKeys = await keys()
const agentKeys = (allKeys as string[]).filter((k) =>
k.startsWith(`${PREFIX}${agentId}:`),
)
if (!agentKeys.length) return undefined
const conversations = await Promise.all(
agentKeys.map((k) => get<AgentConversation>(k)),
)
const valid = conversations.filter((c): c is AgentConversation => c != null)
return valid.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? undefined
}
export async function deleteConversation(
agentId: string,
sessionKey: string,
): Promise<void> {
await del(`${PREFIX}${agentId}:${sessionKey}`)
}

View File

@@ -1,53 +0,0 @@
export interface AssistantTextPart {
kind: 'text'
text: string
}
export interface AssistantThinkingPart {
kind: 'thinking'
text: string
done: boolean
}
export interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
export interface AssistantToolBatchPart {
kind: 'tool-batch'
tools: ToolEntry[]
}
export type AssistantPart =
| AssistantTextPart
| AssistantThinkingPart
| AssistantToolBatchPart
export interface AgentConversationTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
timestamp: number
}
export interface AgentConversation {
agentId: string
agentName: string
sessionKey: string
turns: AgentConversationTurn[]
createdAt: number
updatedAt: number
}
export interface AgentCardData {
agentId: string
name: string
model?: string
status: 'idle' | 'working' | 'error'
lastMessage?: string
lastMessageTimestamp?: number
}

View File

@@ -31,6 +31,8 @@ export enum Feature {
WORKSPACE_FOLDER_SUPPORT = 'WORKSPACE_FOLDER_SUPPORT',
// Proxy server support
PROXY_SUPPORT = 'PROXY_SUPPORT',
// Workflows feature
WORKFLOW_SUPPORT = 'WORKFLOW_SUPPORT',
// previousConversation as structured array (older servers only accept string)
PREVIOUS_CONVERSATION_ARRAY = 'PREVIOUS_CONVERSATION_ARRAY',
// Soul page: agent personality viewer and editor
@@ -71,6 +73,7 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.CUSTOMIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
[Feature.WORKSPACE_FOLDER_SUPPORT]: { minBrowserOSVersion: '0.36.4.0' },
[Feature.PROXY_SUPPORT]: { minBrowserOSVersion: '0.39.0.1' },
[Feature.WORKFLOW_SUPPORT]: { minServerVersion: '0.0.41' },
[Feature.PREVIOUS_CONVERSATION_ARRAY]: { minServerVersion: '0.0.64' },
[Feature.SOUL_SUPPORT]: { minServerVersion: '0.0.67' },
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },

View File

@@ -1,6 +1,19 @@
/** @public */
export const MESSAGE_LIKE_EVENT = 'ui.message.like'
export const GRAPH_MESSAGE_LIKE_EVENT = 'settings.graph.message.like'
export const GRAPH_MESSAGE_DISLIKE_EVENT = 'settings.graph.message.dislike'
/** @public */
export const NEW_GRAPH_CREATED_EVENT = 'settings.graph.created'
/** @public */
export const GRAPH_SAVED_EVENT = 'settings.graph.saved'
/** @public */
export const GRAPH_UPDATED_EVENT = 'settings.graph.updated'
/** @public */
export const MESSAGE_DISLIKE_EVENT = 'ui.message.dislike'
@@ -165,6 +178,21 @@ export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
/** @public */
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
/** @public */
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
/** @public */
export const WORKFLOW_RUN_STARTED_EVENT = 'settings.workflow.run_started'
/** @public */
export const WORKFLOW_RUN_STOPPED_EVENT = 'settings.workflow.run_stopped'
/** @public */
export const WORKFLOW_RUN_RETRIED_EVENT = 'settings.workflow.run_retried'
/** @public */
export const WORKFLOW_RUN_COMPLETED_EVENT = 'settings.workflow.run_completed'
/** @public */
export const SIDEPANEL_AI_TRIGGERED_EVENT = 'sidepanel.ai.triggered'
@@ -280,6 +308,14 @@ export const KIMI_API_KEY_CONFIGURED_EVENT = 'settings.kimi.api_key_configured'
export const KIMI_API_KEY_GUIDE_CLICKED_EVENT =
'settings.kimi.api_key_guide_clicked'
/** @public */
export const KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT =
'ui.rate_limit.kimi_docs_clicked'
/** @public */
export const KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT =
'ui.rate_limit.moonshot_platform_clicked'
/** @public */
export const SIDEPANEL_VOICE_RECORDING_STARTED_EVENT =
'sidepanel.voice.recording_started'

View File

@@ -49,6 +49,11 @@ export const productVideoUrl = 'https://youtu.be/J-lFhTP-7is'
*/
export const productRepositoryShortUrl = 'https://git.new/browseros'
/**
* @public
*/
export const workflowsHelpUrl = 'https://docs.browseros.com/features/workflows'
/**
* @public
*/

View File

@@ -2,7 +2,6 @@ import { storage } from '@wxt-dev/storage'
import type { UIMessage } from 'ai'
import { useEffect, useState } from 'react'
import { useSessionInfo } from '../auth/sessionStorage'
import { removeConversationExecutionHistory } from '../execution-history/storage'
import { uploadConversationsToGraphql } from './uploadConversationsToGraphql'
const MAX_CONVERSATIONS = 50
@@ -43,7 +42,6 @@ export function useConversations() {
const removeConversation = async (id: string) => {
const current = (await conversationStorage.getValue()) ?? []
await conversationStorage.setValue(current.filter((c) => c.id !== id))
await removeConversationExecutionHistory(id)
}
const saveConversation = async (id: string, messages: UIMessage[]) => {
@@ -70,16 +68,11 @@ export function useConversations() {
messages,
lastMessagedAt: Date.now(),
}
const nextConversations = [newConversation, ...current]
const removedConversations = nextConversations.slice(MAX_CONVERSATIONS)
await conversationStorage.setValue(
nextConversations.slice(0, MAX_CONVERSATIONS),
)
await Promise.all(
removedConversations.map((conversation) =>
removeConversationExecutionHistory(conversation.id),
),
)
let updated = [newConversation, ...current]
if (updated.length > MAX_CONVERSATIONS) {
updated = updated.slice(0, MAX_CONVERSATIONS)
}
await conversationStorage.setValue(updated)
}
}

View File

@@ -6,6 +6,7 @@ const EnvSchema = z.object({
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
VITE_PUBLIC_KIMI_LAUNCH: z.string().optional(),
PROD: z.boolean(),
})

View File

@@ -1,161 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { UIMessage } from 'ai'
import {
getMessageText,
getResponsePreview,
normalizeExecutionSteps,
} from './normalize'
function asMessagePart(
part: Record<string, unknown>,
): UIMessage['parts'][number] {
return part as unknown as UIMessage['parts'][number]
}
function createAssistantMessage(parts: UIMessage['parts']): UIMessage {
return {
id: 'assistant-1',
role: 'assistant',
parts,
} as UIMessage
}
describe('normalizeExecutionSteps', () => {
it('filters nudge tools from the execution history', () => {
const message = createAssistantMessage([
asMessagePart({ type: 'text', text: 'I checked that for you.' }),
asMessagePart({
type: 'tool-suggest_schedule',
toolCallId: 'nudge-1',
state: 'output-available',
input: { scheduleType: 'daily' },
output: { suggestedName: 'Morning briefing' },
}),
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'output-available',
input: { ref_id: 'page-1' },
output: { pageId: 1 },
}),
])
const normalized = normalizeExecutionSteps({
assistantMessage: message,
nowIso: '2026-03-26T10:00:00.000Z',
})
expect(normalized.assistantMessageId).toBe('assistant-1')
expect(normalized.actionCount).toBe(1)
expect(normalized.steps).toHaveLength(1)
expect(normalized.steps[0]).toMatchObject({
id: 'tool-1',
toolName: 'open',
state: 'output-available',
})
})
it('preserves the original start time when a tool step reaches a terminal state', () => {
const initialTimestamp = '2026-03-26T10:00:00.000Z'
const completedTimestamp = '2026-03-26T10:00:04.000Z'
const running = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'input-available',
input: { ref_id: 'page-1' },
}),
]),
nowIso: initialTimestamp,
})
const completed = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'output-available',
input: { ref_id: 'page-1' },
output: { title: 'Example Domain' },
}),
]),
previousSteps: running.steps,
nowIso: completedTimestamp,
})
expect(completed.steps[0]?.startedAt).toBe(initialTimestamp)
expect(completed.steps[0]?.completedAt).toBe(completedTimestamp)
})
it('uses a compact preview for completed tool output', () => {
const normalized = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'output-available',
input: { ref_id: 'page-1' },
output: {
content: [
{
type: 'text',
text: 'Navigated to https://amazon.com. Additional context: page snapshot follows.',
},
],
},
}),
]),
nowIso: '2026-03-26T10:00:00.000Z',
})
expect(normalized.steps[0]?.previewText).toBe('Completed successfully')
})
it('surfaces ACL blocks as a compact issue label', () => {
const normalized = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-click',
toolCallId: 'tool-1',
state: 'output-available',
input: { x: 10, y: 20 },
output: {
content: [
{
type: 'text',
text: "Action blocked by ACL rule: 'add to cart'. The element on this page is restricted.",
},
],
},
}),
]),
nowIso: '2026-03-26T10:00:00.000Z',
})
expect(normalized.steps[0]?.previewText).toBe('Blocked by ACL rule')
})
})
describe('execution history text helpers', () => {
it('joins text parts into a single response body', () => {
const text = getMessageText({
parts: [
asMessagePart({ type: 'text', text: 'First line' }),
asMessagePart({ type: 'text', text: 'Second line' }),
],
})
expect(text).toBe('First line\n\nSecond line')
})
it('truncates long response previews', () => {
const preview = getResponsePreview({
parts: [asMessagePart({ type: 'text', text: 'a'.repeat(220) })],
})
expect(preview).toHaveLength(180)
expect(preview.endsWith('...')).toBe(true)
})
})

View File

@@ -1,215 +0,0 @@
import type { DynamicToolUIPart, ToolUIPart, UIMessage } from 'ai'
import type {
ExecutionStepApproval,
ExecutionStepRecord,
ExecutionStepState,
} from './types'
const NUDGE_TOOL_NAMES = new Set(['suggest_schedule', 'suggest_app_connection'])
const TERMINAL_STEP_STATES = new Set<ExecutionStepState>([
'output-available',
'output-error',
'output-denied',
])
const MAX_PREVIEW_CHARS = 180
type ToolLikePart = ToolUIPart | DynamicToolUIPart
function truncateText(value: string): string {
if (value.length <= MAX_PREVIEW_CHARS) return value
return `${value.slice(0, MAX_PREVIEW_CHARS - 3)}...`
}
function stringifyValue(value: unknown): string {
if (typeof value === 'string') return value
if (value == null) return ''
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
function getNestedText(value: unknown, depth = 0): string | undefined {
if (depth > 5 || value == null) return undefined
if (typeof value === 'string') {
const text = normalizeText(value)
return text || undefined
}
if (Array.isArray(value)) {
for (const item of value) {
const text = getNestedText(item, depth + 1)
if (text) return text
}
return undefined
}
if (typeof value !== 'object') return undefined
const record = value as Record<string, unknown>
for (const key of ['text', 'message', 'reason', 'content']) {
const text = getNestedText(record[key], depth + 1)
if (text) return text
}
for (const nestedValue of Object.values(record)) {
const text = getNestedText(nestedValue, depth + 1)
if (text) return text
}
return undefined
}
function getCompactIssueLabel(value?: string): string | undefined {
if (!value) return undefined
if (value.includes('Action blocked by ACL rule')) {
return 'Blocked by ACL rule'
}
return undefined
}
function getToolName(part: ToolLikePart): string {
if (part.type === 'dynamic-tool') {
return part.toolName
}
return part.type.replace('tool-', '')
}
function isToolPart(part: UIMessage['parts'][number]): part is ToolLikePart {
return part.type === 'dynamic-tool' || part.type.startsWith('tool-')
}
function isExecutionToolPart(
part: UIMessage['parts'][number],
): part is ToolLikePart {
if (!isToolPart(part)) return false
return !NUDGE_TOOL_NAMES.has(getToolName(part))
}
function getPreviewText(part: ToolLikePart): string {
if (part.state === 'approval-requested') {
return 'Waiting for approval'
}
if (part.state === 'approval-responded') {
return part.approval?.approved === false
? 'Approval rejected'
: 'Approval granted'
}
if (part.state === 'output-denied') {
return getCompactIssueLabel(part.approval?.reason) ?? 'Action denied'
}
if (part.state === 'output-error') {
return getCompactIssueLabel(part.errorText) ?? 'Action failed'
}
if (part.state === 'output-available') {
const preview =
getCompactIssueLabel(getNestedText(part.output)) ??
getCompactIssueLabel(stringifyValue(part.output))
return preview ?? 'Completed successfully'
}
if (part.state === 'input-available') {
return 'Action running'
}
return 'Preparing action'
}
function getApproval(part: ToolLikePart): ExecutionStepApproval | undefined {
return part.approval
? {
id: part.approval.id,
approved: part.approval.approved,
reason: part.approval.reason,
}
: undefined
}
function getCompletedAt(
existingStep: ExecutionStepRecord | undefined,
state: ExecutionStepState,
nowIso: string,
): string | undefined {
if (existingStep?.completedAt) return existingStep.completedAt
if (!TERMINAL_STEP_STATES.has(state)) return undefined
return nowIso
}
function createStepRecord(
part: ToolLikePart,
order: number,
nowIso: string,
existingStep?: ExecutionStepRecord,
): ExecutionStepRecord {
const state = part.state as ExecutionStepState
return {
id: part.toolCallId,
toolName: getToolName(part),
order,
state,
startedAt: existingStep?.startedAt ?? nowIso,
completedAt: getCompletedAt(existingStep, state, nowIso),
input: part.input,
output: 'output' in part ? part.output : undefined,
errorText: 'errorText' in part ? part.errorText : undefined,
previewText: getPreviewText(part),
approval: getApproval(part),
}
}
export function getMessageText(
message?: Pick<UIMessage, 'parts'> | null,
): string {
if (!message) return ''
return message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n\n')
.trim()
}
export function getResponsePreview(message?: Pick<UIMessage, 'parts'> | null) {
return truncateText(getMessageText(message))
}
export function normalizeExecutionSteps(args: {
assistantMessage?: UIMessage | null
previousSteps?: ExecutionStepRecord[]
nowIso: string
}) {
const { assistantMessage, previousSteps = [], nowIso } = args
const previousStepsById = new Map(
previousSteps.map((step) => [step.id, step]),
)
const steps = assistantMessage
? assistantMessage.parts.flatMap((part, index) => {
if (!isExecutionToolPart(part)) return []
const existingStep = previousStepsById.get(part.toolCallId)
return [createStepRecord(part, index, nowIso, existingStep)]
})
: []
return {
assistantMessageId: assistantMessage?.id,
steps,
actionCount: steps.length,
approvalCount: steps.filter((step) => step.approval).length,
deniedCount: steps.filter((step) => step.state === 'output-denied').length,
errorCount: steps.filter((step) => step.state === 'output-error').length,
}
}

View File

@@ -1,146 +0,0 @@
import { storage } from '@wxt-dev/storage'
import { useEffect, useState } from 'react'
import type {
ConversationExecutionHistory,
ExecutionHistoryByConversation,
ExecutionTaskRecord,
} from './types'
export const executionHistoryStorage =
storage.defineItem<ExecutionHistoryByConversation>(
'local:executionHistoryByConversation',
{
fallback: {},
version: 1,
},
)
function upsertTaskInHistory(
history: ConversationExecutionHistory,
task: ExecutionTaskRecord,
): ConversationExecutionHistory {
const existingIndex = history.tasks.findIndex((item) => item.id === task.id)
if (existingIndex === -1) {
return {
...history,
updatedAt: Date.now(),
tasks: [...history.tasks, task],
}
}
const nextTasks = [...history.tasks]
nextTasks[existingIndex] = task
return {
...history,
updatedAt: Date.now(),
tasks: nextTasks,
}
}
function createConversationHistory(
conversationId: string,
): ConversationExecutionHistory {
return {
conversationId,
updatedAt: Date.now(),
tasks: [],
}
}
export async function upsertConversationExecutionTask(
task: ExecutionTaskRecord,
): Promise<void> {
const current = (await executionHistoryStorage.getValue()) ?? {}
const history =
current[task.conversationId] ??
createConversationHistory(task.conversationId)
await executionHistoryStorage.setValue({
...current,
[task.conversationId]: upsertTaskInHistory(history, task),
})
}
export async function getConversationExecutionHistory(
conversationId: string,
): Promise<ConversationExecutionHistory | null> {
const current = (await executionHistoryStorage.getValue()) ?? {}
return current[conversationId] ?? null
}
export async function getExecutionHistoryByConversation(): Promise<ExecutionHistoryByConversation> {
return (await executionHistoryStorage.getValue()) ?? {}
}
export async function removeConversationExecutionHistory(
conversationId: string,
): Promise<void> {
const current = (await executionHistoryStorage.getValue()) ?? {}
if (!(conversationId in current)) return
const { [conversationId]: _removed, ...rest } = current
await executionHistoryStorage.setValue(rest)
}
export async function removeConversationExecutionTask(args: {
conversationId: string
taskId: string
}): Promise<void> {
const current = (await executionHistoryStorage.getValue()) ?? {}
const history = current[args.conversationId]
if (!history) return
const nextTasks = history.tasks.filter((task) => task.id !== args.taskId)
if (nextTasks.length === history.tasks.length) return
if (nextTasks.length === 0) {
const { [args.conversationId]: _removed, ...rest } = current
await executionHistoryStorage.setValue(rest)
return
}
await executionHistoryStorage.setValue({
...current,
[args.conversationId]: {
...history,
updatedAt: Date.now(),
tasks: nextTasks,
},
})
}
export function useConversationExecutionHistory(conversationId?: string) {
const [history, setHistory] = useState<ConversationExecutionHistory | null>(
null,
)
useEffect(() => {
if (!conversationId) {
setHistory(null)
return
}
getConversationExecutionHistory(conversationId).then(setHistory)
const unwatch = executionHistoryStorage.watch((nextValue) => {
setHistory(nextValue?.[conversationId] ?? null)
})
return () => unwatch()
}, [conversationId])
return history
}
export function useExecutionHistoryByConversation() {
const [historyByConversation, setHistoryByConversation] =
useState<ExecutionHistoryByConversation>({})
useEffect(() => {
getExecutionHistoryByConversation().then(setHistoryByConversation)
const unwatch = executionHistoryStorage.watch((nextValue) => {
setHistoryByConversation(nextValue ?? {})
})
return () => unwatch()
}, [])
return historyByConversation
}

View File

@@ -1,64 +0,0 @@
export type ExecutionTaskStatus =
| 'running'
| 'completed'
| 'stopped'
| 'failed'
| 'interrupted'
export type ExecutionStepState =
| 'input-streaming'
| 'input-available'
| 'approval-requested'
| 'approval-responded'
| 'output-available'
| 'output-error'
| 'output-denied'
export interface ExecutionStepApproval {
id: string
approved?: boolean
reason?: string
}
export interface ExecutionStepRecord {
id: string
toolName: string
order: number
state: ExecutionStepState
startedAt: string
completedAt?: string
input?: unknown
output?: unknown
errorText?: string
previewText: string
approval?: ExecutionStepApproval
}
export interface ExecutionTaskRecord {
id: string
conversationId: string
promptText: string
promptMessageId?: string
assistantMessageId?: string
startedAt: string
completedAt?: string
status: ExecutionTaskStatus
responseText?: string
responsePreview?: string
actionCount: number
approvalCount: number
deniedCount: number
errorCount: number
steps: ExecutionStepRecord[]
}
export interface ConversationExecutionHistory {
conversationId: string
updatedAt: number
tasks: ExecutionTaskRecord[]
}
export type ExecutionHistoryByConversation = Record<
string,
ConversationExecutionHistory
>

View File

@@ -0,0 +1,14 @@
import { env } from '@/lib/env'
const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on'])
function parseKimiLaunchFlag(value: string | undefined): boolean {
if (!value) return false
return ENABLED_VALUES.has(value.trim().toLowerCase())
}
const kimiLaunchEnabled = parseKimiLaunchFlag(env.VITE_PUBLIC_KIMI_LAUNCH)
export function isKimiLaunchEnabled(): boolean {
return kimiLaunchEnabled
}

View File

@@ -0,0 +1,5 @@
import { isKimiLaunchEnabled } from './kimi-launch'
export function useKimiLaunch(): boolean {
return isKimiLaunchEnabled()
}

View File

@@ -1,5 +1,6 @@
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
/** @public */
export interface LlmHubProvider {
@@ -7,15 +8,43 @@ export interface LlmHubProvider {
url: string
}
const KIMI_PROVIDER: LlmHubProvider = {
name: 'Kimi',
url: 'https://www.kimi.com',
}
function ensureKimiFirst(providers: LlmHubProvider[]): LlmHubProvider[] {
if (!isKimiLaunchEnabled()) return providers
const hasKimi = providers.some(
(p) => p.name === 'Kimi' || p.url.includes('kimi.com'),
)
return hasKimi ? providers : [KIMI_PROVIDER, ...providers]
}
export async function loadProviders(): Promise<LlmHubProvider[]> {
try {
const adapter = getBrowserOSAdapter()
const providersPref = await adapter.getPref(
BROWSEROS_PREFS.THIRD_PARTY_LLM_PROVIDERS,
)
return (providersPref?.value as LlmHubProvider[]) || []
const providers = (providersPref?.value as LlmHubProvider[]) || []
if (providers.length === 0) {
if (isKimiLaunchEnabled()) {
const defaults = [KIMI_PROVIDER]
await saveProviders(defaults)
return defaults
}
return []
}
const normalized = ensureKimiFirst(providers)
if (normalized !== providers) {
await saveProviders(normalized)
}
return normalized
} catch {
return []
return isKimiLaunchEnabled() ? [KIMI_PROVIDER] : []
}
}

View File

@@ -2,12 +2,14 @@ import { storage } from '@wxt-dev/storage'
import { sessionStorage } from '@/lib/auth/sessionStorage'
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
/** Default provider ID constant */
export const DEFAULT_PROVIDER_ID = 'browseros'
const DEFAULT_PROVIDER_NAME = 'BrowserOS'
const KIMI_LAUNCH_PROVIDER_NAME = 'Kimi K2.5'
/** Storage key for LLM providers array */
export const providersStorage = storage.defineItem<LlmProviderConfig[]>(
@@ -89,7 +91,7 @@ export function setupLlmProvidersSyncToBackend(): () => void {
/** Load providers from storage */
export async function loadProviders(): Promise<LlmProviderConfig[]> {
const providers = (await providersStorage.getValue()) || []
const normalizedProviders = normalizeProviderNames(providers)
const normalizedProviders = normalizeProvidersForLaunch(providers)
// Keep storage consistent so every consumer sees the same provider name.
if (
@@ -107,7 +109,7 @@ export function createDefaultBrowserOSProvider(): LlmProviderConfig {
return {
id: DEFAULT_PROVIDER_ID,
type: 'browseros',
name: DEFAULT_PROVIDER_NAME,
name: getBuiltInProviderName(),
baseUrl: 'https://api.browseros.com/v1',
modelId: 'browseros-auto',
supportsImages: true,
@@ -123,22 +125,26 @@ export function createDefaultProvidersConfig(): LlmProviderConfig[] {
return [createDefaultBrowserOSProvider()]
}
/**
* Normalize built-in provider names back to "BrowserOS" (e.g. from "Kimi K2.5"
* which was set during a previous partnership launch).
*/
function normalizeProviderNames(
function getBuiltInProviderName(): string {
return isKimiLaunchEnabled()
? KIMI_LAUNCH_PROVIDER_NAME
: DEFAULT_PROVIDER_NAME
}
function normalizeProvidersForLaunch(
providers: LlmProviderConfig[],
): LlmProviderConfig[] {
const builtInProviderName = getBuiltInProviderName()
return providers.map((provider) => {
if (
provider.id === DEFAULT_PROVIDER_ID &&
provider.type === 'browseros' &&
provider.name !== DEFAULT_PROVIDER_NAME
provider.name !== builtInProviderName
) {
return {
...provider,
name: DEFAULT_PROVIDER_NAME,
name: builtInProviderName,
}
}
return provider

View File

@@ -1,84 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
import { buildChatRequestBody } from './buildChatRequestBody'
const provider: LlmProviderConfig = {
id: 'browseros',
type: 'browseros',
name: 'BrowserOS',
modelId: 'browseros-auto',
supportsImages: true,
contextWindow: 200000,
temperature: 0,
createdAt: 0,
updatedAt: 0,
}
describe('buildChatRequestBody', () => {
it('preserves approval config and browser context on approval resumes', () => {
const toolApprovalConfig: ToolApprovalConfig = {
categories: {
input: true,
navigation: true,
observation: true,
screenshots: true,
scripts: true,
'data-modification': true,
assistant: true,
},
}
const body = buildChatRequestBody({
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
provider,
mode: 'agent',
browserContext: {
windowId: 2,
activeTab: {
id: 10,
url: 'https://amazon.com',
title: 'Amazon',
},
enabledMcpServers: ['slack'],
},
userSystemPrompt: 'Stay in the current tab.',
toolApprovalConfig,
toolApprovalResponses: [
{
approvalId: 'approval-1',
approved: true,
},
],
})
expect(body.toolApprovalConfig).toEqual(toolApprovalConfig)
expect(body.browserContext).toEqual({
windowId: 2,
activeTab: {
id: 10,
url: 'https://amazon.com',
title: 'Amazon',
},
enabledMcpServers: ['slack'],
})
expect(body.toolApprovalResponses).toEqual([
{
approvalId: 'approval-1',
approved: true,
},
])
})
it('omits empty approval configs from requests', () => {
const body = buildChatRequestBody({
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
provider,
toolApprovalConfig: {
categories: {},
},
})
expect(body.toolApprovalConfig).toBeUndefined()
})
})

View File

@@ -1,115 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
export interface ApprovalResponseData {
approvalId: string
approved: boolean
reason?: string
}
export interface ChatHistoryEntry {
role: 'user' | 'assistant'
content: string
}
export interface ChatRequestBrowserContext {
windowId?: number
activeTab?: {
id?: number
url?: string
title?: string
}
selectedTabs?: {
id?: number
url?: string
title?: string
}[]
enabledMcpServers?: string[]
customMcpServers?: {
name: string
url?: string
}[]
}
interface ChatRequestBodyParams {
conversationId: string
provider: LlmProviderConfig
message?: string
mode?: ChatMode
browserContext?: ChatRequestBrowserContext
userSystemPrompt?: string
userWorkingDir?: string
supportsImages?: boolean
previousConversation?: ChatHistoryEntry[] | string
declinedApps?: string[]
aclRules?: AclRule[]
selectedText?: string
selectedTextSource?: {
url: string
title: string
}
toolApprovalConfig?: ToolApprovalConfig
toolApprovalResponses?: ApprovalResponseData[]
isScheduledTask?: boolean
}
export const toRequestToolApprovalConfig = (
approvalConfig?: ToolApprovalConfig,
): ToolApprovalConfig | undefined => {
if (!approvalConfig) return undefined
return Object.values(approvalConfig.categories).some(Boolean)
? approvalConfig
: undefined
}
export const buildChatRequestBody = ({
conversationId,
provider,
message = '',
mode,
browserContext,
userSystemPrompt,
userWorkingDir,
supportsImages,
previousConversation,
declinedApps,
aclRules,
selectedText,
selectedTextSource,
toolApprovalConfig,
toolApprovalResponses,
isScheduledTask,
}: ChatRequestBodyParams) => ({
message,
provider: provider.type,
providerType: provider.type,
providerName: provider.name,
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
conversationId,
model: provider.modelId ?? 'default',
mode,
contextWindowSize: provider.contextWindow,
temperature: provider.temperature,
resourceName: provider.resourceName,
accessKeyId: provider.accessKeyId,
secretAccessKey: provider.secretAccessKey,
region: provider.region,
sessionToken: provider.sessionToken,
reasoningEffort: provider.reasoningEffort,
reasoningSummary: provider.reasoningSummary,
browserContext,
userSystemPrompt,
userWorkingDir,
supportsImages: supportsImages ?? provider.supportsImages,
previousConversation,
declinedApps: declinedApps?.length ? declinedApps : undefined,
aclRules: aclRules?.length ? aclRules : undefined,
selectedText,
selectedTextSource,
toolApprovalConfig: toRequestToolApprovalConfig(toolApprovalConfig),
toolApprovalResponses,
isScheduledTask,
})

View File

@@ -8,7 +8,6 @@ import {
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
import { buildChatRequestBody } from '@/lib/messaging/server/buildChatRequestBody'
import { personalizationStorage } from '../personalization/personalizationStorage'
import { scheduleSystemPrompt } from './scheduleSystemPrompt'
import type { ToolCallExecution } from './scheduleTypes'
@@ -113,31 +112,42 @@ export async function getChatServerResponse(
headers: {
'Content-Type': 'application/json',
},
// Important: this chat logic is also used in apps/agent/entrypoints/sidepanel/index/useChatSession.ts for sidepanel conversation. Make sure to keep them in sync for any future changes.
body: JSON.stringify({
messages: [{ role: 'user', content: request.message }],
...buildChatRequestBody({
message: request.message,
conversationId,
provider,
mode: request.mode ?? 'agent',
browserContext:
request.activeTab ||
request.windowId ||
enabledMcpServers.length ||
customMcpServers.length
? {
windowId: request.windowId,
activeTab: request.activeTab,
enabledMcpServers:
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
customMcpServers:
customMcpServers.length > 0 ? customMcpServers : undefined,
}
: undefined,
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
supportsImages: provider.supportsImages,
isScheduledTask: true,
}),
message: request.message,
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
conversationId,
model: provider?.modelId ?? 'default',
mode: request.mode ?? 'agent',
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
resourceName: provider?.resourceName,
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
browserContext:
request.activeTab ||
request.windowId ||
enabledMcpServers.length ||
customMcpServers.length
? {
windowId: request.windowId,
activeTab: request.activeTab,
enabledMcpServers:
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
customMcpServers:
customMcpServers.length > 0 ? customMcpServers : undefined,
}
: undefined,
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
isScheduledTask: true,
supportsImages: provider?.supportsImages,
}),
})

View File

@@ -1,71 +0,0 @@
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
export function parseSSELines<T>(buffer: string): {
events: T[]
remainder: string
} {
const lines = buffer.split('\n')
const remainder = lines.pop() ?? ''
const events: T[] = []
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (payload === '[DONE]') continue
try {
events.push(JSON.parse(payload) as T)
} catch {}
}
return { events, remainder }
}
export async function consumeSSEStream<T>(
response: Response,
onEvent: (event: T) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
const abortReader = () => {
void reader.cancel()
}
signal?.addEventListener('abort', abortReader, { once: true })
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const { events, remainder } = parseSSELines<T>(buffer)
buffer = remainder
for (const event of events) {
onEvent(event)
}
}
} catch (error) {
if (signal?.aborted || isAbortError(error)) return
throw error
} finally {
signal?.removeEventListener('abort', abortReader)
const trailing = decoder.decode()
if (trailing) {
buffer += trailing
}
if (buffer) {
const { events } = parseSSELines<T>(buffer)
for (const event of events) {
onEvent(event)
}
}
}
}

View File

@@ -1,84 +0,0 @@
import type { UIMessage } from 'ai'
import type { ApprovalResponse, PendingApproval } from './approval-sync-storage'
export function extractPendingApprovals(
messages: UIMessage[],
conversationId: string,
timestamp = Date.now(),
): PendingApproval[] {
const pending: PendingApproval[] = []
for (const msg of messages) {
for (const part of msg.parts) {
const toolPart = part as {
type?: string
state?: string
toolCallId?: string
input?: Record<string, unknown>
approval?: { id: string }
}
if (
toolPart.state === 'approval-requested' &&
toolPart.approval?.id &&
toolPart.toolCallId
) {
pending.push({
approvalId: toolPart.approval.id,
toolCallId: toolPart.toolCallId,
toolName: (toolPart.type ?? '').replace('tool-', ''),
input: toolPart.input ?? {},
conversationId,
timestamp,
})
}
}
}
return pending
}
export function replacePendingApprovalsForConversation(
existing: PendingApproval[],
conversationId: string,
next: PendingApproval[],
): PendingApproval[] {
const existingByApprovalId = new Map(
existing.map((item) => [item.approvalId, item]),
)
const preserved = next.map((item) => {
const current = existingByApprovalId.get(item.approvalId)
return current ? { ...item, timestamp: current.timestamp } : item
})
return [
...existing.filter((item) => item.conversationId !== conversationId),
...preserved,
]
}
export function queueApprovalResponse(
existing: ApprovalResponse[],
response: ApprovalResponse,
): ApprovalResponse[] {
return [
...existing.filter((item) => item.approvalId !== response.approvalId),
response,
]
}
export function removePendingApprovalsById(
existing: PendingApproval[],
approvalIds: string[],
): PendingApproval[] {
const ids = new Set(approvalIds)
return existing.filter((item) => !ids.has(item.approvalId))
}
export function removeApprovalResponsesById(
existing: ApprovalResponse[],
approvalIds: string[],
): ApprovalResponse[] {
const ids = new Set(approvalIds)
return existing.filter((item) => !ids.has(item.approvalId))
}

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { UIMessage } from 'ai'
import {
extractPendingApprovals,
queueApprovalResponse,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from './approval-sync-helpers'
describe('approval sync storage helpers', () => {
it('extracts pending approvals from assistant tool parts', () => {
const messages = [
{
id: 'assistant-1',
role: 'assistant',
parts: [
{
type: 'tool-click',
state: 'approval-requested',
toolCallId: 'tool-1',
input: { selector: '#buy-now' },
approval: { id: 'approval-1' },
},
],
},
] as UIMessage[]
expect(extractPendingApprovals(messages, 'conversation-1', 123)).toEqual([
{
approvalId: 'approval-1',
toolCallId: 'tool-1',
toolName: 'click',
input: { selector: '#buy-now' },
conversationId: 'conversation-1',
timestamp: 123,
},
])
})
it('replaces pending approvals for one conversation without clearing others', () => {
const existing = [
{
approvalId: 'approval-a',
toolCallId: 'tool-a',
toolName: 'click',
input: {},
conversationId: 'conversation-a',
timestamp: 1,
},
{
approvalId: 'approval-b',
toolCallId: 'tool-b',
toolName: 'navigate_page',
input: {},
conversationId: 'conversation-b',
timestamp: 2,
},
]
expect(
replacePendingApprovalsForConversation(existing, 'conversation-a', []),
).toEqual([existing[1]])
})
it('queues and removes approval responses by approval id', () => {
const queued = queueApprovalResponse(
[
{
approvalId: 'approval-a',
approved: true,
timestamp: 1,
},
],
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
)
expect(queued).toEqual([
{
approvalId: 'approval-a',
approved: true,
timestamp: 1,
},
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
])
expect(removeApprovalResponsesById(queued, ['approval-a'])).toEqual([
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
])
})
it('removes only handled pending approvals', () => {
const pending = [
{
approvalId: 'approval-a',
toolCallId: 'tool-a',
toolName: 'click',
input: {},
conversationId: 'conversation-a',
timestamp: 1,
},
{
approvalId: 'approval-b',
toolCallId: 'tool-b',
toolName: 'fill',
input: {},
conversationId: 'conversation-b',
timestamp: 2,
},
]
expect(removePendingApprovalsById(pending, ['approval-b'])).toEqual([
pending[0],
])
})
})

View File

@@ -1,47 +0,0 @@
import { storage } from '@wxt-dev/storage'
export {
extractPendingApprovals,
queueApprovalResponse,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from './approval-sync-helpers'
export interface PendingApproval {
approvalId: string
toolCallId: string
toolName: string
input: Record<string, unknown>
conversationId: string
timestamp: number
}
export interface ApprovalResponse {
approvalId: string
approved: boolean
reason?: string
timestamp: number
}
export interface ToolExecutionLogEntry {
toolCallId: string
toolName: string
status: 'auto-allowed' | 'approved' | 'denied' | 'error'
conversationId: string
timestamp: number
input?: Record<string, unknown>
}
export const pendingToolApprovalsStorage = storage.defineItem<
PendingApproval[]
>('local:pending-tool-approvals', { fallback: [] })
export const approvalResponsesStorage = storage.defineItem<ApprovalResponse[]>(
'local:approval-responses',
{ fallback: [] },
)
export const toolExecutionLogStorage = storage.defineItem<
ToolExecutionLogEntry[]
>('local:tool-execution-log', { fallback: [] })

View File

@@ -1,38 +0,0 @@
import { storage } from '@wxt-dev/storage'
import type { ToolApprovalCategoryId, ToolApprovalConfig } from './types'
export const toolApprovalConfigStorage = storage.defineItem<ToolApprovalConfig>(
'local:tool-approval-config',
{
fallback: {
categories: {},
},
},
)
const LEGACY_ALL_CATEGORY_IDS: ToolApprovalCategoryId[] = [
'input',
'navigation',
'screenshots',
'scripts',
'data-modification',
]
const NEW_CATEGORY_IDS: ToolApprovalCategoryId[] = ['observation', 'assistant']
export function normalizeToolApprovalConfig(
config: ToolApprovalConfig,
): ToolApprovalConfig {
const categories = { ...config.categories }
const shouldMigrateLegacyAll =
LEGACY_ALL_CATEGORY_IDS.every((id) => categories[id] === true) &&
NEW_CATEGORY_IDS.every((id) => categories[id] === undefined)
if (shouldMigrateLegacyAll) {
for (const id of NEW_CATEGORY_IDS) {
categories[id] = true
}
}
return { categories }
}

View File

@@ -1,7 +0,0 @@
export {
TOOL_APPROVAL_CATEGORIES as TOOL_CATEGORIES,
TOOL_APPROVAL_CATEGORIES,
type ToolApprovalCategory,
type ToolApprovalCategoryId,
type ToolApprovalConfig,
} from '@browseros/shared/constants/tool-approval'

View File

@@ -0,0 +1,54 @@
import { storage } from '@wxt-dev/storage'
import { useEffect, useState } from 'react'
export interface Workflow {
id: string
codeId: string
workflowName: string
}
export const workflowStorage = storage.defineItem<Workflow[]>(
'local:workflows',
{
fallback: [],
},
)
export function useWorkflows() {
const [workflows, setWorkflows] = useState<Workflow[]>([])
useEffect(() => {
workflowStorage.getValue().then(setWorkflows)
const unwatch = workflowStorage.watch((newValue) => {
setWorkflows(newValue ?? [])
})
return unwatch
}, [])
const addWorkflow = async (workflow: Omit<Workflow, 'id'>) => {
const newWorkflow: Workflow = {
id: crypto.randomUUID(),
...workflow,
}
const current = (await workflowStorage.getValue()) ?? []
await workflowStorage.setValue([...current, newWorkflow])
return newWorkflow
}
const removeWorkflow = async (id: string) => {
const current = (await workflowStorage.getValue()) ?? []
await workflowStorage.setValue(current.filter((w) => w.id !== id))
}
const editWorkflow = async (
id: string,
updates: Partial<Omit<Workflow, 'id'>>,
) => {
const current = (await workflowStorage.getValue()) ?? []
await workflowStorage.setValue(
current.map((w) => (w.id === id ? { ...w, ...updates } : w)),
)
}
return { workflows, addWorkflow, removeWorkflow, editWorkflow }
}

View File

@@ -2,7 +2,7 @@
"name": "@browseros/agent",
"description": "manifest.json description",
"private": true,
"version": "0.0.99",
"version": "0.0.98",
"type": "module",
"scripts": {
"dev": "test -d generated/graphql || bun run codegen; mkdir -p /tmp/browseros-dev; bun --env-file=.env.development wxt",
@@ -20,7 +20,6 @@
"dependencies": {
"@ai-sdk/react": "^3.0.96",
"@browseros/server": "workspace:*",
"@browseros/shared": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons": "^2.44.0",
"@mdxeditor/editor": "^3.52.4",
@@ -52,9 +51,6 @@
"@types/dompurify": "^3.2.0",
"@webext-core/messaging": "^2.3.0",
"@wxt-dev/storage": "^1.2.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.9.3",
"ai": "^6.0.94",
"better-auth": "^1.4.17",

View File

@@ -221,37 +221,6 @@
background-clip: padding-box;
}
.agent-terminal-shell .xterm {
height: 100%;
}
.agent-terminal-shell .xterm-viewport {
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: oklch(from var(--border) l c h / 0.45) transparent;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-track {
background: transparent;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-thumb {
background: oklch(from var(--border) l c h / 0.45);
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: oklch(from var(--border) l c h / 0.7);
background-clip: padding-box;
}
/* Custom animation keyframes for minimal, elegant hero animations */
@keyframes float {
0%,

View File

@@ -5,8 +5,7 @@
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./*"],
"@browseros/shared/*": ["../../packages/shared/src/*"]
"@/*": ["./*"]
},
"plugins": [
{

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