Compare commits

..

8 Commits

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

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

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

View File

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

3
.gitignore vendored
View File

@@ -26,6 +26,3 @@ gclient.json
**/resources/binaries/
packages/browseros/build/tools/
# AI SDK DevTools traces
.devtools/

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/browseros-agent/packages/agent-sdk
working-directory: packages/agent-sdk
steps:
- uses: actions/checkout@v6
@@ -23,7 +23,7 @@ jobs:
- name: Install dependencies
run: bun ci
working-directory: packages/browseros-agent
working-directory: .
- name: Build
run: bun run build

View File

@@ -7,21 +7,18 @@ jobs:
name: Run Tests
runs-on: macos-latest
timeout-minutes: 10
defaults:
run:
working-directory: packages/browseros-agent
steps:
- name: Checkout code
- name: 📥 Checkout code
uses: actions/checkout@v6
- name: Setup Bun
- name: 🧰 Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
- name: 📦 Install dependencies
run: bun ci
- name: Run all tests
- name: 🧪 Run all tests
run: bun test:all
env:
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

View File

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

View File

@@ -1,7 +1,6 @@
import { ChevronDown, LogIn, LogOut, User } from 'lucide-react'
import type { FC } from 'react'
import { useNavigate } from 'react-router'
import { i18n } from '#i18n'
import ProductLogo from '@/assets/product_logo.svg'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import {
@@ -69,11 +68,7 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
</div>
)
) : (
<img
src={ProductLogo}
alt={i18n.t('sidebar.branding.alt')}
className="size-8"
/>
<img src={ProductLogo} alt="BrowserOS" className="size-8" />
)
return (
@@ -110,9 +105,7 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
: 'font-medium text-primary',
)}
>
{isLoggedIn
? i18n.t('sidebar.branding.personal')
: i18n.t('sidebar.branding.signIn')}
{isLoggedIn ? 'Personal' : 'Sign in'}
</span>
</div>
</button>
@@ -130,14 +123,14 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
{displayName}
</p>
<p className="text-muted-foreground text-xs leading-none">
{i18n.t('sidebar.branding.personal')}
Personal
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 size-4" />
{i18n.t('sidebar.branding.updateProfile')}
Update Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -145,13 +138,13 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
variant="destructive"
>
<LogOut className="mr-2 size-4" />
{i18n.t('sidebar.branding.signOut')}
Sign out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => navigate('/login')}>
<LogIn className="mr-2 size-4" />
{i18n.t('sidebar.branding.signIn')}
Sign in
</DropdownMenuItem>
)}
</DropdownMenuContent>

View File

@@ -9,7 +9,6 @@ import {
} from 'lucide-react'
import type { FC } from 'react'
import { NavLink, useLocation } from 'react-router'
import { i18n } from '#i18n'
import {
Tooltip,
TooltipContent,
@@ -25,44 +24,40 @@ interface SidebarNavigationProps {
}
type NavItem = {
nameKey: string
name: string
to: string
icon: typeof Home
feature?: Feature
}
const primaryNavItems: NavItem[] = [
{ nameKey: 'sidebar.nav.home', to: '/home', icon: Home },
{ name: 'Home', to: '/home', icon: Home },
{
nameKey: 'sidebar.nav.connectApps',
name: 'Connect Apps',
to: '/connect-apps',
icon: PlugZap,
feature: Feature.MANAGED_MCP_SUPPORT,
},
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
{
nameKey: 'sidebar.nav.scheduledTasks',
to: '/scheduled',
icon: CalendarClock,
},
{
nameKey: 'sidebar.nav.skills',
name: 'Skills',
to: '/home/skills',
icon: Wand2,
feature: Feature.SKILLS_SUPPORT,
},
{
nameKey: 'sidebar.nav.memory',
name: 'Memory',
to: '/home/memory',
icon: Brain,
feature: Feature.MEMORY_SUPPORT,
},
{
nameKey: 'sidebar.nav.soul',
name: 'Soul',
to: '/home/soul',
icon: Sparkles,
feature: Feature.SOUL_SUPPORT,
},
{ nameKey: 'sidebar.nav.settings', to: '/settings/ai', icon: Settings },
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
@@ -102,7 +97,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
expanded ? 'opacity-100' : 'opacity-0',
)}
>
{i18n.t(item.nameKey as never)}
{item.name}
</span>
</NavLink>
)
@@ -111,9 +106,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
return (
<Tooltip key={item.to}>
<TooltipTrigger asChild>{navItem}</TooltipTrigger>
<TooltipContent side="right">
{i18n.t(item.nameKey as never)}
</TooltipContent>
<TooltipContent side="right">{item.name}</TooltipContent>
</Tooltip>
)
}

View File

@@ -1,6 +1,5 @@
import { Info, Keyboard } from 'lucide-react'
import type { FC } from 'react'
import { i18n } from '#i18n'
import { Button } from '@/components/ui/button'
import {
Tooltip,
@@ -51,7 +50,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
expanded ? 'opacity-100' : 'opacity-0',
)}
>
{i18n.t('sidebar.footer.about')}
About BrowserOS
</span>
</a>
)
@@ -69,7 +68,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
expanded ? 'opacity-100' : 'opacity-0',
)}
>
{i18n.t('sidebar.footer.shortcuts')}
Shortcuts
</span>
</Button>
)
@@ -82,9 +81,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
) : (
<Tooltip>
<TooltipTrigger asChild>{shortcutsButton}</TooltipTrigger>
<TooltipContent side="right">
{i18n.t('sidebar.footer.shortcuts')}
</TooltipContent>
<TooltipContent side="right">Shortcuts</TooltipContent>
</Tooltip>
)}
@@ -93,9 +90,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
) : (
<Tooltip>
<TooltipTrigger asChild>{aboutLink}</TooltipTrigger>
<TooltipContent side="right">
{i18n.t('sidebar.footer.about')}
</TooltipContent>
<TooltipContent side="right">About BrowserOS</TooltipContent>
</Tooltip>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import type { FC } from 'react'
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
import { NewTab } from '../newtab/index/NewTab'
import { NewTabChat } from '../newtab/index/NewTabChat'
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
import { Personalize } from '../newtab/personalize/Personalize'
import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
@@ -80,7 +79,6 @@ export const App: FC = () => {
{/* Home routes */}
<Route path="home" element={<NewTabLayout />}>
<Route index element={<NewTab />} />
<Route path="chat" element={<NewTabChat />} />
<Route path="personalize" element={<Personalize />} />
<Route path="soul" element={<SoulPage />} />
<Route path="skills" element={<SkillsPage />} />

View File

@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { type FC, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -13,27 +13,14 @@ import {
} from '@/components/ui/alert-dialog'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import {
CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
CHATGPT_PRO_OAUTH_STARTED_EVENT,
GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
GITHUB_COPILOT_OAUTH_STARTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import {
getProviderTemplate,
type ProviderTemplate,
} from '@/lib/llm-providers/providerTemplates'
import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import { testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
import { track } from '@/lib/metrics/track'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import {
DeleteRemoteLlmProviderDocument,
@@ -114,117 +101,12 @@ export const AISettingsPage: FC = () => {
null,
)
// OAuth status for ChatGPT Plus/Pro
const {
status: chatgptProStatus,
startPolling: startChatGPTProPolling,
disconnect: disconnectChatGPTPro,
} = useOAuthStatus('chatgpt-pro')
// OAuth status for GitHub Copilot
const {
status: copilotStatus,
startPolling: startCopilotPolling,
disconnect: disconnectCopilot,
} = useOAuthStatus('github-copilot')
// Track whether user explicitly started an OAuth flow this session
const oauthFlowStartedRef = useRef(false)
const copilotOAuthStartedRef = useRef(false)
// Auto-create provider only when user actively completed OAuth,
// not on passive page load when server has old tokens
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!chatgptProStatus?.authenticated) return
if (!oauthFlowStartedRef.current) return
const exists = providers.some((p) => p.type === 'chatgpt-pro')
if (exists) return
const now = Date.now()
try {
const template = getProviderTemplate('chatgpt-pro')
saveProvider({
id: `chatgpt-pro-${now}`,
type: 'chatgpt-pro',
name: `ChatGPT Plus/Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`,
modelId: template?.defaultModelId ?? 'gpt-5.3-codex',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 400000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, {
email: chatgptProStatus.email,
})
toast.success('ChatGPT Plus/Pro Connected', {
description: chatgptProStatus.email
? `Authenticated as ${chatgptProStatus.email}`
: 'Successfully authenticated with ChatGPT Plus/Pro',
})
} catch (err) {
toast.error('Failed to create ChatGPT Plus/Pro provider', {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
oauthFlowStartedRef.current = false
}
}, [chatgptProStatus?.authenticated])
// Auto-create GitHub Copilot provider on successful OAuth
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!copilotStatus?.authenticated) return
if (!copilotOAuthStartedRef.current) return
const exists = providers.some((p) => p.type === 'github-copilot')
if (exists) return
const now = Date.now()
try {
const template = getProviderTemplate('github-copilot')
saveProvider({
id: `github-copilot-${now}`,
type: 'github-copilot',
name: 'GitHub Copilot',
modelId: template?.defaultModelId ?? 'gpt-4o',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 128000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
track(GITHUB_COPILOT_OAUTH_COMPLETED_EVENT)
toast.success('GitHub Copilot Connected', {
description: 'Successfully authenticated with GitHub Copilot',
})
} catch (err) {
toast.error('Failed to create GitHub Copilot provider', {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
copilotOAuthStartedRef.current = false
}
}, [copilotStatus?.authenticated])
const handleAddProvider = () => {
setTemplateValues(undefined)
setIsNewDialogOpen(true)
}
const handleUseTemplate = (template: ProviderTemplate) => {
// OAuth providers: trigger OAuth flow instead of opening form dialog
if (template.id === 'chatgpt-pro') {
handleStartChatGPTProOAuth()
return
}
if (template.id === 'github-copilot') {
handleStartGitHubCopilotOAuth()
return
}
setTemplateValues({
type: template.id,
name: template.name,
@@ -237,68 +119,6 @@ export const AISettingsPage: FC = () => {
setIsNewDialogOpen(true)
}
const handleStartChatGPTProOAuth = () => {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
oauthFlowStartedRef.current = true
const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings')
const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}`
window.open(startUrl, '_blank')
// Start polling for OAuth completion
startChatGPTProPolling()
track(CHATGPT_PRO_OAUTH_STARTED_EVENT)
toast.info('Authenticating with ChatGPT Plus/Pro', {
description: 'Complete the login in the opened tab.',
})
}
const handleStartGitHubCopilotOAuth = async () => {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
copilotOAuthStartedRef.current = true
try {
// Device Code flow: get user code from server, then open GitHub
const res = await fetch(`${agentServerUrl}/oauth/github-copilot/start`)
if (!res.ok) throw new Error(`Server returned ${res.status}`)
const data = (await res.json()) as {
userCode?: string
verificationUri?: string
}
if (!data.userCode || !data.verificationUri) {
throw new Error('Invalid response from server')
}
// Open GitHub device verification page
window.open(data.verificationUri, '_blank')
// Start polling for completion
startCopilotPolling()
track(GITHUB_COPILOT_OAUTH_STARTED_EVENT)
toast.info(`Enter code: ${data.userCode}`, {
description: 'Paste this code on the GitHub page that just opened.',
duration: 60_000,
})
} catch (err) {
copilotOAuthStartedRef.current = false
toast.error('Failed to start GitHub Copilot authentication', {
description: err instanceof Error ? err.message : 'Unknown error',
})
}
}
const handleEditProvider = (provider: LlmProviderConfig) => {
setEditingProvider(provider)
setIsEditDialogOpen(true)
@@ -310,15 +130,6 @@ export const AISettingsPage: FC = () => {
const confirmDeleteProvider = async () => {
if (providerToDelete) {
// Clear OAuth tokens on server for OAuth-based providers
if (providerToDelete.type === 'chatgpt-pro') {
await disconnectChatGPTPro()
track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT)
}
if (providerToDelete.type === 'github-copilot') {
await disconnectCopilot()
track(GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT)
}
await deleteProvider(providerToDelete.id)
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
setProviderToDelete(null)

View File

@@ -61,8 +61,6 @@ const providerTypeEnum = z.enum([
'lmstudio',
'bedrock',
'browseros',
'chatgpt-pro',
'github-copilot',
])
/**
@@ -86,9 +84,6 @@ export const providerFormSchema = z
secretAccessKey: z.string().optional(),
region: z.string().optional(),
sessionToken: z.string().optional(),
// ChatGPT Pro (Codex)
reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(),
reasoningSummary: z.enum(['auto', 'concise', 'detailed']).optional(),
})
.superRefine((data, ctx) => {
// Azure: require either resourceName or baseUrl
@@ -132,10 +127,6 @@ export const providerFormSchema = z
})
}
}
// OAuth providers: no credentials needed (server-managed)
else if (data.type === 'chatgpt-pro' || data.type === 'github-copilot') {
// No validation needed — OAuth tokens are on the server
}
// Other providers: require baseUrl
else if (!data.baseUrl) {
ctx.addIssue({
@@ -191,10 +182,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
const kimiLaunch = useKimiLaunch()
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
if (opt.value === 'chatgpt-pro')
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (opt.value === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (opt.value === 'moonshot')
return kimiLaunch || initialValues?.type === 'moonshot'
if (opt.value === 'openai-compatible') {
@@ -222,8 +209,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: initialValues?.secretAccessKey || '',
region: initialValues?.region || '',
sessionToken: initialValues?.sessionToken || '',
reasoningEffort: initialValues?.reasoningEffort || 'high',
reasoningSummary: initialValues?.reasoningSummary || 'auto',
},
})
@@ -316,8 +301,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: initialValues.secretAccessKey || '',
region: initialValues.region || '',
sessionToken: initialValues.sessionToken || '',
reasoningEffort: initialValues.reasoningEffort || 'high',
reasoningSummary: initialValues.reasoningSummary || 'auto',
})
setIsCustomModel(false)
}
@@ -343,8 +326,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: '',
region: '',
sessionToken: '',
reasoningEffort: 'high',
reasoningSummary: 'auto',
})
setIsCustomModel(false)
}
@@ -382,10 +363,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
const canTest = (): boolean => {
if (!watchedModelId) return false
// OAuth providers: always testable (server has the OAuth token)
if (watchedType === 'chatgpt-pro' || watchedType === 'github-copilot')
return true
if (watchedType === 'azure') {
return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey
}
@@ -467,84 +444,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
}
const renderProviderSpecificFields = () => {
// GitHub Copilot: OAuth credentials only
if (watchedType === 'github-copilot') {
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
Credentials are managed via GitHub OAuth. No API key needed.
</div>
)
}
// ChatGPT Pro: OAuth credentials + Codex reasoning settings
if (watchedType === 'chatgpt-pro') {
return (
<>
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
Credentials are managed via OAuth. No API key needed.
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="reasoningEffort"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Effort</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || 'high'}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How much the model thinks before responding
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reasoningSummary"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Summary</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || 'auto'}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="concise">Concise</SelectItem>
<SelectItem value="detailed">Detailed</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Detail level of visible thinking steps
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)
}
if (watchedType === 'azure') {
return (
<>

View File

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

View File

@@ -26,10 +26,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
const kimiLaunch = useKimiLaunch()
const filteredTemplates = providerTemplates.filter((template) => {
if (template.id === 'chatgpt-pro')
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (template.id === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (template.id === 'moonshot') return kimiLaunch
if (template.id === 'openai-compatible') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import {
Trash2,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
@@ -20,9 +20,6 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Switch } from '@/components/ui/switch'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import { providersStorage } from '@/lib/llm-providers/storage'
import type { ProviderType } from '@/lib/llm-providers/types'
import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage'
import type { ScheduledJob, ScheduledJobRun } from './types'
@@ -83,25 +80,9 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
onRetryRun,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [providerInfo, setProviderInfo] = useState<{
name: string
type: ProviderType
} | null>(null)
const { jobRuns } = useScheduledJobRuns()
// Load provider info for display
useEffect(() => {
if (!job.providerId) {
setProviderInfo(null)
return
}
providersStorage.getValue().then((providers) => {
const match = providers?.find((p) => p.id === job.providerId)
setProviderInfo(match ? { name: match.name, type: match.type } : null)
})
}, [job.providerId])
const runs = useMemo(
() =>
jobRuns
@@ -136,19 +117,6 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
</p>
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span>{formatSchedule(job)}</span>
{providerInfo && (
<>
<span></span>
<span className="flex items-center gap-1">
{providerInfo.type === 'browseros' ? (
<BrowserOSIcon size={12} />
) : (
<ProviderIcon type={providerInfo.type} size={12} />
)}
{providerInfo.name}
</span>
</>
)}
{job.lastRunAt && (
<>
<span></span>

View File

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

View File

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

View File

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

View File

@@ -117,7 +117,6 @@ export const scheduledJobRuns = async () => {
const response = await getChatServerResponse({
message: job.query,
signal: abortController.signal,
providerId: job.providerId,
})
await updateJobRun(jobRun.id, {

View File

@@ -1,7 +1,6 @@
import { Upload, X } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useState } from 'react'
import { i18n } from '#i18n'
import { Button } from '@/components/ui/button'
import {
Card,
@@ -49,9 +48,7 @@ export const ImportDataHint = () => {
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Upload className="size-5 text-muted-foreground" />
<CardTitle className="text-base">
{i18n.t('newtab.importData.title')}
</CardTitle>
<CardTitle className="text-base">Import your data</CardTitle>
</div>
<Button
variant="ghost"
@@ -63,7 +60,7 @@ export const ImportDataHint = () => {
</Button>
</div>
<CardDescription>
{i18n.t('newtab.importData.description')}
Bring bookmarks, history, and passwords from Chrome.
</CardDescription>
<label
htmlFor="import-dont-ask-again"
@@ -76,11 +73,11 @@ export const ImportDataHint = () => {
setDontAskAgain(checked === true)
}
/>
{i18n.t('newtab.importData.dontShowAgain')}
Don't show this again
</label>
<Button className="w-full" onClick={handleImport}>
<Upload className="size-4" />
{i18n.t('newtab.importData.openSettings')}
Open Import Settings
</Button>
</CardHeader>
</Card>

View File

@@ -11,9 +11,6 @@ import {
} from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { i18n } from '#i18n'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import { AppSelector } from '@/components/elements/AppSelector'
import {
GlowingBorder,
@@ -39,6 +36,7 @@ import {
import {
NEWTAB_AI_TRIGGERED_EVENT,
NEWTAB_APPS_OPENED_EVENT,
NEWTAB_CHAT_RESET_EVENT,
NEWTAB_CHAT_STARTED_EVENT,
NEWTAB_OPENED_EVENT,
NEWTAB_SEARCH_EXECUTED_EVENT,
@@ -47,8 +45,6 @@ import {
NEWTAB_TABS_OPENED_EVENT,
NEWTAB_WORKSPACE_OPENED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
@@ -62,6 +58,7 @@ import {
useSuggestions,
} from './lib/suggestions/useSuggestions'
import { NewTabBranding } from './NewTabBranding'
import { NewTabChat } from './NewTabChat'
import { NewTabTip } from './NewTabTip'
import { ScheduleResults } from './ScheduleResults'
import { SearchSuggestions } from './SearchSuggestions'
@@ -81,13 +78,13 @@ interface MentionState {
*/
export const NewTab = () => {
const activeHint = useActiveHint()
const navigate = useNavigate()
const [inputValue, setInputValue] = useState('')
const [mounted, setMounted] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const tabsDropdownRef = useRef<HTMLDivElement>(null)
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
const [chatActive, setChatActive] = useState(false)
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
filterText: '',
@@ -95,12 +92,13 @@ export const NewTab = () => {
})
const { selectedFolder } = useWorkspace()
const { supports } = useCapabilities()
const { providers, selectedProvider, handleSelectProvider } =
useChatSessionContext()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
useSyncRemoteIntegrations()
const { messages, sendMessage, setMode, resetConversation } =
useChatSessionContext()
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
return userMCPIntegrations?.integrations?.find(
@@ -130,9 +128,7 @@ export const NewTab = () => {
query: inputValue,
selectedTabs,
})
const searchPlaceholder = i18n.t('newtab.search.placeholder', [
providerConfig.name,
])
const searchPlaceholder = `Ask BrowserOS or search ${providerConfig.name}...`
const {
isOpen,
@@ -279,28 +275,17 @@ export const NewTab = () => {
const startInlineChat = (
message: string,
chatMode: 'chat' | 'agent',
aiTab?: { name: string; description: string },
mode: 'chat' | 'agent',
action?: ReturnType<
typeof createBrowserOSAction | typeof createAITabAction
>,
) => {
track(NEWTAB_CHAT_STARTED_EVENT, {
mode: chatMode,
tabs_count: selectedTabs.length,
})
const tabIds = selectedTabs
.map((t) => t.id)
.filter((id): id is number => id !== undefined)
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
setMode(mode)
setChatActive(true)
sendMessage({ text: message, action })
reset()
setSelectedTabs([])
const params = new URLSearchParams({ q: message, mode: chatMode })
if (tabIds.length > 0) {
params.set('tabs', tabIds.join(','))
}
if (aiTab) {
params.set('actionType', 'ai-tab')
params.set('tabName', aiTab.name)
params.set('tabDescription', aiTab.description)
}
navigate(`/home/chat?${params.toString()}`)
}
const runSelectedAction = (item: SuggestionItem | undefined) => {
@@ -321,18 +306,15 @@ export const NewTab = () => {
mode: 'agent',
tabs_count: selectedTabs.length,
})
const action = createAITabAction({
name: item.name,
description: item.description,
tabs: selectedTabs,
})
const searchQuery = `${item.name}${item.description ? ` - ${item.description}` : ''}}`
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
startInlineChat(searchQuery, 'agent', {
name: item.name,
description: item.description,
})
startInlineChat(searchQuery, 'agent', action)
} else {
const action = createAITabAction({
name: item.name,
description: item.description,
tabs: selectedTabs,
})
openSidePanelWithSearch('open', {
query: searchQuery,
mode: 'agent',
@@ -348,14 +330,14 @@ export const NewTab = () => {
mode: item.mode,
tabs_count: selectedTabs.length,
})
const action = createBrowserOSAction({
mode: item.mode,
message: item.message,
tabs: selectedTabs,
})
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
startInlineChat(item.message, item.mode)
startInlineChat(item.message, item.mode, action)
} else {
const action = createBrowserOSAction({
mode: item.mode,
message: item.message,
tabs: selectedTabs,
})
openSidePanelWithSearch('open', {
query: item.message,
mode: item.mode,
@@ -369,6 +351,12 @@ export const NewTab = () => {
}
}
const handleBackToSearch = () => {
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
resetConversation()
setChatActive(false)
}
const isSuggestionsVisible =
!mentionState.isOpen &&
((isOpen && inputValue.length) ||
@@ -380,6 +368,10 @@ export const NewTab = () => {
track(NEWTAB_OPENED_EVENT)
}, [])
if (chatActive) {
return <NewTabChat onBackToSearch={handleBackToSearch} />
}
return (
<div className="pt-[max(25vh,16px)]">
{/* Main content */}
@@ -498,7 +490,7 @@ export const NewTab = () => {
{selectedTab.title}
</div>
<div className="text-muted-foreground text-xs">
{i18n.t('newtab.selectedTab')}
Tab
</div>
</div>
<button
@@ -532,34 +524,6 @@ export const NewTab = () => {
{mounted && (
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
<div className="flex items-center gap-1">
{selectedProvider && (
<ChatProviderSelector
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={handleSelectProvider}
>
<Button
variant="ghost"
size="icon"
title={selectedProvider.name}
className={cn(
'h-8 w-8 rounded-lg transition-all',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={16} />
) : (
<ProviderIcon
type={selectedProvider.type as ProviderType}
size={16}
/>
)}
</Button>
</ChatProviderSelector>
)}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
<WorkspaceSelector>
<Button
@@ -572,10 +536,7 @@ export const NewTab = () => {
)}
>
<Folder className="h-4 w-4" />
<span>
{selectedFolder?.name ||
i18n.t('newtab.addWorkspace')}
</span>
<span>{selectedFolder?.name || 'Add workspace'}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</WorkspaceSelector>
@@ -598,7 +559,7 @@ export const NewTab = () => {
)}
>
<Layers className="h-4 w-4" />
<span>{i18n.t('common.tabs')}</span>
<span>Tabs</span>
</Button>
</TabPickerPopover>
</div>
@@ -608,7 +569,7 @@ export const NewTab = () => {
<div className="ml-auto flex items-center gap-1.5">
{connectedManagedServers.length === 0 && (
<span className="flex items-center gap-1 font-semibold text-[var(--accent-orange)] text-sm">
{i18n.t('newtab.appsNew')}
New!
</span>
)}
{connectedManagedServers.length === 0 ? (
@@ -629,13 +590,14 @@ export const NewTab = () => {
)}
>
<PlugZap className="h-4 w-4" />
<span>{i18n.t('common.apps')}</span>
<span>Apps</span>
<ChevronDown className="h-3 w-3" />
</Button>
</TooltipTrigger>
</AppSelector>
<TooltipContent side="left" className="max-w-56">
{i18n.t('newtab.appsTooltip')}
Apps directly connected will have more accurate and
faster responses for your queries!
</TooltipContent>
</Tooltip>
) : (
@@ -672,7 +634,7 @@ export const NewTab = () => {
+{connectedManagedServers.length - 4}
</span>
)}
<span>{i18n.t('common.apps')}</span>
<span>Apps</span>
<ChevronDown className="h-3 w-3" />
</Button>
</AppSelector>

View File

@@ -1,6 +1,5 @@
import { motion } from 'motion/react'
import type { FC } from 'react'
import { i18n } from '#i18n'
import ProductLogoSvg from '@/assets/product_logo.svg'
export const NewTabBranding: FC = () => {
@@ -16,11 +15,7 @@ export const NewTabBranding: FC = () => {
}}
className="flex h-20 w-20 items-center justify-center rounded-xl bg-transparent"
>
<img
src={ProductLogoSvg}
alt={i18n.t('newtab.branding.alt')}
className="h-20 w-20"
/>
<img src={ProductLogoSvg} alt="BrowserOS" className="h-20 w-20" />
</motion.div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,78 @@
import { ArrowLeft, Plus } from 'lucide-react'
import type { FC } from 'react'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
interface NewTabChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
onSelectProvider: (provider: Provider) => void
onNewConversation: () => void
onBackToSearch: () => void
hasMessages: boolean
}
export const NewTabChatHeader: FC<NewTabChatHeaderProps> = ({
selectedProvider,
providers,
onSelectProvider,
onNewConversation,
onBackToSearch,
hasMessages,
}) => {
return (
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-2.5 backdrop-blur-md">
<div className="flex items-center gap-2">
{/* Back to search */}
<button
type="button"
onClick={onBackToSearch}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Back to search"
>
<ArrowLeft className="h-4 w-4" />
</button>
{/* Provider selector */}
<ChatProviderSelector
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={onSelectProvider}
>
<button
type="button"
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title="Change AI Provider"
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={18} />
) : (
<ProviderIcon
type={selectedProvider.type as ProviderType}
size={18}
/>
)}
<span className="font-semibold text-base">
{selectedProvider.name}
</span>
</button>
</ChatProviderSelector>
</div>
<div className="flex items-center gap-1">
{hasMessages && (
<button
type="button"
onClick={onNewConversation}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
</header>
)
}

View File

@@ -1,7 +1,6 @@
import { ChevronRight, Lightbulb, X } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { type FC, useState } from 'react'
import { i18n } from '#i18n'
import { NEWTAB_TIP_DISMISSED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { dismissTip, shouldShowTip, TIPS } from './tips'
@@ -40,7 +39,7 @@ export const NewTabTip: FC = () => {
<Lightbulb className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-orange)]" />
<p className="text-muted-foreground text-xs leading-relaxed">
<span className="font-semibold text-[var(--accent-orange)]">
{i18n.t('newtab.tip.label')}
Tip:
</span>{' '}
{tip.text}
</p>
@@ -48,7 +47,7 @@ export const NewTabTip: FC = () => {
type="button"
onClick={handleNext}
className="flex-shrink-0 rounded-sm p-0.5 text-muted-foreground/50 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
title={i18n.t('newtab.tip.nextTip')}
title="Next tip"
>
<ChevronRight className="h-3 w-3" />
</button>
@@ -56,7 +55,7 @@ export const NewTabTip: FC = () => {
type="button"
onClick={handleDismiss}
className="flex-shrink-0 rounded-sm p-0.5 text-muted-foreground/50 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
title={i18n.t('newtab.tip.dismiss')}
title="Dismiss"
>
<X className="h-3 w-3" />
</button>

View File

@@ -1,4 +1,3 @@
import { i18n } from '#i18n'
import {
Dialog,
DialogContent,
@@ -26,10 +25,10 @@ export const ShortcutsDialog = ({
<DialogContent className="styled-scrollbar max-h-[80vh] max-w-xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-semibold text-2xl">
{i18n.t('newtab.shortcuts.title')}
Keyboard Shortcuts
</DialogTitle>
<DialogDescription>
{i18n.t('newtab.shortcuts.description')}
Use these shortcuts to navigate BrowserOS faster
</DialogDescription>
</DialogHeader>
@@ -60,7 +59,7 @@ export const ShortcutsDialog = ({
</div>
<div className="mt-8 border-border/50 border-t pt-4 text-center text-muted-foreground text-xs">
{i18n.t('newtab.shortcuts.moreComingSoon')}
More shortcuts coming soon
</div>
</DialogContent>
</Dialog>

View File

@@ -2,7 +2,6 @@ import { Cloud, X } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { i18n } from '#i18n'
import { Button } from '@/components/ui/button'
import {
Card,
@@ -48,9 +47,7 @@ export const SignInHint = () => {
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Cloud className="size-5 text-muted-foreground" />
<CardTitle className="text-base">
{i18n.t('newtab.signIn.title')}
</CardTitle>
<CardTitle className="text-base">Sync your data</CardTitle>
</div>
<Button
variant="ghost"
@@ -62,7 +59,7 @@ export const SignInHint = () => {
</Button>
</div>
<CardDescription>
{i18n.t('newtab.signIn.description')}
Sign in to sync conversation history to the cloud.
</CardDescription>
<label
htmlFor="sync-dont-ask-again"
@@ -75,10 +72,10 @@ export const SignInHint = () => {
setDontAskAgain(checked === true)
}
/>
{i18n.t('newtab.signIn.dontAskAgain')}
Don't ask again
</label>
<Button className="w-full" onClick={() => navigate('/login')}>
{i18n.t('newtab.signIn.signInButton')}
Sign in
</Button>
</CardHeader>
</Card>

View File

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

View File

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

View File

@@ -8,14 +8,9 @@ import {
SIDEPANEL_SUGGESTION_CLICKED_EVENT,
SIDEPANEL_TAB_REMOVED_EVENT,
SIDEPANEL_TAB_TOGGLED_EVENT,
SIDEPANEL_VOICE_ERROR_EVENT,
SIDEPANEL_VOICE_RECORDING_STARTED_EVENT,
SIDEPANEL_VOICE_RECORDING_STOPPED_EVENT,
SIDEPANEL_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
import { track } from '@/lib/metrics/track'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { useChatSessionContext } from '../layout/ChatSessionContext'
import { ChatEmptyState } from './ChatEmptyState'
import { ChatError } from './ChatError'
@@ -53,8 +48,6 @@ export const Chat = () => {
onDismiss: onDismissJtbdPopup,
} = useJtbdPopup()
const voice = useVoiceInput()
const [input, setInput] = useState('')
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
const [mounted, setMounted] = useState(false)
@@ -90,26 +83,6 @@ export const Chat = () => {
previousChatStatus.current = status
}, [status])
// Insert transcript into input when transcription completes
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setInput((prev) => {
const separator = prev.trim() ? ' ' : ''
return prev + separator + voice.transcript
})
track(SIDEPANEL_VOICE_TRANSCRIPTION_COMPLETED_EVENT)
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing])
// Track voice errors
useEffect(() => {
if (voice.error) {
track(SIDEPANEL_VOICE_ERROR_EVENT, { error: voice.error })
}
}, [voice.error])
const handleModeChange = (newMode: ChatMode) => {
track(SIDEPANEL_MODE_CHANGED_EVENT, { from: mode, to: newMode })
setMode(newMode)
@@ -174,27 +147,6 @@ export const Chat = () => {
executeMessage(suggestion)
}
const handleStartRecording = async () => {
const started = await voice.startRecording()
if (started) {
track(SIDEPANEL_VOICE_RECORDING_STARTED_EVENT)
}
}
const handleStopRecording = async () => {
await voice.stopRecording()
track(SIDEPANEL_VOICE_RECORDING_STOPPED_EVENT)
}
const voiceState = {
isRecording: voice.isRecording,
isTranscribing: voice.isTranscribing,
audioLevels: voice.audioLevels,
error: voice.error,
onStartRecording: handleStartRecording,
onStopRecording: handleStopRecording,
}
return (
<>
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
@@ -238,7 +190,6 @@ export const Chat = () => {
attachedTabs={attachedTabs}
onToggleTab={toggleTabSelection}
onRemoveTab={removeTab}
voice={voiceState}
/>
</>
)

View File

@@ -1,6 +1,5 @@
import { Globe, X } from 'lucide-react'
import type { FC } from 'react'
import { i18n } from '#i18n'
interface ChatAttachedTabsProps {
tabs: chrome.tabs.Tab[]
@@ -35,7 +34,7 @@ export const ChatAttachedTabs: FC<ChatAttachedTabsProps> = ({
type="button"
onClick={() => onRemoveTab(tab.id)}
className="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-background"
title={i18n.t('attachedTabs.removeTab')}
title="Remove tab"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>

View File

@@ -1,6 +1,5 @@
import { Sparkles } from 'lucide-react'
import type { FC } from 'react'
import { i18n } from '#i18n'
import { cn } from '@/lib/utils'
import { AGENT_SUGGESTIONS, CHAT_SUGGESTIONS, type ChatMode } from './chatTypes'
@@ -29,14 +28,12 @@ export const ChatEmptyState: FC<ChatEmptyStateProps> = ({
</div>
<div>
<h2 className="mb-1 font-semibold text-lg">
{mode === 'chat'
? i18n.t('chat.empty.chatTitle')
: i18n.t('chat.empty.agentTitle')}
{mode === 'chat' ? 'Chat with this page' : 'Agent at your service'}
</h2>
<p className="max-w-[200px] text-muted-foreground text-xs">
{mode === 'chat'
? i18n.t('chat.empty.chatSubtitle')
: i18n.t('chat.empty.agentSubtitle')}
? 'Ask questions about the current page or any topic'
: 'Let AI automate tasks and browse for you'}
</p>
</div>

View File

@@ -1,6 +1,5 @@
import { AlertCircle, RefreshCw } from 'lucide-react'
import type { FC } from 'react'
import { i18n } from '#i18n'
// import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import {
@@ -39,7 +38,7 @@ function parseErrorMessage(message: string): {
message.includes('127.0.0.1')
) {
return {
text: i18n.t('chat.error.connectionMessage'),
text: 'Unable to connect to BrowserOS agent. Follow below instructions.',
url: 'https://docs.browseros.com/troubleshooting/connection-issues',
isConnectionError: true,
}
@@ -48,7 +47,7 @@ function parseErrorMessage(message: string): {
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
if (message.includes('BrowserOS LLM daily limit reached')) {
return {
text: i18n.t('chat.error.rateLimitMessage'),
text: 'Add your own API key for unlimited usage.',
url: 'https://dub.sh/browseros-usage-limit',
isRateLimit: true,
}
@@ -84,9 +83,9 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
// --- End commented out survey code ---
const getTitle = () => {
if (isRateLimit) return i18n.t('chat.error.dailyLimitTitle')
if (isConnectionError) return i18n.t('chat.error.connectionFailedTitle')
return i18n.t('chat.error.genericTitle')
if (isRateLimit) return 'Daily limit reached'
if (isConnectionError) return 'Connection failed'
return 'Something went wrong'
}
return (
@@ -103,7 +102,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
rel="noopener noreferrer"
className="text-muted-foreground text-xs underline hover:text-foreground"
>
{i18n.t('chat.error.troubleshootingLink')}
View troubleshooting guide
</a>
)}
{/* --- Commented out for Kimi partnership launch (restore after) ---
@@ -140,7 +139,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
className="underline hover:text-foreground"
onClick={() => track(KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT)}
>
{i18n.t('chat.error.kimiApiKeyLink')}
Learn how to get a Kimi API key
</a>
{' or '}
<a
@@ -150,7 +149,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
className="underline hover:text-foreground"
onClick={() => track(KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT)}
>
{i18n.t('chat.error.getApiKey')}
get your API key
</a>
</p>
</div>
@@ -163,7 +162,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
className="mt-1 gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
{i18n.t('chat.error.tryAgain')}
Try again
</Button>
)}
</div>

View File

@@ -1,7 +1,6 @@
import { ChevronDown, Folder, Layers, PlugZap } from 'lucide-react'
import type { FC, FormEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { i18n } from '#i18n'
import { AppSelector } from '@/components/elements/AppSelector'
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
@@ -9,17 +8,12 @@ import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetU
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import {
type SelectedTextData,
selectedTextStorage,
} from '@/lib/selected-text/selectedTextStorage'
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import { cn } from '@/lib/utils'
import type { VoiceInputState } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ChatAttachedTabs } from './ChatAttachedTabs'
import { ChatInput, type ChatInputHandle } from './ChatInput'
import { ChatModeToggle } from './ChatModeToggle'
import { ChatSelectedText } from './ChatSelectedText'
import type { ChatMode } from './chatTypes'
interface ChatFooterProps {
@@ -33,7 +27,6 @@ interface ChatFooterProps {
attachedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
onRemoveTab: (tabId?: number) => void
voice?: VoiceInputState
}
export const ChatFooter: FC<ChatFooterProps> = ({
@@ -47,40 +40,13 @@ export const ChatFooter: FC<ChatFooterProps> = ({
attachedTabs,
onToggleTab,
onRemoveTab,
voice,
}) => {
const { selectedFolder } = useWorkspace()
const { supports } = useCapabilities()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
useSyncRemoteIntegrations()
const chatInputRef = useRef<ChatInputHandle>(null)
const [selectionMap, setSelectionMap] = useState<
Record<string, SelectedTextData>
>({})
const [activeTabId, setActiveTabId] = useState<number | undefined>()
// Track active tab for tab-scoped selection display
useEffect(() => {
chrome.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => setActiveTabId(tabs[0]?.id))
const listener = (activeInfo: { tabId: number }) => {
setActiveTabId(activeInfo.tabId)
}
chrome.tabs.onActivated.addListener(listener)
return () => chrome.tabs.onActivated.removeListener(listener)
}, [])
// Watch selected text storage (per-tab map)
useEffect(() => {
selectedTextStorage.getValue().then(setSelectionMap)
const unwatch = selectedTextStorage.watch(setSelectionMap)
return () => unwatch()
}, [])
const visibleSelectedText = activeTabId
? (selectionMap[String(activeTabId)] ?? null)
: null
const [isTabMentionOpen, setIsTabMentionOpen] = useState(false)
useEffect(() => {
@@ -114,19 +80,6 @@ export const ChatFooter: FC<ChatFooterProps> = ({
return (
<footer className="border-border/40 border-t bg-background/80 backdrop-blur-md">
<ChatAttachedTabs tabs={attachedTabs} onRemoveTab={onRemoveTab} />
{visibleSelectedText && (
<ChatSelectedText
selectedText={visibleSelectedText}
onDismiss={() => {
if (!activeTabId) return
const key = String(activeTabId)
selectedTextStorage.getValue().then((map) => {
const { [key]: _, ...rest } = map
selectedTextStorage.setValue(rest)
})
}}
/>
)}
<div className="p-3">
<div className="flex items-center gap-2">
@@ -143,7 +96,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
aria-expanded={isTabMentionOpen}
aria-haspopup="dialog"
className="flex cursor-pointer items-center gap-1 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title={i18n.t('footer.attachTabs')}
title="Attach tabs (@)"
>
<Layers className="h-4 w-4" />
{attachedTabs.length > 0 && (
@@ -167,7 +120,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
title={
selectedFolder
? selectedFolder.name
: i18n.t('footer.selectWorkspace')
: 'Select workspace folder'
}
>
<div className="relative">
@@ -186,7 +139,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
<button
type="button"
className="flex cursor-pointer items-center gap-1 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title={i18n.t('footer.connectApps')}
title="Connect apps"
>
{connectedManagedServers.length > 0 ? (
<>
@@ -219,10 +172,6 @@ export const ChatFooter: FC<ChatFooterProps> = ({
</div>
</div>
{voice?.error && (
<div className="mt-1 text-destructive text-xs">{voice.error}</div>
)}
<ChatInput
input={input}
status={status}
@@ -233,7 +182,6 @@ export const ChatFooter: FC<ChatFooterProps> = ({
selectedTabs={attachedTabs}
onToggleTab={onToggleTab}
onTabMentionOpenChange={setIsTabMentionOpen}
voice={voice}
ref={chatInputRef}
/>
</div>

View File

@@ -1,7 +1,6 @@
import { Github, History, Plus, SettingsIcon } from 'lucide-react'
import type { FC } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { i18n } from '#i18n'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { ThemeToggle } from '@/components/elements/theme-toggle'
@@ -15,7 +14,6 @@ interface ChatHeaderProps {
onSelectProvider: (provider: Provider) => void
onNewConversation: () => void
hasMessages: boolean
hideHistory?: boolean
}
export const ChatHeader: FC<ChatHeaderProps> = ({
@@ -24,7 +22,6 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
onSelectProvider,
onNewConversation,
hasMessages,
hideHistory,
}) => {
const location = useLocation()
const navigate = useNavigate()
@@ -47,7 +44,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
<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={i18n.t('chat.header.changeProvider')}
title="Change AI Provider"
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={18} />
@@ -70,38 +67,37 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
type="button"
onClick={onNewConversation}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title={i18n.t('chat.header.newConversation')}
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
)}
{!hideHistory &&
(isHistoryPage ? (
<button
type="button"
onClick={handleNewConversationFromHistory}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title={i18n.t('chat.header.newConversation')}
>
<Plus className="h-4 w-4" />
</button>
) : (
<Link
to="/history"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title={i18n.t('chat.header.chatHistory')}
>
<History className="h-4 w-4" />
</Link>
))}
{isHistoryPage ? (
<button
type="button"
onClick={handleNewConversationFromHistory}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
) : (
<Link
to="/history"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Chat history"
>
<History className="h-4 w-4" />
</Link>
)}
<a
href={productRepositoryUrl}
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={i18n.t('chat.header.starOnGithub')}
title="Star on Github"
>
<Github className="h-4 w-4" />
</a>
@@ -111,7 +107,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
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={i18n.t('chat.header.settings')}
title="Settings"
>
<SettingsIcon className="h-4 w-4" />
</a>

View File

@@ -1,4 +1,4 @@
import { Loader2, Mic, Send, Square, SquareStop } from 'lucide-react'
import { Send, SquareStop } from 'lucide-react'
import type { FormEvent, KeyboardEvent } from 'react'
import {
forwardRef,
@@ -8,10 +8,8 @@ import {
useRef,
useState,
} from 'react'
import { i18n } from '#i18n'
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
import { cn } from '@/lib/utils'
import type { VoiceInputState } from '@/lib/voice/useVoiceInput'
import type { ChatMode } from './chatTypes'
interface MentionState {
@@ -30,7 +28,6 @@ interface ChatInputProps {
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
onTabMentionOpenChange?: (isOpen: boolean) => void
voice?: VoiceInputState
}
export interface ChatInputHandle {
@@ -52,7 +49,6 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
selectedTabs,
onToggleTab,
onTabMentionOpenChange,
voice,
},
ref,
) => {
@@ -263,78 +259,6 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [mentionState.isOpen, closeMention])
const renderVoiceButton = () => {
if (!voice) return null
if (voice.isRecording) {
return (
<button
type="button"
onClick={voice.onStopRecording}
className="cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900"
>
<Square className="h-3.5 w-3.5" />
<span className="sr-only">
{i18n.t('chat.input.stopRecording')}
</span>
</button>
)
}
if (voice.isTranscribing) {
return (
<button
type="button"
disabled
className="rounded-full p-2 text-muted-foreground"
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="sr-only">{i18n.t('chat.input.transcribing')}</span>
</button>
)
}
return (
<button
type="button"
onClick={voice.onStartRecording}
disabled={isBusy}
className="cursor-pointer rounded-full p-2 text-muted-foreground transition-all duration-200 hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<Mic className="h-3.5 w-3.5" />
<span className="sr-only">{i18n.t('chat.input.voiceInput')}</span>
</button>
)
}
const renderSendButton = () => {
if (isBusy) {
return (
<button
type="button"
onClick={onStop}
className="cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900"
>
<SquareStop className="h-3.5 w-3.5" />
<span className="sr-only">{i18n.t('chat.input.stop')}</span>
</button>
)
}
return (
<button
type="submit"
disabled={
!input.trim() || voice?.isRecording || voice?.isTranscribing
}
className="cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
<span className="sr-only">{i18n.t('chat.input.send')}</span>
</button>
)
}
return (
<form
onSubmit={handleSubmit}
@@ -349,43 +273,38 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
onClose={closeMention}
anchorRef={textareaRef}
/>
{voice?.isRecording ? (
<div className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]">
{voice.audioLevels.map((level, i) => (
<div
key={i.toString()}
className="w-1 rounded-full bg-red-500 transition-all duration-75"
style={{
height: `${Math.max(4, Math.min(20, level * 0.6))}px`,
}}
/>
))}
</div>
<textarea
ref={textareaRef}
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) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
mode === 'chat' ? 'Ask about this page...' : 'What should I do?'
}
rows={1}
/>
{isBusy ? (
<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>
) : (
<textarea
ref={textareaRef}
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 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
voice ? 'pr-[4.5rem]' : 'pr-11',
)}
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
voice?.isTranscribing
? i18n.t('chat.input.placeholderTranscribing')
: mode === 'chat'
? i18n.t('chat.input.placeholderChat')
: i18n.t('chat.input.placeholderAgent')
}
disabled={voice?.isTranscribing}
rows={1}
/>
<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>
)}
<div className="absolute right-1.5 bottom-1.5 flex items-center gap-1">
{renderVoiceButton()}
{renderSendButton()}
</div>
</form>
)
},

View File

@@ -1,7 +1,6 @@
import { CheckIcon, CopyIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { type FC, useState } from 'react'
import { i18n } from '#i18n'
import { MessageAction, MessageActions } from '@/components/ai-elements/message'
import { Button } from '@/components/ui/button'
import {
@@ -64,8 +63,8 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
navigator.clipboard.writeText(messageText)
track(SIDEPANEL_MESSAGE_COPIED_EVENT)
}}
label={i18n.t('chat.actions.copy')}
tooltip={i18n.t('chat.actions.copyToClipboard')}
label="Copy"
tooltip="Copy to clipboard"
>
<CopyIcon className="size-3" />
</MessageAction>
@@ -80,7 +79,7 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
className="flex items-center gap-1 text-muted-foreground text-xs"
>
<CheckIcon className="size-3" />
<span>{i18n.t('chat.actions.feedbackSubmitted')}</span>
<span>Feedback submitted</span>
</motion.div>
) : (
<motion.div
@@ -92,9 +91,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
className="flex items-center gap-1"
>
<MessageAction
label={i18n.t('chat.actions.like')}
label="Like"
onClick={handleLike}
tooltip={i18n.t('chat.actions.likeTooltip')}
tooltip="Like this response"
>
<ThumbsUpIcon
className="size-4"
@@ -102,9 +101,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
/>
</MessageAction>
<MessageAction
label={i18n.t('chat.actions.dislike')}
label="Dislike"
onClick={handleDislikeClick}
tooltip={i18n.t('chat.actions.dislikeTooltip')}
tooltip="Dislike this response"
>
<ThumbsDownIcon
className="size-4"
@@ -118,13 +117,13 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
<Dialog open={dislikeDialogOpen} onOpenChange={setDislikeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{i18n.t('chat.actions.feedbackTitle')}</DialogTitle>
<DialogTitle>What went wrong?</DialogTitle>
<DialogDescription>
{i18n.t('chat.actions.feedbackDescription')}
Help us improve by sharing what was wrong with this response.
</DialogDescription>
</DialogHeader>
<Input
placeholder={i18n.t('chat.actions.feedbackPlaceholder')}
placeholder="Add a comment (optional)"
value={dislikeComment}
onChange={(e) => setDislikeComment(e.target.value)}
onKeyDown={(e) => {
@@ -135,11 +134,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
/>
<DialogFooter>
<Button variant="outline" onClick={handleDislikeCancel}>
{i18n.t('common.cancel')}
</Button>
<Button onClick={handleDislikeSubmit}>
{i18n.t('common.submit')}
Cancel
</Button>
<Button onClick={handleDislikeSubmit}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,6 +1,5 @@
import { MessageSquare, MousePointer2 } from 'lucide-react'
import type { FC } from 'react'
import { i18n } from '#i18n'
import {
Tooltip,
TooltipContent,
@@ -38,20 +37,20 @@ export const ChatModeToggle: FC<ChatModeToggleProps> = ({
{isAgentMode ? (
<>
<MousePointer2 className="h-3 w-3" />
<span>{i18n.t('chat.mode.agent')}</span>
<span>Agent Mode ON</span>
</>
) : (
<>
<MessageSquare className="h-3 w-3" />
<span>{i18n.t('chat.mode.chat')}</span>
<span>Chat Mode ON</span>
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px]">
{isAgentMode
? i18n.t('chat.mode.agentTooltip')
: i18n.t('chat.mode.chatTooltip')}
? 'AI can browse, click, and navigate'
: 'AI can only read, cannot click or navigate'}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

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

View File

@@ -1,7 +1,6 @@
import { Check, Plug } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { i18n } from '#i18n'
import { Button } from '@/components/ui/button'
import {
BREADCRUMB_CONNECT_CLICKED_EVENT,
@@ -39,11 +38,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
apiKeyUrl: string
} | null>(null)
const [resolvedText, setResolvedText] = useState(
isLastMessage
? ''
: i18n.t('chat.connectApp.suggested', [
(data.appName as string) ?? 'App',
]),
isLastMessage ? '' : `${(data.appName as string) ?? 'App'} suggested`,
)
const { sendMessage } = useChatSessionContext()
@@ -115,11 +110,9 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
managedServerDescription: '',
})
track(MANAGED_MCP_ADDED_EVENT, { server_name: appName })
toast.success(
i18n.t('chat.connectApp.connectedSuccess', [apiKeyServer.name]),
)
toast.success(`${apiKeyServer.name} connected successfully`)
setApiKeyServer(null)
setResolvedText(i18n.t('chat.connectApp.connected', [appName]))
setResolvedText(`Connected ${appName}`)
setPhase('resolved')
sendMessage({
text: `I've connected ${appName}, continue with the task`,
@@ -134,7 +127,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
const handleOAuthComplete = () => {
track(BREADCRUMB_CONNECT_COMPLETED_EVENT, { app_name: appName })
setResolvedText(i18n.t('chat.connectApp.connected', [appName]))
setResolvedText(`Connected ${appName}`)
setPhase('resolved')
sendMessage({
text: `I've connected ${appName}, continue with the task`,
@@ -147,7 +140,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
if (!current.includes(appName)) {
await declinedAppsStorage.setValue([...current, appName])
}
setResolvedText(i18n.t('chat.connectApp.continuedWithout', [appName]))
setResolvedText(`Continuing without ${appName}`)
setPhase('resolved')
sendMessage({
text: `Continue without connecting ${appName}, do it manually with browser automation`,
@@ -172,20 +165,20 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
<Plug className="h-5 w-5 shrink-0 text-[var(--accent-orange)]" />
<div>
<p className="font-medium text-sm">
{i18n.t('chat.connectApp.authorizeTitle', [appName])}
Authorize {appName} in the opened tab
</p>
<p className="mt-1 text-muted-foreground text-xs">
{i18n.t('chat.connectApp.authorizeDescription')}
Complete the sign-in flow, then click the button below.
</p>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={handleOAuthComplete}>
{i18n.t('chat.connectApp.authorizedButton', [appName])}
I've authorized {appName}, continue
</Button>
<Button size="sm" variant="ghost" onClick={handleManual}>
{i18n.t('chat.connectApp.skipButton')}
Skip, do it manually
</Button>
</div>
</div>
@@ -199,7 +192,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
<Plug className="h-5 w-5 shrink-0 text-[var(--accent-orange)]" />
<div>
<p className="font-medium text-sm">
{i18n.t('chat.connectApp.connectForBetter', [appName])}
Connect {appName} for better results
</p>
{reason && (
<p className="mt-1 text-muted-foreground text-xs">{reason}</p>
@@ -209,12 +202,10 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={handleConnect} disabled={connecting}>
{connecting
? i18n.t('chat.connectApp.connecting')
: i18n.t('chat.connectApp.connectButton', [appName])}
{connecting ? 'Connecting...' : `Connect ${appName}`}
</Button>
<Button size="sm" variant="ghost" onClick={handleManual}>
{i18n.t('chat.connectApp.doItManually')}
Do it manually
</Button>
</div>
</div>

View File

@@ -1,6 +1,5 @@
import { Clock, X } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { i18n } from '#i18n'
import { Button } from '@/components/ui/button'
import {
BREADCRUMB_SCHEDULE_CLICKED_EVENT,
@@ -41,9 +40,7 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
if (dismissed) return null
const scheduleLabel =
scheduleType === 'daily'
? i18n.t('chat.schedule.dailyAt', [scheduleTime])
: i18n.t('chat.schedule.everyHour')
scheduleType === 'daily' ? `daily at ${scheduleTime}` : 'every hour'
const handleSchedule = () => {
track(BREADCRUMB_SCHEDULE_CLICKED_EVENT, {
@@ -78,7 +75,7 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
<div className="flex items-start gap-3 pr-6">
<Clock className="h-5 w-5 shrink-0 text-[var(--accent-orange)]" />
<div>
<p className="font-medium text-sm">{i18n.t('chat.schedule.title')}</p>
<p className="font-medium text-sm">Run this automatically?</p>
<p className="mt-1 text-muted-foreground text-xs">
&ldquo;{suggestedName}&rdquo; &mdash; I can run this {scheduleLabel}
</p>
@@ -87,10 +84,10 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={handleSchedule}>
{i18n.t('chat.schedule.scheduleButton')}
Schedule this task
</Button>
<Button size="sm" variant="ghost" onClick={handleDismiss}>
{i18n.t('chat.schedule.maybeLater')}
Maybe later
</Button>
</div>
</div>

View File

@@ -27,7 +27,6 @@ import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { track } from '@/lib/metrics/track'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
@@ -71,8 +70,6 @@ export type ChatOrigin = 'sidepanel' | 'newtab'
export interface ChatSessionOptions {
origin?: ChatOrigin
/** When false, messages are queued until integrations finish syncing. */
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.`
@@ -166,34 +163,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const modeRef = useRef<ChatMode>(mode)
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
const workingDirRef = useRef<string | undefined>(undefined)
const selectionMapRef = useRef<
Record<string, { text: string; url: string; title: string }>
>({})
const pendingSelectionTabKeyRef = useRef<string | null>(null)
const messagesRef = useRef<UIMessage[]>([])
useEffect(() => {
const toRef = (
map: Record<string, { text: string; pageUrl: string; pageTitle: string }>,
) => {
const result: Record<
string,
{ text: string; url: string; title: string }
> = {}
for (const [k, v] of Object.entries(map)) {
result[k] = { text: v.text, url: v.pageUrl, title: v.pageTitle }
}
return result
}
selectedTextStorage.getValue().then((map) => {
selectionMapRef.current = toRef(map)
})
const unwatchText = selectedTextStorage.watch((map) => {
selectionMapRef.current = toRef(map)
})
return () => unwatchText()
}, [])
useEffect(() => {
selectedWorkspaceStorage.getValue().then((folder) => {
workingDirRef.current = folder?.path
@@ -237,12 +208,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
currentWindow: true,
})
const activeTab = activeTabsList?.[0] ?? undefined
const activeTabSelection = activeTab?.id
? (selectionMapRef.current[String(activeTab.id)] ?? null)
: null
const message = getLastMessageText(messages)
const provider =
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
const provider = selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
const currentMode = modeRef.current
const enabledMcpServers = enabledMcpServersRef.current
const customMcpServers = enabledCustomServersRef.current
@@ -317,7 +284,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
: undefined
const result = {
return {
api: `${agentUrlRef.current}/chat`,
body: {
message,
@@ -338,9 +305,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
// ChatGPT Pro (Codex)
reasoningEffort: provider?.reasoningEffort,
reasoningSummary: provider?.reasoningSummary,
browserContext,
userSystemPrompt:
options?.origin === 'newtab'
@@ -352,21 +316,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
supportsImages: provider?.supportsImages,
previousConversation,
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
selectedText: activeTabSelection?.text,
selectedTextSource: activeTabSelection
? {
url: activeTabSelection.url,
title: activeTabSelection.title,
}
: undefined,
},
}
// Track which tab's selection was sent so we can clear it on success
pendingSelectionTabKeyRef.current =
activeTabSelection && activeTab?.id ? String(activeTab.id) : null
return result
},
}),
})
@@ -460,19 +411,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (!justFinished) return
// Clear the selected text that was sent with this request
const tabKey = pendingSelectionTabKeyRef.current
if (tabKey) {
pendingSelectionTabKeyRef.current = null
delete selectionMapRef.current[tabKey]
selectedTextStorage.getValue().then((map) => {
if (map[tabKey]) {
const { [tabKey]: _, ...rest } = map
selectedTextStorage.setValue(rest)
}
})
}
const messagesToSave = messages.filter((m) => m.parts?.length > 0)
if (messagesToSave.length === 0) return
@@ -483,47 +421,12 @@ export const useChatSession = (options?: ChatSessionOptions) => {
}
}, [status])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{
text: string
action?: ChatAction
} | null>(null)
useEffect(() => {
isIntegrationsSyncedRef.current = isIntegrationsSynced
}, [isIntegrationsSynced])
// Flush pending message when integrations sync completes
useEffect(() => {
if (isIntegrationsSynced && pendingMessageRef.current) {
const pending = pendingMessageRef.current
pendingMessageRef.current = null
if (pending.action) {
setTextToAction((prev) => {
const next = new Map(prev)
// biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above
next.set(pending.text, pending.action!)
return next
})
}
baseSendMessage({ text: pending.text })
}
}, [isIntegrationsSynced, baseSendMessage])
const sendMessage = (params: { text: string; action?: ChatAction }) => {
track(MESSAGE_SENT_EVENT, {
mode,
provider_type: selectedLlmProvider?.type,
model: selectedLlmProvider?.modelId,
})
if (!isIntegrationsSyncedRef.current) {
// Queue the message — will be sent when sync completes
pendingMessageRef.current = params
return
}
if (params.action) {
const action = params.action
setTextToAction((prev) => {
@@ -600,7 +503,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
providers,
selectedProvider,
isLoading: isLoadingProviders || isLoadingAgentUrl,
isSyncing: !isIntegrationsSynced,
isRestoringConversation,
agentUrlError,
chatError,

View File

@@ -1,5 +1,4 @@
import { createContext, type FC, type ReactNode, useContext } from 'react'
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import {
type ChatSessionOptions,
useChatSession,
@@ -12,11 +11,7 @@ const ChatSessionContext = createContext<ChatSessionContextValue | null>(null)
export const ChatSessionProvider: FC<
{ children: ReactNode } & ChatSessionOptions
> = ({ children, ...options }) => {
const { hasSynced } = useSyncRemoteIntegrations()
const session = useChatSession({
...options,
isIntegrationsSynced: hasSynced,
})
const session = useChatSession(options)
return (
<ChatSessionContext.Provider value={session}>
{children}

View File

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

View File

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

View File

@@ -29,30 +29,6 @@ export const CONVERSATION_RESET_EVENT = 'ui.conversation.reset'
/** @public */
export const AI_PROVIDER_ADDED_EVENT = 'settings.ai_provider.added'
/** @public */
export const CHATGPT_PRO_OAUTH_STARTED_EVENT =
'settings.chatgpt_pro.oauth_started'
/** @public */
export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT =
'settings.chatgpt_pro.oauth_completed'
/** @public */
export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT =
'settings.chatgpt_pro.oauth_disconnected'
/** @public */
export const GITHUB_COPILOT_OAUTH_STARTED_EVENT =
'settings.github_copilot.oauth_started'
/** @public */
export const GITHUB_COPILOT_OAUTH_COMPLETED_EVENT =
'settings.github_copilot.oauth_completed'
/** @public */
export const GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT =
'settings.github_copilot.oauth_disconnected'
/** @public */
export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
@@ -80,10 +56,6 @@ export const SCHEDULED_TASK_DELETED_EVENT = 'settings.scheduled_task.deleted'
/** @public */
export const SCHEDULED_TASK_TOGGLED_EVENT = 'settings.scheduled_task.toggled'
/** @public */
export const SCHEDULED_TASK_PROMPT_REFINED_EVENT =
'settings.scheduled_task.prompt_refined'
/** @public */
export const SCHEDULED_TASK_TESTED_EVENT = 'settings.scheduled_task.tested'
@@ -142,21 +114,6 @@ export const NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT =
/** @public */
export const NEWTAB_CHAT_MODE_CHANGED_EVENT = 'newtab.chat.mode_changed'
/** @public */
export const NEWTAB_VOICE_RECORDING_STARTED_EVENT =
'newtab.voice.recording_started'
/** @public */
export const NEWTAB_VOICE_RECORDING_STOPPED_EVENT =
'newtab.voice.recording_stopped'
/** @public */
export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
'newtab.voice.transcription_completed'
/** @public */
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
/** @public */
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
@@ -294,18 +251,3 @@ export const KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT =
/** @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'
/** @public */
export const SIDEPANEL_VOICE_RECORDING_STOPPED_EVENT =
'sidepanel.voice.recording_stopped'
/** @public */
export const SIDEPANEL_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
'sidepanel.voice.transcription_completed'
/** @public */
export const SIDEPANEL_VOICE_ERROR_EVENT = 'sidepanel.voice.error'

View File

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

View File

@@ -20,24 +20,6 @@ export interface ProviderTemplate {
* @public
*/
export const providerTemplates: ProviderTemplate[] = [
{
id: 'chatgpt-pro',
name: 'ChatGPT Plus/Pro',
defaultBaseUrl: 'https://chatgpt.com/backend-api',
defaultModelId: 'gpt-5.3-codex',
supportsImages: true,
contextWindow: 400000,
setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth',
},
{
id: 'github-copilot',
name: 'GitHub Copilot',
defaultBaseUrl: 'https://api.githubcopilot.com',
defaultModelId: 'gpt-5-mini',
supportsImages: true,
contextWindow: 128000,
setupGuideUrl: 'https://docs.browseros.com/features/github-copilot-oauth',
},
{
id: 'moonshot',
name: 'Moonshot AI',
@@ -147,8 +129,6 @@ export const providerTemplates: ProviderTemplate[] = [
* @public
*/
export const providerTypeOptions: { value: ProviderType; label: string }[] = [
{ value: 'chatgpt-pro', label: 'ChatGPT Plus/Pro' },
{ value: 'github-copilot', label: 'GitHub Copilot' },
{ value: 'moonshot', label: 'Moonshot AI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
@@ -177,8 +157,6 @@ export const getProviderTemplate = (
* Auto-fills when user selects a provider type
*/
export const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
'chatgpt-pro': 'https://chatgpt.com/backend-api',
'github-copilot': 'https://api.githubcopilot.com',
moonshot: 'https://api.moonshot.ai/v1',
anthropic: 'https://api.anthropic.com/v1',
openai: 'https://api.openai.com/v1',

View File

@@ -14,8 +14,6 @@ export type ProviderType =
| 'bedrock'
| 'browseros'
| 'moonshot'
| 'chatgpt-pro'
| 'github-copilot'
/**
* LLM Provider configuration
@@ -58,10 +56,6 @@ export interface LlmProviderConfig {
region?: string
/** AWS session token (for temporary STS credentials) */
sessionToken?: string
// ChatGPT Pro (Codex) fields
reasoningEffort?: 'none' | 'low' | 'medium' | 'high'
reasoningSummary?: 'auto' | 'concise' | 'detailed'
}
/**

View File

@@ -158,7 +158,9 @@ export function useLlmProviders(): UseLlmProvidersReturn {
// Fall back to first provider if defaultProviderId is stale/invalid
const selectedProvider = useMemo(
() =>
providers.find((p) => p.id === defaultProviderId) ?? providers[0] ?? null,
providers.find((p) => p.id === defaultProviderId) ??
providers[0] ??
null,
[providers, defaultProviderId],
)

View File

@@ -1,90 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface OAuthStatus {
authenticated: boolean
email?: string
provider: string
}
interface UseOAuthStatusReturn {
status: OAuthStatus | null
isPolling: boolean
startPolling: () => void
stopPolling: () => void
refresh: () => Promise<OAuthStatus | null>
disconnect: () => Promise<void>
}
export function useOAuthStatus(provider: string): UseOAuthStatusReturn {
const [status, setStatus] = useState<OAuthStatus | null>(null)
const [isPolling, setIsPolling] = useState(false)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
async function fetchStatus(): Promise<OAuthStatus | null> {
try {
const serverUrl = await getAgentServerUrl()
const res = await fetch(`${serverUrl}/oauth/${provider}/status`)
if (!res.ok) return null
const data = (await res.json()) as OAuthStatus
setStatus(data)
return data
} catch {
return null
}
}
function stopPolling() {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current)
if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current)
pollIntervalRef.current = null
pollTimeoutRef.current = null
setIsPolling(false)
}
function startPolling() {
stopPolling()
setIsPolling(true)
pollIntervalRef.current = setInterval(async () => {
const result = await fetchStatus()
if (result?.authenticated) {
stopPolling()
}
}, 2_000)
pollTimeoutRef.current = setTimeout(stopPolling, 300_000)
}
async function disconnect() {
try {
const serverUrl = await getAgentServerUrl()
await fetch(`${serverUrl}/oauth/${provider}`, { method: 'DELETE' })
setStatus({ authenticated: false, provider })
} catch {
// Best-effort disconnect
}
}
// Initial status check on mount
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
useEffect(() => {
fetchStatus()
}, [])
// Cleanup on unmount
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
useEffect(() => {
return () => stopPolling()
}, [])
return {
status,
isPolling,
startPolling,
stopPolling,
refresh: fetchStatus,
disconnect,
}
}

View File

@@ -1,15 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { useGetMCPServersList } from '@/entrypoints/app/connect-mcp/useGetMCPServersList'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
import { type McpServer, mcpServerStorage } from './mcpServerStorage'
export interface SyncStatus {
/** True while the initial sync is in progress (fetching + writing to storage) */
isSyncing: boolean
/** True once the sync has completed at least once this session */
hasSynced: boolean
}
/**
* Syncs remote Klavis integrations into local Chrome storage.
*
@@ -19,10 +12,8 @@ export interface SyncStatus {
*
* This hook detects authenticated remote integrations missing from local storage
* and adds them so they appear in the UI (and can be disconnected).
*
* Returns sync status so consumers can gate behavior on sync completion.
*/
export function useSyncRemoteIntegrations(): SyncStatus {
export function useSyncRemoteIntegrations() {
const { data: userMCPIntegrations, isLoading: isIntegrationsLoading } =
useGetUserMCPIntegrations()
const { data: serversList } = useGetMCPServersList()
@@ -30,26 +21,13 @@ export function useSyncRemoteIntegrations(): SyncStatus {
const serversListRef = useRef(serversList)
integrationsRef.current = userMCPIntegrations
serversListRef.current = serversList
const hasSyncedRef = useRef(false)
const [syncState, setSyncState] = useState<SyncStatus>({
isSyncing: true,
hasSynced: false,
})
const hasSynced = useRef(false)
const integrationCount = userMCPIntegrations?.integrations?.length ?? 0
useEffect(() => {
// Still loading data — keep isSyncing: true
if (isIntegrationsLoading) return
// No integrations at all — nothing to sync, mark done
if (!integrationCount) {
setSyncState({ isSyncing: false, hasSynced: true })
return
}
// Already synced this session
if (hasSyncedRef.current) return
if (isIntegrationsLoading || !integrationCount) return
if (hasSynced.current) return
const integrations = integrationsRef.current?.integrations
if (!integrations) return
@@ -62,30 +40,26 @@ export function useSyncRemoteIntegrations(): SyncStatus {
!localServers.some((s) => s.managedServerName === remote.name),
)
if (missing.length > 0) {
const catalog = serversListRef.current
const newServers: McpServer[] = missing.map((integration) => {
const catalogEntry = catalog?.servers.find(
(s) => s.name === integration.name,
)
return {
id: `${Date.now()}-${integration.name}`,
displayName: integration.name,
type: 'managed',
managedServerName: integration.name,
managedServerDescription: catalogEntry?.description ?? '',
}
})
if (missing.length === 0) return
await mcpServerStorage.setValue([...localServers, ...newServers])
}
const catalog = serversListRef.current
const newServers: McpServer[] = missing.map((integration) => {
const catalogEntry = catalog?.servers.find(
(s) => s.name === integration.name,
)
return {
id: `${Date.now()}-${integration.name}`,
displayName: integration.name,
type: 'managed',
managedServerName: integration.name,
managedServerDescription: catalogEntry?.description ?? '',
}
})
hasSyncedRef.current = true
setSyncState({ isSyncing: false, hasSynced: true })
await mcpServerStorage.setValue([...localServers, ...newServers])
}
hasSynced.current = true
syncMissing()
}, [isIntegrationsLoading, integrationCount])
return syncState
}

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,6 @@ interface ChatServerRequest {
windowId?: number
activeTab?: ActiveTab
signal?: AbortSignal
providerId?: string
}
interface ChatServerResponse {
@@ -76,23 +75,11 @@ const getDefaultProvider = async (): Promise<LlmProviderConfig | null> => {
return defaultProvider ?? providers[0] ?? null
}
// Resolve provider by ID, falling back to global default
const resolveProvider = async (
providerId?: string,
): Promise<LlmProviderConfig> => {
if (providerId) {
const providers = await providersStorage.getValue()
const match = providers?.find((p) => p.id === providerId)
if (match) return match
}
return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
}
export async function getChatServerResponse(
request: ChatServerRequest,
): Promise<ChatServerResponse> {
const agentServerUrl = await getAgentServerUrl()
const provider = await resolveProvider(request.providerId)
const provider = (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
const conversationId = request.conversationId ?? crypto.randomUUID()
const personalization = await personalizationStorage.getValue()

View File

@@ -11,7 +11,6 @@ export const GetScheduledJobsByProfileIdDocument = graphql(`
scheduleTime
scheduleInterval
enabled
llmProviderId
createdAt
updatedAt
lastRunAt

View File

@@ -1,71 +0,0 @@
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import {
createDefaultBrowserOSProvider,
defaultProviderIdStorage,
providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
const resolveProvider = async (
providerId?: string,
): Promise<LlmProviderConfig> => {
const providers = await providersStorage.getValue()
if (providerId && providers?.length) {
const match = providers.find((p) => p.id === providerId)
if (match) return match
}
if (providers?.length) {
const defaultProviderId = await defaultProviderIdStorage.getValue()
const defaultProvider = providers.find((p) => p.id === defaultProviderId)
if (defaultProvider) return defaultProvider
if (providers[0]) return providers[0]
}
return createDefaultBrowserOSProvider()
}
interface RefinePromptResponse {
success: boolean
refined?: string
message?: string
}
export async function refinePrompt(params: {
prompt: string
name: string
providerId?: string
}): Promise<string> {
const agentServerUrl = await getAgentServerUrl()
const provider = await resolveProvider(params.providerId)
const response = await fetch(`${agentServerUrl}/refine-prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: params.prompt,
name: params.name,
provider: provider.type,
model: provider.modelId ?? 'default',
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
resourceName: provider.resourceName,
accessKeyId: provider.accessKeyId,
secretAccessKey: provider.secretAccessKey,
region: provider.region,
sessionToken: provider.sessionToken,
}),
})
if (!response.ok) {
const errorData = (await response
.json()
.catch(() => null)) as RefinePromptResponse | null
throw new Error(errorData?.message ?? `Request failed: ${response.status}`)
}
const data = (await response.json()) as RefinePromptResponse
if (!data.success || !data.refined) {
throw new Error(data.message ?? 'Failed to refine prompt')
}
return data.refined
}

View File

@@ -6,7 +6,6 @@ export interface ScheduledJob {
scheduleTime?: string
scheduleInterval?: number
enabled: boolean
providerId?: string
createdAt: string
updatedAt: string
lastRunAt?: string

View File

@@ -19,7 +19,6 @@ type RemoteScheduledJob = {
scheduleTime: string | null
scheduleInterval: number | null
enabled: boolean
llmProviderId: string | null
createdAt: string
updatedAt: string
lastRunAt: string | null
@@ -33,7 +32,6 @@ function toComparable(job: ScheduledJob) {
...data,
scheduleTime: data.scheduleTime ?? null,
scheduleInterval: data.scheduleInterval ?? null,
providerId: data.providerId ?? null,
}
}
@@ -45,7 +43,6 @@ function remoteToComparable(job: RemoteScheduledJob) {
scheduleTime: job.scheduleTime,
scheduleInterval: job.scheduleInterval,
enabled: job.enabled,
providerId: job.llmProviderId,
}
}
@@ -62,7 +59,6 @@ function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
scheduleTime: remote.scheduleTime ?? undefined,
scheduleInterval: remote.scheduleInterval ?? undefined,
enabled: remote.enabled,
providerId: remote.llmProviderId ?? undefined,
createdAt: normalizeTimestamp(remote.createdAt),
updatedAt: normalizeTimestamp(remote.updatedAt),
lastRunAt: remote.lastRunAt
@@ -167,7 +163,6 @@ export async function syncSchedulesToBackend(
scheduleTime: job.scheduleTime ?? null,
scheduleInterval: job.scheduleInterval ?? null,
enabled: job.enabled,
llmProviderId: job.providerId ?? null,
lastRunAt: job.lastRunAt
? new Date(job.lastRunAt).toISOString()
: null,
@@ -187,7 +182,6 @@ export async function syncSchedulesToBackend(
scheduleTime: job.scheduleTime ?? null,
scheduleInterval: job.scheduleInterval ?? null,
enabled: job.enabled,
llmProviderId: job.providerId ?? null,
createdAt: new Date(job.createdAt).toISOString(),
updatedAt: job.updatedAt || new Date().toISOString(),
lastRunAt: job.lastRunAt

View File

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

View File

@@ -1,134 +0,0 @@
extName: BrowserOS-Assistent
extActionTitle: BrowserOS fragen
chat:
input:
placeholderAgent: Was soll ich tun?
placeholderChat: Frage zu dieser Seite...
placeholderTranscribing: Transkribieren...
send: Senden
stop: Stop
stopRecording: Aufnahme stoppen
transcribing: Transkribieren
voiceInput: Spracheingabe
mode:
agent: Agent-Modus AN
chat: Chat-Modus AN
agentTooltip: KI kann browsen, klicken und navigieren
chatTooltip: KI kann nur lesen, nicht klicken oder navigieren
empty:
agentTitle: Agent zu Ihren Diensten
agentSubtitle: KI automatisiert Aufgaben und browset für Sie
chatTitle: Mit dieser Seite chatten
chatSubtitle: Fragen zur aktuellen Seite oder einem beliebigen Thema stellen
header:
changeProvider: KI-Anbieter wechseln
newConversation: Neues Gespräch
chatHistory: Chat-Verlauf
starOnGithub: Auf Github starren
settings: Einstellungen
error:
dailyLimitTitle: Tageslimit erreicht
connectionFailedTitle: Verbindung fehlgeschlagen
genericTitle: Fehler aufgetreten
connectionMessage: Verbindung zu BrowserOS-Agent nicht möglich. Folgen Sie den Anweisungen unten.
rateLimitMessage: Eigenen API-Key hinzufügen für unbegrenzte Nutzung.
troubleshootingLink: Fehlerbehebung anzeigen
kimiApiKeyLink: Anleitung zum Kimi API-Key
getApiKey: API-Key erhalten
tryAgain: Erneut versuchen
actions:
copy: Kopieren
copyToClipboard: In Zwischenablage kopieren
feedbackSubmitted: Feedback gesendet
like: Gefällt mir
likeTooltip: Antwort gefällt mir
dislike: Gefällt nicht
dislikeTooltip: Antwort gefällt nicht
feedbackTitle: Was ist schiefgelaufen?
feedbackDescription: Teilen Sie uns mit, was an dieser Antwort nicht stimmte.
feedbackPlaceholder: Kommentar (optional)
schedule:
title: Automatisch ausführen?
dailyAt: täglich um $1
everyHour: jede Stunde
scheduleButton: Aufgabe planen
maybeLater: Später vielleicht
connectApp:
connecting: Verbinden...
connectButton: $1 verbinden
authorizeTitle: $1 im geöffneten Tab autorisieren
authorizeDescription: Anmeldung abschließen, dann Button klicken.
authorizedButton: $1 autorisiert, weiter
skipButton: Überspringen, manuell
suggested: $1 vorgeschlagen
connected: $1 verbunden
continuedWithout: Ohne $1 fortfahren
connectForBetter: $1 für bessere Ergebnisse verbinden
doItManually: Manuell erledigen
connectedSuccess: $1 erfolgreich verbunden
connectFailed: Verbindung zu $1 fehlgeschlagen
footer:
attachTabs: Tabs anhängen (@)
selectWorkspace: Arbeitsbereich wählen
connectApps: Apps verbinden
selectedText:
removeTitle: Textauswahl entfernen
attachedTabs:
removeTab: Tab entfernen
newtab:
search:
placeholder: BrowserOS fragen oder $1 suchen...
branding:
alt: BrowserOS
selectedTab: Tab
appsNew: Neu!
appsTooltip: Direkt verbundene Apps liefern genauere und schnellere Antworten!
addWorkspace: Arbeitsbereich hinzufügen
tip:
label: "Tipp:"
nextTip: Nächster Tipp
dismiss: Schließen
signIn:
title: Daten synchronisieren
description: Anmelden zum Synchronisieren des Chat-Verlaufs.
dontAskAgain: Nicht wieder fragen
signInButton: Anmelden
importData:
title: Daten importieren
description: Lesezeichen, Verlauf und Passwörter aus Chrome importieren.
dontShowAgain: Nicht mehr anzeigen
openSettings: Importeinstellungen öffnen
shortcuts:
title: Tastenkürzel
description: Diese Kürzel für schnellere Navigation in BrowserOS
moreComingSoon: Weitere Kürzel folgen
sidebar:
nav:
home: Start
connectApps: Apps verbinden
scheduledTasks: Geplante Aufgaben
workflows: Workflows
skills: Skills
memory: Speicher
soul: Seele
settings: Einstellungen
footer:
about: Über BrowserOS
shortcuts: Kürzel
branding:
alt: BrowserOS
personal: Persönlich
updateProfile: Profil aktualisieren
signOut: Abmelden
signIn: Anmelden
common:
cancel: Abbrechen
submit: Absenden
save: Speichern
delete: Löschen
edit: Bearbeiten
close: Schließen
loading: Laden...
error: Fehler aufgetreten
apps: Apps
tabs: Tabs

View File

@@ -1,169 +0,0 @@
# Extension manifest
extName: BrowserOS Assistant
extActionTitle: Ask BrowserOS
# Chat input
chat:
input:
placeholderAgent: What should I do?
placeholderChat: Ask about this page...
placeholderTranscribing: Transcribing...
send: Send
stop: Stop
stopRecording: Stop recording
transcribing: Transcribing
voiceInput: Voice input
# Chat mode toggle
mode:
agent: Agent Mode ON
chat: Chat Mode ON
agentTooltip: AI can browse, click, and navigate
chatTooltip: AI can only read, cannot click or navigate
# Chat empty state
empty:
agentTitle: Agent at your service
agentSubtitle: Let AI automate tasks and browse for you
chatTitle: Chat with this page
chatSubtitle: Ask questions about the current page or any topic
# Chat header
header:
changeProvider: Change AI Provider
newConversation: New conversation
chatHistory: Chat history
starOnGithub: Star on Github
settings: Settings
# Chat errors
error:
dailyLimitTitle: Daily limit reached
connectionFailedTitle: Connection failed
genericTitle: Something went wrong
connectionMessage: "Unable to connect to BrowserOS agent. Follow below instructions."
rateLimitMessage: Add your own API key for unlimited usage.
troubleshootingLink: View troubleshooting guide
kimiApiKeyLink: Learn how to get a Kimi API key
getApiKey: get your API key
tryAgain: Try again
# Chat message actions
actions:
copy: Copy
copyToClipboard: Copy to clipboard
feedbackSubmitted: Feedback submitted
like: Like
likeTooltip: Like this response
dislike: Dislike
dislikeTooltip: Dislike this response
feedbackTitle: What went wrong?
feedbackDescription: Help us improve by sharing what was wrong with this response.
feedbackPlaceholder: Add a comment (optional)
# Schedule suggestion card
schedule:
title: Run this automatically?
dailyAt: "daily at $1"
everyHour: every hour
scheduleButton: Schedule this task
maybeLater: Maybe later
# Connect app card
connectApp:
connecting: Connecting...
connectButton: "Connect $1"
authorizeTitle: "Authorize $1 in the opened tab"
authorizeDescription: Complete the sign-in flow, then click the button below.
authorizedButton: "I've authorized $1, continue"
skipButton: "Skip, do it manually"
suggested: "$1 suggested"
connected: "Connected $1"
continuedWithout: "Continuing without $1"
connectForBetter: "Connect $1 for better results"
doItManually: Do it manually
connectedSuccess: "$1 connected successfully"
connectFailed: "Failed to connect $1"
# Chat footer
footer:
attachTabs: Attach tabs (@)
selectWorkspace: Select workspace folder
connectApps: Connect apps
# Selected text card
selectedText:
removeTitle: Remove selected text
# Attached tabs
attachedTabs:
removeTab: Remove tab
# Newtab page
newtab:
search:
placeholder: "Ask BrowserOS or search $1..."
branding:
alt: BrowserOS
selectedTab: Tab
appsNew: "New!"
appsTooltip: Apps directly connected will have more accurate and faster responses for your queries!
addWorkspace: Add workspace
tip:
label: "Tip:"
nextTip: Next tip
dismiss: Dismiss
# Sign in hint
signIn:
title: Sync your data
description: Sign in to sync conversation history to the cloud.
dontAskAgain: Don't ask again
signInButton: Sign in
# Import data hint
importData:
title: Import your data
description: "Bring bookmarks, history, and passwords from Chrome."
dontShowAgain: Don't show this again
openSettings: Open Import Settings
# Shortcuts dialog
shortcuts:
title: Keyboard Shortcuts
description: Use these shortcuts to navigate BrowserOS faster
moreComingSoon: More shortcuts coming soon
# Sidebar navigation
sidebar:
nav:
home: Home
connectApps: Connect Apps
scheduledTasks: Scheduled Tasks
workflows: Workflows
skills: Skills
memory: Memory
soul: Soul
settings: Settings
footer:
about: About BrowserOS
shortcuts: Shortcuts
branding:
alt: BrowserOS
personal: Personal
updateProfile: Update Profile
signOut: Sign out
signIn: Sign in
# Common / shared
common:
cancel: Cancel
submit: Submit
save: Save
delete: Delete
edit: Edit
close: Close
loading: Loading...
error: Something went wrong
apps: Apps
tabs: Tabs

View File

@@ -1,134 +0,0 @@
extName: Asistente BrowserOS
extActionTitle: Pregunta a BrowserOS
chat:
input:
placeholderAgent: ¿Qué debo hacer?
placeholderChat: Pregunta sobre esta página...
placeholderTranscribing: Transcribiendo...
send: Enviar
stop: Detener
stopRecording: Detener grabación
transcribing: Transcribiendo
voiceInput: Entrada de voz
mode:
agent: Modo Agente ON
chat: Modo Chat ON
agentTooltip: La IA puede navegar e interactuar
chatTooltip: Solo lectura, sin interacción
empty:
agentTitle: Agente a su servicio
agentSubtitle: Deja que la IA automatice y navegue por ti
chatTitle: Chatea con esta página
chatSubtitle: Pregunta sobre la página actual o cualquier tema
header:
changeProvider: Cambiar proveedor de IA
newConversation: Nueva conversación
chatHistory: Historial
starOnGithub: Star en GitHub
settings: Ajustes
error:
dailyLimitTitle: Límite diario alcanzado
connectionFailedTitle: Conexión fallida
genericTitle: Algo salió mal
connectionMessage: No se pudo conectar al agente. Sigue las instrucciones.
rateLimitMessage: Añade tu clave API para uso ilimitado.
troubleshootingLink: Ver guía de solución
kimiApiKeyLink: Cómo obtener clave API de Kimi
getApiKey: obtener tu clave API
tryAgain: Reintentar
actions:
copy: Copiar
copyToClipboard: Copiar al portapapeles
feedbackSubmitted: Feedback enviado
like: Me gusta
likeTooltip: Me gusta esta respuesta
dislike: No me gusta
dislikeTooltip: No me gusta esta respuesta
feedbackTitle: ¿Qué salió mal?
feedbackDescription: Cuéntanos qué salió mal para mejorar.
feedbackPlaceholder: Añade un comentario (opcional)
schedule:
title: ¿Ejecutar automáticamente?
dailyAt: diariamente a las $1
everyHour: cada hora
scheduleButton: Programar tarea
maybeLater: Más tarde
connectApp:
connecting: Conectando...
connectButton: Conectar $1
authorizeTitle: Autoriza $1 en la pestaña abierta
authorizeDescription: Completa el inicio de sesión y haz clic abajo.
authorizedButton: He autorizado $1, continuar
skipButton: Omitir, hacer manualmente
suggested: $1 sugerido
connected: $1 conectado
continuedWithout: Continuando sin $1
connectForBetter: Conecta $1 para mejores resultados
doItManually: Hacerlo manualmente
connectedSuccess: $1 conectado con éxito
connectFailed: Error al conectar $1
footer:
attachTabs: Adjuntar pestañas (@)
selectWorkspace: Seleccionar carpeta
connectApps: Conectar apps
selectedText:
removeTitle: Eliminar texto seleccionado
attachedTabs:
removeTab: Quitar pestaña
newtab:
search:
placeholder: Pregunta a BrowserOS o busca $1...
branding:
alt: BrowserOS
selectedTab: Pestaña
appsNew: ¡Nuevo!
appsTooltip: Las apps conectadas ofrecen respuestas más precisas y rápidas
addWorkspace: Añadir espacio de trabajo
tip:
label: "Consejo:"
nextTip: Siguiente consejo
dismiss: Descartar
signIn:
title: Sincroniza tus datos
description: Inicia sesión para sincronizar el historial en la nube.
dontAskAgain: No preguntar de nuevo
signInButton: Iniciar sesión
importData:
title: Importar tus datos
description: Importa marcadores, historial y contraseñas de Chrome.
dontShowAgain: No mostrar de nuevo
openSettings: Abrir ajustes de importación
shortcuts:
title: Atajos de teclado
description: Usa estos atajos para navegar más rápido
moreComingSoon: Más atajos pronto
sidebar:
nav:
home: Inicio
connectApps: Conectar Apps
scheduledTasks: Tareas programadas
workflows: Flujos
skills: Habilidades
memory: Memoria
soul: Alma
settings: Ajustes
footer:
about: Acerca de BrowserOS
shortcuts: Atajos
branding:
alt: BrowserOS
personal: Personal
updateProfile: Actualizar perfil
signOut: Cerrar sesión
signIn: Iniciar sesión
common:
cancel: Cancelar
submit: Enviar
save: Guardar
delete: Eliminar
edit: Editar
close: Cerrar
loading: Cargando...
error: Algo salió mal
apps: Apps
tabs: Pestañas

View File

@@ -1,134 +0,0 @@
extName: Assistant BrowserOS
extActionTitle: Demander à BrowserOS
chat:
input:
placeholderAgent: Que dois-je faire ?
placeholderChat: Demandez sur cette page...
placeholderTranscribing: Transcription...
send: Envoyer
stop: Arrêter
stopRecording: Arrêter l'enregistrement
transcribing: Transcription
voiceInput: Saisie vocale
mode:
agent: Mode agent activé
chat: Mode chat activé
agentTooltip: L'IA peut naviguer, cliquer et se déplacer
chatTooltip: L'IA peut lire mais pas cliquer ni naviguer
empty:
agentTitle: Agent à votre service
agentSubtitle: Laissez l'IA automatiser et naviguer pour vous
chatTitle: Discuter avec cette page
chatSubtitle: Posez des questions sur cette page ou tout sujet
header:
changeProvider: Changer fournisseur IA
newConversation: Nouvelle conversation
chatHistory: Historique
starOnGithub: Étoiler sur Github
settings: Paramètres
error:
dailyLimitTitle: Limite quotidienne atteinte
connectionFailedTitle: Connexion échouée
genericTitle: Une erreur s'est produite
connectionMessage: Impossible de connecter l'agent BrowserOS. Suivez les instructions ci-dessous.
rateLimitMessage: Ajoutez votre propre clé API pour un usage illimité.
troubleshootingLink: Voir le guide de dépannage
kimiApiKeyLink: Comment obtenir une clé API Kimi
getApiKey: obtenir votre clé API
tryAgain: Réessayer
actions:
copy: Copier
copyToClipboard: Copier dans le presse-papiers
feedbackSubmitted: Feedback envoyé
like: Utile
likeTooltip: Cette réponse est utile
dislike: Inutile
dislikeTooltip: Cette réponse est inutile
feedbackTitle: Quel est le problème ?
feedbackDescription: Aidez-nous à nous améliorer en indiquant le problème.
feedbackPlaceholder: Commentaire (facultatif)
schedule:
title: Exécuter automatiquement ?
dailyAt: tous les jours à $1
everyHour: toutes les heures
scheduleButton: Planifier cette tâche
maybeLater: Plus tard
connectApp:
connecting: Connexion...
connectButton: Connecter $1
authorizeTitle: Autoriser $1 dans l'onglet ouvert
authorizeDescription: Terminez la connexion, puis cliquez ci-dessous.
authorizedButton: J'ai autorisé $1, continuer
skipButton: Passer, faire manuellement
suggested: $1 suggéré
connected: $1 connecté
continuedWithout: Continuer sans $1
connectForBetter: Connectez $1 pour de meilleurs résultats
doItManually: Faire manuellement
connectedSuccess: $1 connecté avec succès
connectFailed: Impossible de connecter $1
footer:
attachTabs: Joindre onglets (@)
selectWorkspace: Choisir l'espace de travail
connectApps: Connecter apps
selectedText:
removeTitle: Supprimer le texte sélectionné
attachedTabs:
removeTab: Supprimer l'onglet
newtab:
search:
placeholder: Demandez à BrowserOS ou cherchez $1...
branding:
alt: BrowserOS
selectedTab: Onglet
appsNew: Nouveau !
appsTooltip: Les apps connectées directement offrent des réponses plus précises et rapides !
addWorkspace: Ajouter un espace de travail
tip:
label: "Astuce :"
nextTip: Astuce suivante
dismiss: Ignorer
signIn:
title: Synchroniser vos données
description: Connectez-vous pour synchroniser l'historique dans le cloud.
dontAskAgain: Ne plus demander
signInButton: Se connecter
importData:
title: Importer vos données
description: Importez marque-pages, historique et mots de passe depuis Chrome.
dontShowAgain: Ne plus afficher
openSettings: Ouvrir les paramètres d'importation
shortcuts:
title: Raccourcis clavier
description: Utilisez ces raccourcis pour naviguer plus vite dans BrowserOS
moreComingSoon: Plus de raccourcis bientôt
sidebar:
nav:
home: Accueil
connectApps: Apps connectées
scheduledTasks: Tâches planifiées
workflows: Workflows
skills: Compétences
memory: Mémoire
soul: Âme
settings: Paramètres
footer:
about: À propos de BrowserOS
shortcuts: Raccourcis
branding:
alt: BrowserOS
personal: Personnel
updateProfile: Mettre à jour le profil
signOut: Déconnexion
signIn: Connexion
common:
cancel: Annuler
submit: Valider
save: Enregistrer
delete: Supprimer
edit: Modifier
close: Fermer
loading: Chargement...
error: Une erreur s'est produite
apps: Apps
tabs: Onglets

View File

@@ -1,134 +0,0 @@
extName: BrowserOS アシスタント
extActionTitle: BrowserOS に質問
chat:
input:
placeholderAgent: 何をしますか?
placeholderChat: このページについて...
placeholderTranscribing: 文字起こし中...
send: 送信
stop: 停止
stopRecording: 録音停止
transcribing: 文字起こし中
voiceInput: 音声入力
mode:
agent: エージェントモードON
chat: チャットモードON
agentTooltip: 閲覧・クリック・ナビゲート可能
chatTooltip: 閲覧のみ、クリック・ナビゲート不可
empty:
agentTitle: エージェントが対応します
agentSubtitle: AIにタスク自動化と閲覧をお任せ
chatTitle: このページとチャット
chatSubtitle: 現在のページやトピックについて質問
header:
changeProvider: AIプロバイダー変更
newConversation: 新規会話
chatHistory: 履歴
starOnGithub: GitHubでスター
settings: 設定
error:
dailyLimitTitle: 1日の制限に達しました
connectionFailedTitle: 接続失敗
genericTitle: エラーが発生しました
connectionMessage: BrowserOSエージェントに接続できません。以下の手順に従ってください。
rateLimitMessage: 無制限利用にはAPIキーを追加してください。
troubleshootingLink: トラブルシューティングを見る
kimiApiKeyLink: Kimi APIキーの取得方法
getApiKey: APIキーを取得
tryAgain: 再試行
actions:
copy: コピー
copyToClipboard: クリップボードにコピー
feedbackSubmitted: フィードバック送信済み
like: いいね
likeTooltip: この回答にいいね
dislike: よくないね
dislikeTooltip: この回答に不満
feedbackTitle: 何が問題でしたか?
feedbackDescription: 回答の問題点を教えてください
feedbackPlaceholder: コメントを追加(任意)
schedule:
title: 自動実行しますか?
dailyAt: 毎日 $1
everyHour: 毎時間
scheduleButton: タスクを予約
maybeLater: 後で
connectApp:
connecting: 接続中...
connectButton: $1 に接続
authorizeTitle: 開いたタブで $1 を承認
authorizeDescription: サインインを完了し、下のボタンをクリックしてください。
authorizedButton: $1 の承認完了、続ける
skipButton: スキップして手動で実行
suggested: $1 を推奨
connected: $1 に接続済み
continuedWithout: $1 なしで続行
connectForBetter: より良い結果のため $1 に接続
doItManually: 手動で実行
connectedSuccess: $1 の接続に成功
connectFailed: $1 の接続に失敗
footer:
attachTabs: タブを添付(@)
selectWorkspace: ワークスペースフォルダを選択
connectApps: アプリを接続
selectedText:
removeTitle: 選択テキストを削除
attachedTabs:
removeTab: タブを削除
newtab:
search:
placeholder: BrowserOSに質問、または$1を検索...
branding:
alt: BrowserOS
selectedTab: タブ
appsNew: 新着!
appsTooltip: 直接接続したアプリはより正確・高速な回答を提供します!
addWorkspace: ワークスペースを追加
tip:
label: ヒント:
nextTip: 次のヒント
dismiss: 閉じる
signIn:
title: データを同期
description: 会話履歴をクラウドに同期するにはサインインしてください。
dontAskAgain: 今後表示しない
signInButton: サインイン
importData:
title: データをインポート
description: Chromeからブックマーク、履歴、パスワードを取り込みます。
dontShowAgain: 今後表示しない
openSettings: インポート設定を開く
shortcuts:
title: キーボードショートカット
description: BrowserOSをより速く操作するためのショートカット
moreComingSoon: さらにショートカットを追加予定
sidebar:
nav:
home: ホーム
connectApps: アプリ接続
scheduledTasks: 予定タスク
workflows: ワークフロー
skills: スキル
memory: メモリ
soul: ソウル
settings: 設定
footer:
about: BrowserOSについて
shortcuts: ショートカット
branding:
alt: BrowserOS
personal: 個人
updateProfile: プロフィール更新
signOut: サインアウト
signIn: サインイン
common:
cancel: キャンセル
submit: 送信
save: 保存
delete: 削除
edit: 編集
close: 閉じる
loading: 読み込み中...
error: エラーが発生しました
apps: アプリ
tabs: タブ

View File

@@ -1,134 +0,0 @@
extName: BrowserOS Assistant
extActionTitle: BrowserOS에게 물어보기
chat:
input:
placeholderAgent: 무엇을 할까요?
placeholderChat: 이 페이지에 대해 질문...
placeholderTranscribing: 전사 중...
send: 전송
stop: 중지
stopRecording: 녹음 중지
transcribing: 전사 중
voiceInput: 음성 입력
mode:
agent: 에이전트 모드 ON
chat: 채팅 모드 ON
agentTooltip: AI가 탐색, 클릭, 이동 가능
chatTooltip: AI는 읽기만 가능, 클릭/이동 불가
empty:
agentTitle: 에이전트가 대기 중
agentSubtitle: AI가 작업을 자동화하고 탐색합니다
chatTitle: 이 페이지와 채팅
chatSubtitle: 현재 페이지나 주제에 대해 질문하세요
header:
changeProvider: AI 제공자 변경
newConversation: 새 대화
chatHistory: 대화 기록
starOnGithub: Github에서 Star
settings: 설정
error:
dailyLimitTitle: 일일 한도 도달
connectionFailedTitle: 연결 실패
genericTitle: 문제가 발생했습니다
connectionMessage: BrowserOS 에이전트에 연결할 수 없습니다. 아래 안내를 따르세요.
rateLimitMessage: 무제한 사용을 위해 API 키를 추가하세요.
troubleshootingLink: 문제 해결 가이드 보기
kimiApiKeyLink: Kimi API 키 얻는 방법 알아보기
getApiKey: API 키 받기
tryAgain: 다시 시도
actions:
copy: 복사
copyToClipboard: 클립보드에 복사
feedbackSubmitted: 피드백 전송됨
like: 좋아요
likeTooltip: 이 응답이 좋아요
dislike: 싫어요
dislikeTooltip: 이 응답이 싫어요
feedbackTitle: 무엇이 잘못되었나요?
feedbackDescription: 이 응답의 문제를 공유하여 개선을 도와주세요.
feedbackPlaceholder: 댓글 추가 (선택사항)
schedule:
title: 자동으로 실행할까요?
dailyAt: 매일 $1
everyHour: 매시간
scheduleButton: 작업 예약
maybeLater: 나중에
connectApp:
connecting: 연결 중...
connectButton: $1 연결
authorizeTitle: 열린 탭에서 $1 인증
authorizeDescription: 로그인을 완료한 후 아래 버튼을 클릭하세요.
authorizedButton: $1 인증 완료, 계속하기
skipButton: 건너뛰기, 수동으로 실행
suggested: $1 추천
connected: $1 연결됨
continuedWithout: $1 없이 계속
connectForBetter: 더 나은 결과를 위해 $1 연결
doItManually: 수동으로 실행
connectedSuccess: $1 연결 성공
connectFailed: $1 연결 실패
footer:
attachTabs: 탭 첨부 (@)
selectWorkspace: 작업 폴더 선택
connectApps: 앱 연결
selectedText:
removeTitle: 선택한 텍스트 제거
attachedTabs:
removeTab: 탭 제거
newtab:
search:
placeholder: BrowserOS에게 묻거나 $1 검색...
branding:
alt: BrowserOS
selectedTab:
appsNew: 신규!
appsTooltip: 직접 연결된 앱은 더 정확하고 빠른 응답을 제공합니다!
addWorkspace: 작업 공간 추가
tip:
label: "팁:"
nextTip: 다음 팁
dismiss: 닫기
signIn:
title: 데이터 동기화
description: 대화 기록을 클라우드에 동기화하려면 로그인하세요.
dontAskAgain: 다시 묻지 않기
signInButton: 로그인
importData:
title: 데이터 가져오기
description: Chrome에서 북마크, 기록, 비밀번호를 가져옵니다.
dontShowAgain: 다시 표시하지 않기
openSettings: 가져오기 설정 열기
shortcuts:
title: 키보드 단축키
description: BrowserOS를 더 빠르게 탐색하려면 이 단축키를 사용하세요.
moreComingSoon: 더 많은 단축키가 곧 제공됩니다
sidebar:
nav:
home:
connectApps: 앱 연결
scheduledTasks: 예약된 작업
workflows: 워크플로우
skills: 스킬
memory: 메모리
soul: 소울
settings: 설정
footer:
about: BrowserOS 정보
shortcuts: 단축키
branding:
alt: BrowserOS
personal: 개인
updateProfile: 프로필 업데이트
signOut: 로그아웃
signIn: 로그인
common:
cancel: 취소
submit: 제출
save: 저장
delete: 삭제
edit: 편집
close: 닫기
loading: 로딩 중...
error: 문제가 발생했습니다
apps:
tabs:

View File

@@ -1,134 +0,0 @@
extName: Assistente BrowserOS
extActionTitle: Pergunte ao BrowserOS
chat:
input:
placeholderAgent: O que devo fazer?
placeholderChat: Pergunte sobre esta página...
placeholderTranscribing: Transcrevendo...
send: Enviar
stop: Parar
stopRecording: Parar gravação
transcribing: Transcrevendo
voiceInput: Entrada de voz
mode:
agent: Modo Agente ATIVO
chat: Modo Chat ATIVO
agentTooltip: A IA pode navegar, clicar e acessar sites
chatTooltip: A IA só lê, não pode clicar ou navegar
empty:
agentTitle: Agente à sua disposição
agentSubtitle: Deixe a IA automatizar tarefas e navegar por você
chatTitle: Converse com esta página
chatSubtitle: Faça perguntas sobre a página atual ou qualquer tópico
header:
changeProvider: Mudar provedor de IA
newConversation: Nova conversa
chatHistory: Histórico de chat
starOnGithub: Estrelar no GitHub
settings: Configurações
error:
dailyLimitTitle: Limite diário atingido
connectionFailedTitle: Falha na conexão
genericTitle: Algo deu errado
connectionMessage: Não foi possível conectar ao agente BrowserOS. Siga as instruções abaixo.
rateLimitMessage: Adicione sua própria chave de API para uso ilimitado.
troubleshootingLink: Ver guia de solução de problemas
kimiApiKeyLink: Saiba como obter uma chave de API Kimi
getApiKey: obtenha sua chave de API
tryAgain: Tentar novamente
actions:
copy: Copiar
copyToClipboard: Copiar para a área de transferência
feedbackSubmitted: Feedback enviado
like: Curtir
likeTooltip: Curtir esta resposta
dislike: Descurtir
dislikeTooltip: Não curtir esta resposta
feedbackTitle: O que deu errado?
feedbackDescription: Ajude-nos a melhorar informando o que estava errado.
feedbackPlaceholder: Adicione um comentário (opcional)
schedule:
title: Executar isso automaticamente?
dailyAt: diariamente às $1
everyHour: a cada hora
scheduleButton: Agendar esta tarefa
maybeLater: Talvez depois
connectApp:
connecting: Conectando...
connectButton: Conectar $1
authorizeTitle: Autorize $1 na aba aberta
authorizeDescription: Complete o fluxo de login, depois clique no botão abaixo.
authorizedButton: Autorizei $1, continuar
skipButton: Pular, fazer manualmente
suggested: $1 sugerido
connected: $1 conectado
continuedWithout: Continuando sem $1
connectForBetter: Conecte $1 para melhores resultados
doItManually: Fazer manualmente
connectedSuccess: $1 conectado com sucesso
connectFailed: Falha ao conectar $1
footer:
attachTabs: Anexar abas (@)
selectWorkspace: Selecionar workspace
connectApps: Conectar apps
selectedText:
removeTitle: Remover texto selecionado
attachedTabs:
removeTab: Remover aba
newtab:
search:
placeholder: Pergunte ao BrowserOS ou pesquise em $1...
branding:
alt: BrowserOS
selectedTab: Aba
appsNew: Novo!
appsTooltip: Apps conectados diretamente oferecem respostas mais precisas e rápidas!
addWorkspace: Adicionar workspace
tip:
label: "Dica:"
nextTip: Próxima dica
dismiss: Fechar
signIn:
title: Sincronize seus dados
description: Entre para sincronizar o histórico de conversas na nuvem.
dontAskAgain: Não perguntar novamente
signInButton: Entrar
importData:
title: Importe seus dados
description: Traga favoritos, histórico e senhas do Chrome.
dontShowAgain: Não mostrar novamente
openSettings: Abrir Configurações de Importação
shortcuts:
title: Atalhos de Teclado
description: Use atalhos para navegar mais rápido no BrowserOS
moreComingSoon: Mais atalhos em breve
sidebar:
nav:
home: Início
connectApps: Conectar Apps
scheduledTasks: Tarefas Agendadas
workflows: Fluxos de trabalho
skills: Habilidades
memory: Memória
soul: Alma
settings: Configurações
footer:
about: Sobre o BrowserOS
shortcuts: Atalhos
branding:
alt: BrowserOS
personal: Pessoal
updateProfile: Atualizar Perfil
signOut: Sair
signIn: Entrar
common:
cancel: Cancelar
submit: Enviar
save: Salvar
delete: Excluir
edit: Editar
close: Fechar
loading: Carregando...
error: Algo deu errado
apps: Apps
tabs: Abas

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