Compare commits

...

43 Commits

Author SHA1 Message Date
Dani Akash
fc84af8d14 feat: created translations 2026-03-20 13:21:39 +05:30
Dani Akash
d01eac9d0b feat: update script to use ai sdk 2026-03-20 13:15:33 +05:30
Dani Akash
be2422437c fix: lint errors 2026-03-20 13:10:04 +05:30
Dani Akash
b2d3acde4e feat: setup english language via internationalization 2026-03-20 13:06:54 +05:30
Nikhil
9257832acf feat: gate ChatGPT Pro and GitHub Copilot behind server version 0.0.77 (#503)
Add CHATGPT_PRO_SUPPORT and GITHUB_COPILOT_SUPPORT feature flags gated
on minServerVersion 0.0.77. Hide template cards and provider type
dropdown options when the server doesn't support the OAuth endpoints.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:43:09 -07:00
Nikhil
7bde0d59fa chore: bump chromium version (#502) 2026-03-19 16:22:13 -07:00
Nikhil
1c737b0f02 chore: bump server version (#501) 2026-03-19 16:17:50 -07:00
Nikhil
5d0a2b9bfe feat: add model selector to newtab search bar (#499)
* feat: add model selector to newtab search bar

Add AI provider/model selector button to the newtab homepage footer bar,
matching the existing button aesthetics (Workspace, Tabs, Apps). Reuses
ChatProviderSelector popover from sidepanel. Users can now see and change
their AI provider before starting a conversation from the newtab page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clean up newtab footer with icon-only buttons

Reduce visual clutter in the search bar footer by converting Provider,
Workspace, and Tabs buttons to compact icon-only buttons (8x8). Text
labels and chevron indicators are removed — native title tooltips
provide discoverability on hover. Apps button on the right keeps its
text label per user preference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add hover-expand labels to newtab footer icon buttons

Replace static title tooltips with smooth hover-expand animation —
buttons show icon-only by default, text label slides out on hover
via max-w transition. Gives a clean compact look while keeping
labels discoverable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert workspace/tabs to full text, keep provider hover-expand only

Restore full text labels for Workspace and Tabs buttons. Only the
provider selector uses the compact icon + hover-expand pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify provider selector to plain icon button

Remove hover-expand animation, use a simple icon-only button with
native title tooltip for the provider selector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:14:15 -07:00
shivammittal274
720baaed3e feat: add GitHub Copilot as OAuth LLM provider (#500)
* feat: add GitHub Copilot as OAuth-based LLM provider

Add GitHub Copilot as a second OAuth provider using the Device Code flow
(RFC 8628). Users authenticate via github.com/login/device, and the server
polls for token completion. Supports 25+ models through a single Copilot
subscription.

Key changes:
- Device Code OAuth flow in token manager (poll with safety margin)
- Custom fetch wrapper injecting Copilot headers + vision detection
- Provider factory using createOpenAICompatible for Chat Completions API
- Extension UI with template card, auto-create on auth, and disconnect

* fix: address PR review comments for GitHub Copilot OAuth

- Validate device code response for error fields (GitHub can return 200
  with error payload)
- Store empty refreshToken instead of access token for GitHub tokens
- Add closeButton to Toaster for dismissing device code toast

* fix: add github-copilot to agent provider factory

The chat route uses a separate provider-factory.ts (agent layer) from the
test-provider route (llm/provider.ts). Added createGitHubCopilotFactory
to the agent factory so chat works with GitHub Copilot.

* fix: add github-copilot to provider icons, models, and dialog

- Add Github icon from lucide-react to providerIcons map
- Add 8 Copilot models (GPT-4o, Claude, Gemini, Grok) to models.ts
- Add github-copilot to NewProviderDialog zod enum, validation skip,
  canTest check, and OAuth credential message

* fix: reorder copilot models with free-tier models first

Put models available on Copilot Free at the top (gpt-4o, gpt-4.1,
gpt-5-mini, claude-haiku-4.5, grok-code-fast-1), followed by
premium models that require paid Copilot subscription.

* fix: set correct 64K context window for Copilot models

Copilot API enforces a 64K input token limit regardless of the
underlying model's native context window. Updated all model entries
and the default template to 64000 so compaction triggers correctly.

* fix: use actual per-model prompt limits from Copilot /models API

Queried api.githubcopilot.com/models for real max_prompt_tokens values.
GPT-4o/4.1 have 64K, Claude/gpt-5-mini have 128K, GPT-5.x have 272K.
Also updated model list to match what's actually available on the API
(e.g. claude-sonnet-4.6 instead of 4.5, added gpt-5.4/5.2-codex).

* feat: resize images for Copilot using VS Code's algorithm

Large screenshots cause 413 errors on Copilot's API. Resize images
following VS Code's approach: max 2048px longest side, 768px shortest
side, re-encode as JPEG at 75% quality. Uses sharp for server-side
image processing.

* fix: address all Greptile P1 review comments

- Add .catch() on fire-and-forget pollDeviceCode to prevent unhandled
  rejection crashes (Node 15+)
- Add deduplication guard (activeDeviceFlows Set) to prevent concurrent
  device code flows for the same provider
- Add runtime validation of server response in frontend before calling
  window.open() and showing toast
- Remove dead GITHUB_DEVICE_VERIFICATION constant from urls.ts

* fix: upgrade biome to 2.4.8, fix all lint errors, and address review bugs

- Upgrade biome from 2.4.5 to 2.4.8 (matches CI) and migrate configs
- Fix image resize: only re-encode when dimensions actually change
- Fix device code polling: retry on transient network errors instead of aborting
- Allow restarting device code flow (clear old flow instead of throwing 500)
- Fix pre-existing noNonNullAssertion and noExplicitAny lint errors globally

* fix: address Greptile P2 review — image resize and config guard

- Fix early-return guard: check max/min sides against their respective
  limits (MAX_LONG_SIDE/MAX_SHORT_SIDE) instead of both against SHORT
- Preserve PNG alpha: detect hasAlpha and keep PNG format instead of
  unconditionally converting to lossy JPEG
- Keep browserosId guard in resolveGitHubCopilotConfig consistent with
  ChatGPT Pro pattern (safety check that caller context is valid)

* feat: update Copilot models to full list from pricing page, default to gpt-5-mini

Added all 23 models from GitHub Copilot pricing page. Ordered with
free-tier models first (gpt-5-mini, claude-haiku-4.5), then premium.
Changed default from gpt-4o to gpt-5-mini since it's unlimited on
Pro plan and has 128K context (vs gpt-4o's 64K limit).
2026-03-20 02:33:09 +05:30
shivammittal274
cee9c764b1 fix(skills): read-only view mode for built-in skills (#494)
* fix(skills): read-only view mode for built-in skills

- SkillCard shows Eye icon + "View" for built-in, Pencil + "Edit" for user
- SkillDialog in read-only mode: disabled fields, no toolbar on markdown
  editor, "View Skill" title, "Close" button, no "Update Skill"
- Hide tip section in read-only mode

* fix(skills): use react-markdown for read-only skill view

Replace MDXEditor with react-markdown for viewing built-in skills.
MDXEditor chokes on code fences, angle brackets, and image syntax
causing content truncation. react-markdown handles standard markdown
correctly with no rendering issues.
2026-03-19 23:48:51 +05:30
Nikhil
7bdeeb85d5 fix: revert: convert settings to popup dialog (#477) (#498)
* Revert "feat: convert settings to popup dialog (#477)"

This reverts commit 42aa0ff1ef.

* fix: address review feedback for PR #498

- Remove erroneous SETTINGS_PAGE_VIEWED_EVENT tracking from SidebarLayout
  (was firing on every non-settings page navigation)
- Fix mobile settings sidebar not closing on route change by merging
  setMobileOpen(false) into the pathname-dependent analytics useEffect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:13:14 -07:00
Dani Akash
19069cb9c4 fix: newtab layout (#497) 2026-03-19 20:40:38 +05:30
Dani Akash
5bb6143373 feat: display selected text from page in sidepanel (#496)
* feat: select text and pass to sidepanel

* fix: lint issues

* fix: persist selection across tabs

* fix: review comments

* fix: change when the selection is cleared

* feat: sanitize url
2026-03-19 20:21:31 +05:30
Dani Akash
f4d4b73a24 fix: improved memory tools (#495)
* fix: new prompt update tool

* fix: memory search tool

* fix: all review comments

* chore: remove dead code
2026-03-19 19:01:25 +05:30
Dani Akash
d965698905 fix: biome & tsc setup across repo (#493)
* fix: biome lint issues

* fix: code quality workflow

* fix: all lint issues

* chore: test lefthook pre-commit hook

* chore: test lefthook with agent file

* chore: revert test comment from lefthook verification

* feat: setup tsgo for typechecking agent

* fix: typecheck cli command

* fix: early return to prevent errors
2026-03-19 18:18:24 +05:30
shivammittal274
50b2f45590 fix(skills): UI section separation and fix find-alternatives rendering (#492)
* fix(skills): UI section separation and fix find-alternatives rendering

- Split skills page into "My Skills" (user) and "BrowserOS Skills" (built-in) sections
- Fix find-alternatives SKILL.md — replace angle bracket placeholders with curly
  braces to prevent MDXEditor from parsing them as JSX and rendering empty content

* fix(skills): bump find-alternatives to v1.1 for CDN sync
2026-03-19 17:38:28 +05:30
Dani Akash
1b88ade021 feat: updated homepage chat (#481)
* feat: updated chat ui from homepage

* fix: vertical scroll

* fix: horizontal scroll issue

* fix: lint issues

* fix: header width

* fix: message input from home to chat

* feat: created sidebar header support in new tab chat

* fix: remove history from new tab chat

* fix: remove the shared element transition

* fix: lint issues

* fix: review comments

* fix: defer the sendMessage callback

* fix: all code concerns

* fix: preserve state of chat on homepage

* fix: review comments
2026-03-19 15:24:05 +05:30
shivammittal274
079a254fa4 fix(skills): separate built-in and user skills into distinct directories (#487)
* fix(skills): separate built-in and user skills into distinct directories

- Move built-in skills to ~/.browseros/skills/builtin/, user skills stay in root
- Unify seed + sync into single syncBuiltinSkills() function, delete seed.ts
- Preserve user's enabled/disabled state during remote sync version updates
- Add catalog reconciliation — remove built-in skills dropped from remote catalog
- Fallback to bundled defaults per-skill when remote sync fails
- One-time migration moves existing default skills from root to builtin/
- Add builtIn field to SkillMeta, determined by directory (not metadata)
- UI shows "Built-in" badge, hides delete button for built-in skills
- Reject deletion of built-in skills in service layer
- Check both dirs for ID collision on skill creation

* fix(skills): address review — dedup by id, guard applyEnabled regex

- loader.ts: deduplication now keys on skill.id (directory slug) not
  skill.name (display name), preventing silent drops on name collision
- remote-sync.ts: applyEnabled checks if regex matched before writing,
  logs warning if remote content lacks an enabled field

* fix(skills): reconciliation preserves bundled defaults, delete returns 403

- reconcileRemovedSkills now keeps DEFAULT_SKILLS IDs in the safe set,
  preventing delete-then-reinstall cycle that lost enabled:false state
- DELETE /skills/:id returns 403 for built-in skills instead of 500

* refactor(skills): simplify syncBuiltinSkills to single clean pass

Build content map (bundled + remote), iterate once, preserve enabled,
reconcile deletions. Removes 7 helper functions, 70 lines of code.

* refactor(skills): extract syncOneSkill, patch content before writing

- syncBuiltinSkills is now 15 lines: build map, iterate, clean up
- syncOneSkill: flat, patches enabled state before writing (single write)
- setEnabled: pure function for content patching
- removeObsoleteSkills: extracted from inline block
2026-03-19 13:35:47 +05:30
Felarof
42aa0ff1ef feat: convert settings to popup dialog (#477)
* feat: convert settings page to popup dialog, move workflows to main nav

Replace the dedicated settings page layout (SettingsSidebarLayout) with a
modal dialog (SettingsDialog) that opens on top of the current page. Settings
are now accessible via a dialog triggered from the main sidebar, eliminating
the confusing dual-sidebar navigation pattern.

- Create SettingsDialog with tabbed left panel and content area
- Move Workflows into main sidebar navigation (feature-gated)
- Remove /settings/* routes (except /settings/survey)
- Delete SettingsSidebarLayout and SettingsSidebar components
- Update backward compatibility redirects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: setup new urls for the dialog box

* fix: dialog close button

* fix: settings analytics

* fix: address review comments

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dani Akash <DaniAkash@users.noreply.github.com>
2026-03-18 23:26:13 +05:30
shivammittal274
4000f094f6 Feat/chatgpt pro polish (#484)
* fix: ChatGPT Pro UI polish — fix undefined display and add icon

- Fix "gpt-5.3-codex · undefined" — hide baseUrl when not set
- Add OpenAI icon for chatgpt-pro provider in icon map

* chore: rename ChatGPT Pro to ChatGPT Plus/Pro (supports both plans)

* chore: remove accidentally committed files
2026-03-18 22:51:22 +05:30
shivammittal274
151be81cee fix: ChatGPT Pro UI polish — fix undefined display and add icon (#483)
- Fix "gpt-5.3-codex · undefined" — hide baseUrl when not set
- Add OpenAI icon for chatgpt-pro provider in icon map
2026-03-18 22:23:28 +05:30
shivammittal274
46a8326140 feat: add ChatGPT Pro OAuth as LLM provider (#476)
* feat: add ChatGPT Pro OAuth as LLM provider

Adds OAuth 2.0 (Authorization Code + PKCE) flow so users can authenticate
with their ChatGPT Pro subscription to power BrowserOS's agent, matching
the pattern used by Codex CLI, OpenCode, and Pi.

Server:
- OAuth token lifecycle (PKCE, exchange, refresh, SQLite storage)
- Dedicated callback server on port 1455 (Codex client ID registration)
- Codex fetch wrapper routing API calls to chatgpt.com/backend-api
- Config resolution + provider factories for all code paths (chat, test, refine)

Extension:
- ChatGPT Pro template card with OAuth flow trigger
- Status polling hook + auto-create provider on auth success
- Model list with Codex-supported models (gpt-5.x-codex family)

* fix: address Greptile PR review comments

- Wire OAuth callback server stop handle into onShutdown (P1: port 1455 leak)
- Guard against missing refresh token + clear stale tokens on failed refresh (P1)
- Add logger.warn to silent catch in codex-fetch body mutation
- Document JWT trust assumption in parseAccessTokenClaims
- Source model ID from provider template instead of hard-coding

* simplify: remove unnecessary OAuth shutdown wiring and useCallback

- Revert OAuthHandle interface — callback server port releases on process exit
- Remove stopCallbackServer from shutdown flow (dead code)
- Remove all useCallback from useOAuthStatus per CLAUDE.md guidance

* style: add readonly modifiers and braces per TS style guide

* docs: add E2E test screenshots for ChatGPT Pro OAuth

* fix: strip item IDs from Codex requests to fix multi-turn conversations

* fix: preserve function_call_output IDs in Codex requests

* fix: resolve Codex store=false + tool-use incompatibility

- Pass providerOptions { openai: { store: false } } to ToolLoopAgent
  so the AI SDK inlines content instead of using item_reference
- Strip item IDs and previous_response_id in codex-fetch (safety net)
- Use .responses() model (Codex only speaks Responses API format)

* fix: remove non-Codex model gpt-5.2 from chatgpt-pro model list

* fix: strip unsupported Codex params and update model list

- Strip temperature, max_tokens, top_p from Codex requests (unsupported)
- Add all available Codex models including gpt-5.4, gpt-5.2, gpt-5.1

* chore: remove screenshots containing email

* feat: enable reasoning events for ChatGPT Pro Codex models

* chore: set reasoning effort to high for ChatGPT Pro

* feat: add configurable reasoning effort and summary for ChatGPT Pro

- Add reasoningEffort (none/low/medium/high) and reasoningSummary
  (auto/concise/detailed) dropdowns in the Edit Provider dialog
- Pass through extension → chat request → agent config → providerOptions
- Defaults: effort=high, summary=auto

* fix: strip max_output_tokens from Codex requests (fixes compaction)

* fix: address Greptile P1 issues

- Fix default model fallback: gpt-4o → gpt-5.3-codex (Codex endpoint)
- Clear stale tokens on refresh failure (prevents infinite retry loop)
- Only auto-create provider after explicit OAuth flow, not on page load
- Add catch block to auto-create effect with error toast
2026-03-18 22:07:43 +05:30
Dani Akash
4b18723a21 fix: undo shortcut in rewrite button (#472)
* fix: undo shortcut in rewrite button

* fix: address reviews
2026-03-18 07:04:48 +05:30
Nikhil
4909927c03 chore: bump PATCH and OFFSET (#479) 2026-03-17 17:41:45 -07:00
Nikhil
22c5e85707 chore: bump server version (#478) 2026-03-17 17:12:23 -07:00
shivammittal274
59b00a6837 feat: remote skill download and auto-sync (#468)
* feat: add remote skill download and auto-sync

Download default skills from remote catalog on first setup with
bundled fallback when offline. Background sync every 45 minutes
checks for new/updated skills without overwriting user-customized
ones. Tracks installed defaults via content hashes in a local
manifest file.

* feat: make skills catalog URL configurable and add generation script

Add SKILLS_CATALOG_URL env var (following CODEGEN_SERVICE_URL pattern)
with fallback to the default constant. Add script to generate
catalog.json from bundled defaults for static hosting.

* feat: add R2 upload script and use cdn.browseros.com for catalog URL

Add upload-skills-catalog.ts that generates and uploads catalog.json
to Cloudflare R2 (same infra as existing build artifacts). Update
default catalog URL to cdn.browseros.com/skills/v1/catalog.json.

* test: add E2E tests for remote skill sync against live CDN

* fix: address code review findings — security, validation, DRY

- Add path traversal protection via safeSkillDir in writeSkillFile
  and readSkillContent (reuses existing validation from service.ts)
- Add runtime type guards for catalog JSON and manifest JSON parsing
- Fix seedFromRemote to return false on partial failure so bundled
  fallback kicks in
- Add per-skill error handling in syncRemoteSkills so one bad skill
  doesn't crash the entire sync
- Wire stopSkillSync into Application.stop() shutdown path
- Extract version from frontmatter in seedFromBundled instead of
  hardcoding '1.0'
- Consolidate duplicated logic: reuse installSkill/writeSkillFile/
  contentHash/saveManifest from remote-sync.ts in seed.ts
- Extract shared catalog generation into scripts/catalog-utils.ts

* test: add flow tests for all four sync scenarios against live CDN

* refactor: remove redundant scripts and inline catalog generation

Drop generate-skills-catalog.ts, catalog-utils.ts, and
e2e-remote-sync.test.ts (covered by flows.test.ts). Inline
catalog generation into upload-skills-catalog.ts.

* test: add full E2E server flow test against live CDN

Tests all 7 steps of the real server lifecycle: fresh seed from CDN,
no-op sync, user edit preservation, skill reinstall, custom skill
protection, background timer firing, and second startup skip.

* chore: remove e2e-server-flow test

* fix: address Greptile review — entry validation, size limit, DRY, no-op saves

- Validate individual skill entries in catalog (id, version, content
  must all be strings) not just the top-level shape
- Add 1MB response size limit on catalog fetch to prevent resource
  exhaustion from compromised/misconfigured CDN
- Skip manifest save when sync cycle had no changes (avoids
  unnecessary disk I/O every 45 minutes)
- Share extractVersion via remote-sync.ts export, remove duplicate
  from seed.ts

* fix: prevent bundled fallback from overwriting partial remote seeds

When seedFromRemote partially fails, the bundled fallback now skips
skills already in the manifest (installed by the partial remote
seed). Also adds Content-Length early check before downloading the
full catalog response body.

* fix: run sync immediately on startup, not just on interval

Previously the first sync fired 45 minutes after boot. Now
startSkillSync runs one sync immediately so returning users
get skill updates right away.

* refactor: simplify sync — remote always wins, remove manifest

Remote catalog is the source of truth. If a skill exists in the
catalog, its version is compared against local frontmatter and
overwritten when newer. No manifest file, no content hashes.

User-created skills (IDs not in catalog) are never touched.

* fix: skip bundled skills already installed by partial remote seed

* chore: remove unreliable Content-Length check

* chore: remove size limit checks, fetch timeout is sufficient
2026-03-17 21:40:45 +05:30
Nikhil
44af9aea6d fix: clean-up old scripts (#474)
* fix: remove old scripts

* fix: remove vscode
2026-03-17 08:56:55 -07:00
Nikhil
1779e1e7bd fix: create user-data dir if missing (#473) 2026-03-17 08:30:39 -07:00
shivammittal274
2597cdbc70 feat: add Rewrite with AI for scheduled task prompts (#465)
* feat: add "Rewrite with AI" prompt refinement for scheduled tasks

Add a lightweight /refine-prompt endpoint that uses generateText to
rewrite rough scheduled task prompts into clear, actionable instructions.
The UI adds a sparkle-icon button next to the Prompt label in the
NewScheduledTaskDialog with loading state, undo support, and disabled
state when the textarea is empty.

* fix: clear stale undo ref on dialog re-open and pass providerId to refinePrompt

- Reset originalPromptRef when dialog opens and on form submit to
  prevent stale "Undo rewrite" button on re-open
- Accept optional providerId in refinePrompt() so the form's selected
  provider is used for refinement instead of always the system default

* fix: hide undo rewrite link while refinement is in flight

* fix: reset isRefining state on dialog re-open

* fix: ignore stale refine-prompt responses after dialog re-open

Use a request generation counter so that if the dialog is closed and
re-opened while a rewrite is in flight, the stale response is silently
discarded instead of overwriting the fresh form state.

* fix: invalidate stale refine requests on dialog reopen and rename to kebab-case

- Increment refineRequestIdRef on dialog open so in-flight requests
  from a previous session are discarded when they complete
- Rename refinePrompt.ts to refine-prompt.ts per CLAUDE.md file naming
2026-03-17 19:40:56 +05:30
shivammittal274
515ad44826 fix: resolve biome v2 config and lint errors (#471)
Migrate `files.ignore` to `files.includes` for Biome v2 compatibility,
fix forEach callback return value, unused variable, import ordering,
and formatting violations.
2026-03-17 19:14:01 +05:30
Dani Akash
2a6848bc1d feat: improved system prompt (#466)
* feat: added ai-sdk dev tools

* feat: new system prompt section

* feat: tests to maintain prompt integrity

* feat: update mcp sync to use react query

* fix: refetch logic for sync

* chore: remove limits on fetching integrations

* fix: refetch integrations on delete

* fix: review comment

* chore: update tests

* fix: improved memory classification

* fix: lint issues

* fix: core memory prompts

* fix: handle scenario where soul file is empty
2026-03-17 19:01:10 +05:30
Dani Akash
74f6a2dff1 fix: issue with fill tool (#469) 2026-03-17 18:58:17 +05:30
Dani Akash
58adac17db feat: new workflows (#470) 2026-03-17 18:56:55 +05:30
shivammittal274
e67c17a0f8 feat: add voice input to agent chat sidebar (#467)
* feat: add voice input to agent chat sidebar

Allow users to record voice and transcribe to text in the chat input.
Mic button shows when input is empty, waveform visualizer during recording,
transcription via OpenAI (llm.browseros.com/api/transcribe).

- Extract shared useVoiceInput hook to lib/voice/
- Time-domain waveform bars that bounce per-frequency-band
- Bar height capped to fit input container
- Analytics events for recording lifecycle

* fix: address review — add fetch timeout, await stopRecording, deduplicate VoiceInputState

- Add AbortSignal.timeout(30s) to transcription fetch
- Await stopRecording() and track analytics after completion
- Export VoiceInputState from useVoiceInput, import in consumers

* fix: await startRecording before tracking, narrow SurveyChat effect deps

- Await startRecording() so analytics only fires after mic permission granted
- Narrow SurveyChat useEffect dependency from [voice] to [voice.transcript, voice.isTranscribing]

* fix: analytics only tracks on success, clean up stream on failure, type API response

- startRecording returns boolean; track(RECORDING_STARTED) only fires on success
- Catch block cleans up MediaStream tracks and AudioContext on partial failure
- Type transcription API response with TranscribeResponse interface

* fix: keep mic button always visible alongside send button

Mic and send are now separate buttons, both always visible.
Mic is disabled while AI is streaming. Send is disabled during
recording/transcribing. Buttons are no longer absolutely positioned
inside the textarea — they sit beside it in the flex row.

* fix: keep mic button always visible inside input alongside send

Both mic and send buttons are always visible inside the input field,
positioned on the right side (ChatGPT-style). Mic is disabled while
AI is streaming. Send is disabled during recording/transcribing.

* fix: remove unreachable CSS branch in recording waveform div
2026-03-17 18:28:19 +05:30
shivammittal274
94e3f99adb feat: add test-ui skill for visual testing of agent extension via CDP (#464)
* feat: add CDP UI inspector script for dev self-testing

* 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

* docs: add self-testing workflow for UI changes via CDP inspector

* 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

* 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.

* 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.

* 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

* 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:18:00 +05:30
Nikhil
e2069bc999 chore: bump server version (#459) 2026-03-16 16:42:54 -07:00
shivammittal274
2d51c82722 fix: detect custom clickable elements in take_snapshot (#452)
take_snapshot only used the AX tree, which misses custom components
(cursor:pointer divs, onclick handlers, etc.) that lack ARIA roles.
These elements appeared as role="generic" and were invisible to the agent.

Changes:
- Merge findCursorInteractiveElements into snapshot() so take_snapshot
  catches cursor:pointer, onclick, and tabindex elements
- Add DisclosureTriangle to INTERACTIVE_ROLES for <summary> elements
- Use aria-label as text fallback in cursor detection for icon-only buttons
- Fix dedup bug in enhancedSnapshot that was silently dropping all
  cursor-detected elements by checking against all AX node IDs instead
  of only already-included output IDs
2026-03-17 02:01:15 +05:30
shivammittal274
29056226bb feat: add eval framework and coordinate-based input tools (#453)
- Add hover_at, type_at, drag_at coordinate tools to server
- Add hoverAt, typeAt, dragAt methods to Browser class
- Export server internals (browser, tool-loop, registry) for eval imports
- Copy eval app from enterprise repo with agents, graders, runner, dashboard
- Nest eval-targets inside apps/eval
- Adapt sessionExecutionDir → workingDir for current server API
- Add biome ignore for dashboard HTML to prevent lint breaking onclick handlers
2026-03-16 23:12:23 +05:30
shivammittal274
d1d2074abc feat: add get_console_logs tool for browser console output (#454)
* feat: add get_console_logs tool to surface browser console output

Captures Runtime.consoleAPICalled, Runtime.exceptionThrown, and
Log.entryAdded CDP events per page with a FIFO ring buffer (500 entries).

- ConsoleCollector: per-page buffers with O(1) session routing via Map lookup
- Session-aware CDP event dispatching (onSessionEvent) in CdpBackend
- Log.enable() added alongside Runtime.enable() in attachToPage
- Single tool with level hierarchy, text search, limit, and clear params
- Buffer clears on main-frame navigation, cleaned up on page close

* fix: address review — handle session re-attach, remove dead code

- ConsoleCollector.attach() now updates session mapping on re-attach
  instead of early-returning, preventing silent event drops after
  target detach/re-attach (e.g. tab crash, cross-process navigation)
- Remove unused clearConsoleLogs() and ConsoleCollector.clear()
2026-03-16 22:20:40 +05:30
shivammittal274
41c9b1547c feat: add per-task LLM provider selection for scheduled tasks (#450)
* feat: add per-task LLM provider selection for scheduled tasks

Allow users to choose which AI provider a scheduled task runs with,
using the same ChatProviderSelector component from the new-tab page.
Falls back to the global default provider when none is selected or
if the selected provider has been deleted.

* fix: lint issues

* chore: updated to latest schema.graphql file

---------

Co-authored-by: Dani Akash <DaniAkash@users.noreply.github.com>
2026-03-16 18:03:21 +05:30
shivammittal274
8b0e6dbfd3 Merge pull request #448 from browseros-ai/fix/filter-empty-conversation-messages
fix: filter empty messages from conversation history
2026-03-16 13:30:42 +05:30
github-actions[bot]
07a2d13f16 docs: shivammittal274 signed the CLA in browseros-ai/BrowserOS#$pullRequestNo 2026-03-15 12:27:03 +00:00
shivammittal274
46031ed573 fix: filter empty messages from conversation history to prevent validation errors
The AI SDK can produce assistant messages with empty parts (parts:[]) when
a stream is aborted, and providers reject assistant messages with empty text
content. This adds a validation utility that filters both cases before
sending messages to createAgentUIStreamResponse and when persisting them.
2026-03-15 17:42:34 +05:30
305 changed files with 65064 additions and 5025 deletions

View File

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

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,47 +13,46 @@ permissions:
statuses: write
jobs:
CLAAssistant:
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'
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'
- 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 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)
path-to-signatures: 'cla-signatures.json'
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md'
branch: 'main'
# 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
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
custom-notsigned-prcomment: |
**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.
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).
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
**To sign the CLA**, please add a comment to this PR with the following text:
**To sign the CLA, please comment on this PR with:**
`I have read the CLA Document and I hereby sign the CLA`
```
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: |
**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'
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 # Can push branches and create commits
pull-requests: write # Can create and update PRs
contents: write
pull-requests: write
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -38,11 +38,5 @@ 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,11 +4,16 @@ 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:
@@ -28,6 +33,9 @@ jobs:
typecheck:
name: runner / Typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/browseros-agent
permissions:
contents: read
steps:
@@ -42,6 +50,9 @@ 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 # Read PR details and add labels
issues: write # Labels are managed via issues API
contents: read # Read repository content
pull-requests: write
issues: write
contents: read
jobs:
validate-pr-title:

View File

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

View File

@@ -7,18 +7,21 @@ 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

3
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

57
lefthook.yml Normal file
View File

@@ -0,0 +1,57 @@
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,286 @@
---
name: test-ui
description: Test the BrowserOS agent extension UI by starting the dev environment and visually verifying changes via CDP. Covers the new tab page (left sidebar — Home, Scheduled Tasks, Settings, etc.) and the right side panel (chat interface). Use after making UI changes to apps/agent/.
argument-hint: [what to test, e.g. "verify the new settings page renders correctly"]
---
# Test Agent UI
Visually test the BrowserOS agent extension UI — both the new tab page (left sidebar) and the right side panel (chat) — by starting the dev environment and inspecting via CDP.
## When to use
After making code changes to `apps/agent/` (the Chrome extension), use this skill to:
- Verify new UI components render correctly
- Check navigation between views works
- Confirm layout/styling changes look right
- Test interactive elements (buttons, inputs, forms)
## Prerequisites
- **Go** must be installed (`brew install go`) — the dev tool is written in Go
- **BrowserOS.app** must be installed at `/Applications/BrowserOS.app/`
- The `scripts/dev/inspect-ui.ts` utility must exist (CDP inspector script)
## Step 1: Start the dev environment
```bash
bun run dev:watch -- --new
```
This single command handles everything:
- Builds the Go dev CLI tool
- Picks random available ports (avoids conflicts)
- Creates a fresh browser profile
- Builds controller-ext
- Runs GraphQL codegen if `apps/agent/generated/graphql/` doesn't exist
- Starts the agent extension with WXT HMR (hot module replacement)
- Waits for CDP to be ready
- Starts the MCP server
Run it in the background and **read the output to find the CDP port**:
```
[info] Ports: CDP=9552 Server=9065 Extension=9929
```
The CDP port is randomized. You MUST extract it from the output and set it for all subsequent commands:
```bash
export BROWSEROS_CDP_PORT=<port from output>
```
Wait for these messages before proceeding:
1. `[server] CDP ready`
2. `[server] HTTP server listening`
## Step 2: Discover targets
```bash
bun scripts/dev/inspect-ui.ts targets
```
You will see targets like:
- `[service_worker]` — extension background scripts (not directly testable for UI)
- `[page] chrome-extension://bflpfmnmnokmjhmgnolecpppdbdophmk/app.html#/...`**New tab page (left sidebar)**
- `[page] sidepanel.html`**Right side panel (chat)**
The two main testable surfaces:
- **`app.html`** — the new tab page with left sidebar (Home, Connect Apps, Scheduled Tasks, Skills, Memory, Soul, Settings)
- **`sidepanel.html`** — the right side panel chat interface
## Step 3: Navigate to the main UI
A fresh profile opens the **onboarding page** (`app.html#/onboarding`). Navigate to the home page first:
```bash
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/home'"
```
Verify with a snapshot (not screenshot — snapshot is faster and sufficient for structural checks):
```bash
bun scripts/dev/inspect-ui.ts snapshot app.html
```
## Snapshot vs Screenshot
**Prefer `snapshot` for most checks** — it's fast, text-based, and tells you what elements exist, their text, and their IDs. Use it after every navigation or interaction to verify state.
**Use `screenshot` only when you need visual verification** — layout changes, CSS/styling, colors, images, or a final "does it look right" check. Screenshots are expensive (capture → save → read image).
| Check | Use |
|-------|-----|
| Did the page navigate? | `snapshot` — look for new elements |
| Does my new component render? | `snapshot` — look for its text/role |
| Did a click change state? | `snapshot` — check element names/values |
| Is the layout correct? | `screenshot` — visual check needed |
| Do CSS changes look right? | `screenshot` — visual check needed |
| Final verification before committing | `screenshot` — one visual confirmation |
## Step 4: Test the new tab page (left sidebar)
### Get element IDs
```bash
bun scripts/dev/inspect-ui.ts snapshot app.html
```
Output shows interactive elements with IDs:
```
[52] link "Home"
[57] link "Connect Apps"
[65] link "Scheduled Tasks"
[74] link "Skills"
[103] link "Settings"
```
### Navigate via click or hash routing
**Click-based** (use element IDs from snapshot):
```bash
bun scripts/dev/inspect-ui.ts click app.html 65 # Click "Scheduled Tasks"
```
**Hash routing** (faster, no snapshot needed):
```bash
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/settings'"
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/scheduled-tasks'"
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/home'"
```
### Verify navigation
```bash
# Snapshot to confirm the page changed (fast, preferred)
bun scripts/dev/inspect-ui.ts snapshot app.html
# Screenshot only if you need to check visual layout
bun scripts/dev/inspect-ui.ts screenshot app.html /tmp/settings.png
```
### CRITICAL: Re-snapshot after every navigation
React re-renders change element IDs. **Always run snapshot again** before clicking/filling after navigating to a new view. Using stale IDs will fail.
## Step 5: Open and test the right side panel
The side panel starts **disabled** in a fresh profile. Open it using BrowserOS-specific APIs:
```bash
bun scripts/dev/inspect-ui.ts open-sidepanel
```
Wait 2 seconds for it to appear as a target, then:
```bash
bun scripts/dev/inspect-ui.ts screenshot sidepanel /tmp/panel.png
bun scripts/dev/inspect-ui.ts snapshot sidepanel
```
### Interact with the side panel
```bash
# Get element IDs
bun scripts/dev/inspect-ui.ts snapshot sidepanel
# Output: [37] textbox "What should I do?"
# [124] button "Send"
# [60] link "Chat history"
# [99] button "Agent Mode ON"
# Fill the chat input and press Enter to send
bun scripts/dev/inspect-ui.ts fill sidepanel 37 "Hello world"
bun scripts/dev/inspect-ui.ts press_key sidepanel Enter
# Or click the Send button
bun scripts/dev/inspect-ui.ts click sidepanel 124
# Wait for a response to appear
bun scripts/dev/inspect-ui.ts wait_for sidepanel text "response text"
# Scroll down to see more content
bun scripts/dev/inspect-ui.ts scroll sidepanel down 3
# Hover over an element to test hover states
bun scripts/dev/inspect-ui.ts hover sidepanel 99
# Snapshot to verify state changed (fast, preferred)
bun scripts/dev/inspect-ui.ts snapshot sidepanel
# Screenshot only for visual/layout verification
bun scripts/dev/inspect-ui.ts screenshot sidepanel /tmp/result.png
```
## Step 6: Verify and iterate
### The core loop
```
snapshot → identify element IDs → click/fill/press_key → snapshot → verify
```
Use `screenshot` only when visual layout verification is needed (CSS changes, final check).
### After making code changes
1. Fix the code in `apps/agent/`
2. WXT HMR will hot-reload the extension automatically (watch mode)
3. Wait 2-3 seconds for the reload to complete
4. **Re-snapshot** — element IDs WILL change after HMR reload
5. Verify the fix with snapshot (or screenshot if visual)
### Check server logs
The dev server output (running in background) contains useful diagnostics:
- `[agent]` — WXT build/HMR status, compilation errors
- `[server]` — MCP server logs, tool execution, errors
- `[build]` — Extension build output
If the UI isn't rendering, check for build errors in the `[agent]` output.
### Check for JavaScript errors
```bash
bun scripts/dev/inspect-ui.ts eval sidepanel "JSON.stringify(window.__errors || 'no errors')"
```
Or check the console for React errors:
```bash
bun scripts/dev/inspect-ui.ts eval app.html "document.querySelector('#root')?.innerHTML?.substring(0, 200)"
```
### Verify API connectivity
The extension talks to the MCP server. Verify the server is reachable:
```bash
bun scripts/dev/inspect-ui.ts eval sidepanel "fetch('http://127.0.0.1:<serverPort>/health').then(r => r.ok).catch(() => false)"
```
### Common issues
| Symptom | Cause | Fix |
|---------|-------|-----|
| Blank page after navigation | React render error | Check `eval` for JS errors |
| Element IDs don't match | Page re-rendered (HMR/navigation) | Re-run `snapshot` before interacting |
| `open-sidepanel` fails | Extension not fully loaded | Wait longer after dev server starts |
| Click does nothing | Element not visible (below fold) | Use `scroll` first, then re-snapshot |
| `wait_for` times out | Content hasn't loaded yet | Check server logs for API errors |
## Available commands reference
| Command | Description |
|---------|-------------|
| `targets` | List all CDP targets, marks extension pages with `[EXTENSION]` |
| `screenshot <target> [file]` | Capture PNG screenshot (default: `screenshot.png`) |
| `snapshot <target>` | Print accessibility tree with `[elementId] role "name"` |
| `click <target> <elementId>` | Click element by ID (3-tier coordinate fallback + JS click) |
| `fill <target> <elementId> <text>` | Focus element, clear, type text |
| `press_key <target> <key>` | Press key or combo: `Enter`, `Escape`, `Tab`, `Control+A`, `Meta+Shift+P` |
| `scroll <target> <dir> [amount]` | Scroll `up`/`down`/`left`/`right`, amount in ticks (default 3) |
| `hover <target> <elementId>` | Hover over element (for tooltips, hover states) |
| `select_option <target> <id> <val>` | Select dropdown option by value or visible text |
| `wait_for <target> text\|selector <v>` | Wait up to 10s for text or CSS selector to appear |
| `eval <target> <expression>` | Run JavaScript in the target's context |
| `open-sidepanel` | Enable and open the right side panel |
`<target>` is a URL substring (e.g., `sidepanel`, `app.html`) or numeric index from `targets` output.
## Known app.html routes
These can be used with `eval app.html "window.location.hash = '#/<route>'"`:
| Route | View |
|-------|------|
| `/home` | Home page with search bar and top sites |
| `/settings` | Settings (LLM providers, customization, workflows, MCP) |
| `/scheduled-tasks` | Scheduled Tasks management |
| `/onboarding` | Onboarding flow (first-run experience) |
## Gotchas learned from real testing
1. **Ports are randomized** with `--new` — always extract from dev server output
2. **Fresh profile = onboarding page** — navigate to `#/home` to see the main UI
3. **Element IDs change after navigation** — always re-snapshot before clicking
4. **Side panel starts disabled**`open-sidepanel` handles the BrowserOS-specific enable + toggle API
5. **`Input.enable` does not exist** — the CDP Input domain has no enable method (already handled in the script)
6. **`DOM.getDocument` required** — must be called before DOM operations like `pushNodesByBackendIdsToFrontend` (already handled in the script)
7. **Settings sub-navigation** — the settings page has its own left sidebar (BrowserOS AI, Chat & Council Provider, Search Provider, Customize BrowserOS, BrowserOS as MCP, Workflows) — use snapshot + click to navigate within settings

View File

@@ -1,41 +0,0 @@
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

@@ -1,58 +0,0 @@
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

@@ -165,3 +165,68 @@ Tests are in `apps/server/tests/`:
- `agent/` - Agent tests (compaction, rate limiter)
- `sdk/` - Agent SDK tests
- `__helpers__/` - Test utilities and fixtures
## Self-Testing UI Changes
After making UI changes to the agent extension (`apps/agent/`), you can visually verify them using the CDP inspector script. This connects directly to the browser via Chrome DevTools Protocol and can inspect extension pages (side panel, new tab, etc.) that the agent's own tools cannot see.
### Prerequisites
The dev server must be running:
```bash
bun run dev:watch -- --new
```
Read the output to find the randomized CDP port, then:
```bash
export BROWSEROS_CDP_PORT=<port from output>
```
### Workflow
1. **List all targets** to see what's available:
```bash
bun scripts/dev/inspect-ui.ts targets
```
2. **Open the side panel** if it's not already open:
```bash
bun scripts/dev/inspect-ui.ts open-sidepanel
```
3. **Take a screenshot** of the side panel:
```bash
bun scripts/dev/inspect-ui.ts screenshot sidepanel /tmp/panel.png
```
Then read `/tmp/panel.png` to view the result.
4. **Get the accessibility tree** for structural verification:
```bash
bun scripts/dev/inspect-ui.ts snapshot sidepanel
```
5. **Click an element** by its ID from the snapshot:
```bash
bun scripts/dev/inspect-ui.ts click sidepanel 142
```
6. **Fill a text input** by its ID from the snapshot:
```bash
bun scripts/dev/inspect-ui.ts fill sidepanel 85 "search query"
```
7. **Evaluate JavaScript** in the extension context:
```bash
bun scripts/dev/inspect-ui.ts eval sidepanel "document.title"
```
### Interaction workflow
The typical loop is: snapshot → identify element IDs → click/fill → screenshot to verify.
Element IDs come from the `[number]` in snapshot output (these are `backendDOMNodeId` values).
This uses the same element resolution as the server's MCP tools — no coordinate guessing.
### Target selection
The `<target>` argument can be:
- An **index** from the `targets` output (e.g., `3`)
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ 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, TabsList, TabsTrigger, TabsContent, tabsListVariants }
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }

View File

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

View File

@@ -2,6 +2,7 @@ import type { FC } from 'react'
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
import { NewTab } from '../newtab/index/NewTab'
import { NewTabChat } from '../newtab/index/NewTabChat'
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
import { Personalize } from '../newtab/personalize/Personalize'
import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
@@ -79,6 +80,7 @@ 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, useMemo, useState } from 'react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -13,14 +13,27 @@ 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 type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import {
getProviderTemplate,
type ProviderTemplate,
} from '@/lib/llm-providers/providerTemplates'
import { testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
import { track } from '@/lib/metrics/track'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import {
DeleteRemoteLlmProviderDocument,
@@ -101,12 +114,117 @@ 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,
@@ -119,6 +237,68 @@ 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)
@@ -130,6 +310,15 @@ 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,6 +61,8 @@ const providerTypeEnum = z.enum([
'lmstudio',
'bedrock',
'browseros',
'chatgpt-pro',
'github-copilot',
])
/**
@@ -84,6 +86,9 @@ 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
@@ -127,6 +132,10 @@ 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({
@@ -182,6 +191,10 @@ 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') {
@@ -209,6 +222,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: initialValues?.secretAccessKey || '',
region: initialValues?.region || '',
sessionToken: initialValues?.sessionToken || '',
reasoningEffort: initialValues?.reasoningEffort || 'high',
reasoningSummary: initialValues?.reasoningSummary || 'auto',
},
})
@@ -301,6 +316,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: initialValues.secretAccessKey || '',
region: initialValues.region || '',
sessionToken: initialValues.sessionToken || '',
reasoningEffort: initialValues.reasoningEffort || 'high',
reasoningSummary: initialValues.reasoningSummary || 'auto',
})
setIsCustomModel(false)
}
@@ -326,6 +343,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
secretAccessKey: '',
region: '',
sessionToken: '',
reasoningEffort: 'high',
reasoningSummary: 'auto',
})
setIsCustomModel(false)
}
@@ -363,6 +382,10 @@ 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
}
@@ -444,6 +467,84 @@ 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,8 +103,10 @@ export const ProviderCard: FC<ProviderCardProps> = ({
for better performance.
</>
)
) : (
) : provider.baseUrl ? (
`${provider.modelId}${provider.baseUrl}`
) : (
provider.modelId
)}
</p>
</div>

View File

@@ -26,6 +26,10 @@ 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,6 +23,8 @@ export interface ModelsData {
bedrock: ModelInfo[]
browseros: ModelInfo[]
moonshot: ModelInfo[]
'chatgpt-pro': ModelInfo[]
'github-copilot': ModelInfo[]
}
/**
@@ -90,6 +92,42 @@ 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,6 +156,7 @@ 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 useSWR from 'swr'
import { useQuery } from '@tanstack/react-query'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
interface UserMCPIntegrationsList {
@@ -9,7 +9,11 @@ interface UserMCPIntegrationsList {
count: number
}
const getUserMCPIntegrations = async ([hostUrl]: [hostUrl: string]) => {
export const INTEGRATIONS_QUERY_KEY = 'klavis-user-integrations'
const getUserMCPIntegrations = async (
hostUrl: string,
): Promise<UserMCPIntegrationsList> => {
const response = await fetch(`${hostUrl}/klavis/user-integrations`)
const data = (await response.json()) as UserMCPIntegrationsList
return data
@@ -18,12 +22,19 @@ const getUserMCPIntegrations = async ([hostUrl]: [hostUrl: string]) => {
export const useGetUserMCPIntegrations = () => {
const { baseUrl: agentServerUrl } = useAgentServerUrl()
return useSWR(
agentServerUrl ? [agentServerUrl, 'klavis/user-integrations'] : null,
getUserMCPIntegrations,
{
keepPreviousData: true,
revalidateOnFocus: true,
},
)
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,
}
}

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,6 +81,7 @@ 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) => {
@@ -89,7 +90,7 @@ export const Chat: FC<Props> = ({
})
voice.clearTranscript()
}
}, [voice])
}, [voice.transcript, voice.isTranscribing])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()

View File

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

View File

@@ -7,8 +7,6 @@ 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
@@ -25,10 +23,6 @@ export const SidebarLayout: FC = () => {
setShortcutsDialogOpen(true)
}, [])
useEffect(() => {
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
}, [location.pathname])
useEffect(() => {
setMobileOpen(false)
}, [])
@@ -103,11 +97,17 @@ export const SidebarLayout: FC = () => {
</div>
{/* Main content - full width, centered */}
<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">
{location.pathname === '/home/chat' ? (
<main className="relative h-dvh overflow-hidden">
<Outlet />
</div>
</main>
</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>
<ShortcutsDialog
open={shortcutsDialogOpen}

View File

@@ -1,8 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod/v3'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -31,6 +35,15 @@ 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
@@ -43,6 +56,7 @@ 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) => {
@@ -81,6 +95,8 @@ 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),
@@ -90,14 +106,36 @@ 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,
@@ -105,6 +143,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleType: initialValues.scheduleType,
scheduleTime: initialValues.scheduleTime || '09:00',
scheduleInterval: initialValues.scheduleInterval || 1,
providerId: initialValues.providerId,
enabled: initialValues.enabled,
})
} else {
@@ -114,12 +153,87 @@ 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(),
@@ -129,9 +243,11 @@ 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)
}
@@ -169,22 +285,96 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
name="query"
render={({ field }) => (
<FormItem>
<FormLabel>Prompt</FormLabel>
<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>
<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>
<FormDescription>
The instruction that will be sent to the agent
</FormDescription>
{!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>
)}
<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, useMemo, useState } from 'react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
@@ -20,6 +20,9 @@ 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'
@@ -80,9 +83,25 @@ 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
@@ -117,6 +136,19 @@ 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,5 +1,6 @@
import { AlertCircle, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
import { AlertCircle, Eye, 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,
@@ -108,23 +109,19 @@ export const SkillsPage: FC = () => {
) : null}
{!isLoading && !error && skills.length > 0 ? (
<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>
<SkillSections
skills={skills}
onEdit={handleEdit}
onDelete={(skill) => setSkillToDelete(skill)}
onToggle={handleToggle}
/>
) : null}
<SkillDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
editingSkill={editingSkill}
readOnly={editingSkill?.builtIn}
onSave={async (data) => {
try {
if (editingSkill) {
@@ -251,6 +248,50 @@ 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
@@ -260,7 +301,14 @@ 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">
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
<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>
<Switch
checked={skill.enabled}
onCheckedChange={onToggle}
@@ -281,18 +329,29 @@ const SkillCard: FC<{
onClick={onEdit}
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<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" />
{skill.builtIn ? (
<>
<Eye className="size-3.5" />
View
</>
) : (
<>
<Pencil className="size-3.5" />
Edit
</>
)}
</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>
@@ -302,12 +361,13 @@ 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, onSave }) => {
}> = ({ open, onOpenChange, editingSkill, readOnly, onSave }) => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [content, setContent] = useState('')
@@ -354,12 +414,18 @@ 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>
{editingSkill ? 'Edit Skill' : 'Create Skill'}
{readOnly
? 'View Skill'
: editingSkill
? 'Edit Skill'
: 'Create Skill'}
</DialogTitle>
<DialogDescription>
{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.'}
{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.'}
</DialogDescription>
</DialogHeader>
@@ -373,6 +439,7 @@ 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.
@@ -388,19 +455,22 @@ 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>
<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>
{!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>
<div className="flex min-h-0 flex-col px-6 py-5">
@@ -411,36 +481,52 @@ const SkillDialog: FC<{
</Badge>
</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"
/>
{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"
/>
)}
</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">
Saved locally and available to your agent immediately.
{readOnly
? 'This skill is managed by BrowserOS and updated automatically.'
: 'Saved locally and available to your agent immediately.'}
</p>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || saving}>
{saving
? 'Saving...'
: editingSkill
? 'Update Skill'
: 'Create Skill'}
</Button>
{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>
</>
)}
</div>
</div>
</DialogContent>

View File

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

View File

@@ -18,6 +18,7 @@ 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'
@@ -66,7 +67,12 @@ export default defineBackground(() => {
}
})
chrome.runtime.onMessage.addListener((message, sender) => {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === 'get-tab-id') {
sendResponse({ tabId: sender.tab?.id })
return true
}
if (message?.type === 'AUTH_SUCCESS' && sender.tab?.id) {
const tabId = sender.tab.id
authRedirectPathStorage
@@ -93,6 +99,17 @@ 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,6 +117,7 @@ export const scheduledJobRuns = async () => {
const response = await getChatServerResponse({
message: job.query,
signal: abortController.signal,
providerId: job.providerId,
})
await updateJobRun(jobRun.id, {

View File

@@ -1,6 +1,7 @@
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,
@@ -48,7 +49,9 @@ 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">Import your data</CardTitle>
<CardTitle className="text-base">
{i18n.t('newtab.importData.title')}
</CardTitle>
</div>
<Button
variant="ghost"
@@ -60,7 +63,7 @@ export const ImportDataHint = () => {
</Button>
</div>
<CardDescription>
Bring bookmarks, history, and passwords from Chrome.
{i18n.t('newtab.importData.description')}
</CardDescription>
<label
htmlFor="import-dont-ask-again"
@@ -73,11 +76,11 @@ export const ImportDataHint = () => {
setDontAskAgain(checked === true)
}
/>
Don't show this again
{i18n.t('newtab.importData.dontShowAgain')}
</label>
<Button className="w-full" onClick={handleImport}>
<Upload className="size-4" />
Open Import Settings
{i18n.t('newtab.importData.openSettings')}
</Button>
</CardHeader>
</Card>

View File

@@ -11,6 +11,9 @@ 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,
@@ -36,7 +39,6 @@ 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,
@@ -45,6 +47,8 @@ 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'
@@ -58,7 +62,6 @@ 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'
@@ -78,13 +81,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: '',
@@ -92,13 +95,12 @@ export const NewTab = () => {
})
const { selectedFolder } = useWorkspace()
const { supports } = useCapabilities()
const { providers, selectedProvider, handleSelectProvider } =
useChatSessionContext()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
useSyncRemoteIntegrations()
const { messages, sendMessage, setMode, resetConversation } =
useChatSessionContext()
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
return userMCPIntegrations?.integrations?.find(
@@ -128,7 +130,9 @@ export const NewTab = () => {
query: inputValue,
selectedTabs,
})
const searchPlaceholder = `Ask BrowserOS or search ${providerConfig.name}...`
const searchPlaceholder = i18n.t('newtab.search.placeholder', [
providerConfig.name,
])
const {
isOpen,
@@ -275,17 +279,28 @@ export const NewTab = () => {
const startInlineChat = (
message: string,
mode: 'chat' | 'agent',
action?: ReturnType<
typeof createBrowserOSAction | typeof createAITabAction
>,
chatMode: 'chat' | 'agent',
aiTab?: { name: string; description: string },
) => {
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
setMode(mode)
setChatActive(true)
sendMessage({ text: message, action })
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)
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) => {
@@ -306,15 +321,18 @@ 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', action)
startInlineChat(searchQuery, 'agent', {
name: item.name,
description: item.description,
})
} else {
const action = createAITabAction({
name: item.name,
description: item.description,
tabs: selectedTabs,
})
openSidePanelWithSearch('open', {
query: searchQuery,
mode: 'agent',
@@ -330,14 +348,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, action)
startInlineChat(item.message, item.mode)
} else {
const action = createBrowserOSAction({
mode: item.mode,
message: item.message,
tabs: selectedTabs,
})
openSidePanelWithSearch('open', {
query: item.message,
mode: item.mode,
@@ -351,12 +369,6 @@ export const NewTab = () => {
}
}
const handleBackToSearch = () => {
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
resetConversation()
setChatActive(false)
}
const isSuggestionsVisible =
!mentionState.isOpen &&
((isOpen && inputValue.length) ||
@@ -368,10 +380,6 @@ export const NewTab = () => {
track(NEWTAB_OPENED_EVENT)
}, [])
if (chatActive) {
return <NewTabChat onBackToSearch={handleBackToSearch} />
}
return (
<div className="pt-[max(25vh,16px)]">
{/* Main content */}
@@ -490,7 +498,7 @@ export const NewTab = () => {
{selectedTab.title}
</div>
<div className="text-muted-foreground text-xs">
Tab
{i18n.t('newtab.selectedTab')}
</div>
</div>
<button
@@ -524,6 +532,34 @@ 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
@@ -536,7 +572,10 @@ export const NewTab = () => {
)}
>
<Folder className="h-4 w-4" />
<span>{selectedFolder?.name || 'Add workspace'}</span>
<span>
{selectedFolder?.name ||
i18n.t('newtab.addWorkspace')}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</WorkspaceSelector>
@@ -559,7 +598,7 @@ export const NewTab = () => {
)}
>
<Layers className="h-4 w-4" />
<span>Tabs</span>
<span>{i18n.t('common.tabs')}</span>
</Button>
</TabPickerPopover>
</div>
@@ -569,7 +608,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">
New!
{i18n.t('newtab.appsNew')}
</span>
)}
{connectedManagedServers.length === 0 ? (
@@ -590,14 +629,13 @@ export const NewTab = () => {
)}
>
<PlugZap className="h-4 w-4" />
<span>Apps</span>
<span>{i18n.t('common.apps')}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</TooltipTrigger>
</AppSelector>
<TooltipContent side="left" className="max-w-56">
Apps directly connected will have more accurate and
faster responses for your queries!
{i18n.t('newtab.appsTooltip')}
</TooltipContent>
</Tooltip>
) : (
@@ -634,7 +672,7 @@ export const NewTab = () => {
+{connectedManagedServers.length - 4}
</span>
)}
<span>Apps</span>
<span>{i18n.t('common.apps')}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</AppSelector>

View File

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

View File

@@ -1,35 +1,41 @@
import { Loader2 } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { type FC, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router'
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'
interface NewTabChatProps {
onBackToSearch: () => void
}
export const NewTabChat: FC = () => {
const [searchParams, setSearchParams] = useSearchParams()
const hasSentInitialRef = useRef(false)
export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
const {
mode,
setMode,
messages,
sendMessage,
status,
stop,
agentUrlError,
chatError,
getActionForMessage,
@@ -42,71 +48,80 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
selectedProvider,
handleSelectProvider,
resetConversation,
} = useChatSessionContext()
const [input, setInput] = useState('')
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
const [mounted, setMounted] = useState(false)
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,
},
})
// 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(() => {
setMounted(true)
}, [])
if (hasSentInitialRef.current) return
const query = searchParams.get('q')
const chatMode = searchParams.get('mode')
const tabIdsParam = searchParams.get('tabs')
if (!query) return
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 })
hasSentInitialRef.current = true
if (chatMode === 'chat' || chatMode === 'agent') {
setMode(chatMode)
}
setInput('')
setAttachedTabs([])
}
setSearchParams({}, { replace: true })
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
executeMessage()
}
const actionType = searchParams.get('actionType')
const tabName = searchParams.get('tabName')
const tabDescription = searchParams.get('tabDescription')
const handleSuggestionClick = (suggestion: string) => {
track(NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT, { mode })
executeMessage(suggestion)
}
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 })
}
}, [])
const handleNewConversation = () => {
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
@@ -116,17 +131,19 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
if (!selectedProvider) return null
return (
<div className="flex h-[calc(100vh-2rem)] flex-col">
<NewTabChatHeader
selectedProvider={selectedProvider}
providers={providers}
onSelectProvider={handleSelectProvider}
onNewConversation={handleNewConversation}
onBackToSearch={onBackToSearch}
hasMessages={messages.length > 0}
/>
<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>
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto px-4 pt-4">
<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">
{isRestoringConversation ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -156,7 +173,7 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
{chatError && <ChatError error={chatError} />}
</main>
<div className="mx-auto w-full max-w-3xl px-4">
<div className="mx-auto w-full max-w-3xl flex-shrink-0 px-4 pb-2">
<ChatFooter
mode={mode}
onModeChange={handleModeChange}
@@ -168,6 +185,7 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
attachedTabs={attachedTabs}
onToggleTab={toggleTabSelection}
onRemoveTab={removeTab}
voice={voiceState}
/>
</div>
</div>

View File

@@ -1,78 +0,0 @@
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,6 +1,7 @@
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'
@@ -39,7 +40,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)]">
Tip:
{i18n.t('newtab.tip.label')}
</span>{' '}
{tip.text}
</p>
@@ -47,7 +48,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="Next tip"
title={i18n.t('newtab.tip.nextTip')}
>
<ChevronRight className="h-3 w-3" />
</button>
@@ -55,7 +56,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="Dismiss"
title={i18n.t('newtab.tip.dismiss')}
>
<X className="h-3 w-3" />
</button>

View File

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

View File

@@ -2,6 +2,7 @@ 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,
@@ -47,7 +48,9 @@ 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">Sync your data</CardTitle>
<CardTitle className="text-base">
{i18n.t('newtab.signIn.title')}
</CardTitle>
</div>
<Button
variant="ghost"
@@ -59,7 +62,7 @@ export const SignInHint = () => {
</Button>
</div>
<CardDescription>
Sign in to sync conversation history to the cloud.
{i18n.t('newtab.signIn.description')}
</CardDescription>
<label
htmlFor="sync-dont-ask-again"
@@ -72,10 +75,10 @@ export const SignInHint = () => {
setDontAskAgain(checked === true)
}
/>
Don't ask again
{i18n.t('newtab.signIn.dontAskAgain')}
</label>
<Button className="w-full" onClick={() => navigate('/login')}>
Sign in
{i18n.t('newtab.signIn.signInButton')}
</Button>
</CardHeader>
</Card>

View File

@@ -3,14 +3,19 @@ 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">
{location.pathname !== '/home/soul' &&
location.pathname !== '/home/memory' &&
location.pathname !== '/home/skills' && <NewTabFocusGrid />}
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
<Outlet />
</ChatSessionProvider>
)

View File

@@ -0,0 +1,42 @@
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,9 +8,14 @@ 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'
@@ -48,6 +53,8 @@ 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)
@@ -83,6 +90,26 @@ 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)
@@ -147,6 +174,27 @@ 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">
@@ -190,6 +238,7 @@ export const Chat = () => {
attachedTabs={attachedTabs}
onToggleTab={toggleTabSelection}
onRemoveTab={removeTab}
voice={voiceState}
/>
</>
)

View File

@@ -1,5 +1,6 @@
import { Globe, X } from 'lucide-react'
import type { FC } from 'react'
import { i18n } from '#i18n'
interface ChatAttachedTabsProps {
tabs: chrome.tabs.Tab[]
@@ -34,7 +35,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="Remove tab"
title={i18n.t('attachedTabs.removeTab')}
>
<X className="h-3 w-3 text-muted-foreground" />
</button>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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'
@@ -8,12 +9,17 @@ 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 { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import {
type SelectedTextData,
selectedTextStorage,
} from '@/lib/selected-text/selectedTextStorage'
import { cn } from '@/lib/utils'
import type { VoiceInputState } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ChatAttachedTabs } from './ChatAttachedTabs'
import { ChatInput, type ChatInputHandle } from './ChatInput'
import { ChatModeToggle } from './ChatModeToggle'
import { ChatSelectedText } from './ChatSelectedText'
import type { ChatMode } from './chatTypes'
interface ChatFooterProps {
@@ -27,6 +33,7 @@ interface ChatFooterProps {
attachedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
onRemoveTab: (tabId?: number) => void
voice?: VoiceInputState
}
export const ChatFooter: FC<ChatFooterProps> = ({
@@ -40,13 +47,40 @@ 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(() => {
@@ -80,6 +114,19 @@ 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">
@@ -96,7 +143,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="Attach tabs (@)"
title={i18n.t('footer.attachTabs')}
>
<Layers className="h-4 w-4" />
{attachedTabs.length > 0 && (
@@ -120,7 +167,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
title={
selectedFolder
? selectedFolder.name
: 'Select workspace folder'
: i18n.t('footer.selectWorkspace')
}
>
<div className="relative">
@@ -139,7 +186,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="Connect apps"
title={i18n.t('footer.connectApps')}
>
{connectedManagedServers.length > 0 ? (
<>
@@ -172,6 +219,10 @@ 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}
@@ -182,6 +233,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
selectedTabs={attachedTabs}
onToggleTab={onToggleTab}
onTabMentionOpenChange={setIsTabMentionOpen}
voice={voice}
ref={chatInputRef}
/>
</div>

View File

@@ -1,6 +1,7 @@
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'
@@ -14,6 +15,7 @@ interface ChatHeaderProps {
onSelectProvider: (provider: Provider) => void
onNewConversation: () => void
hasMessages: boolean
hideHistory?: boolean
}
export const ChatHeader: FC<ChatHeaderProps> = ({
@@ -22,6 +24,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
onSelectProvider,
onNewConversation,
hasMessages,
hideHistory,
}) => {
const location = useLocation()
const navigate = useNavigate()
@@ -44,7 +47,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="Change AI Provider"
title={i18n.t('chat.header.changeProvider')}
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={18} />
@@ -67,37 +70,38 @@ 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="New conversation"
title={i18n.t('chat.header.newConversation')}
>
<Plus className="h-4 w-4" />
</button>
)}
{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>
)}
{!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>
))}
<a
href={productRepositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Star on Github"
title={i18n.t('chat.header.starOnGithub')}
>
<Github className="h-4 w-4" />
</a>
@@ -107,7 +111,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="Settings"
title={i18n.t('chat.header.settings')}
>
<SettingsIcon className="h-4 w-4" />
</a>

View File

@@ -1,4 +1,4 @@
import { Send, SquareStop } from 'lucide-react'
import { Loader2, Mic, Send, Square, SquareStop } from 'lucide-react'
import type { FormEvent, KeyboardEvent } from 'react'
import {
forwardRef,
@@ -8,8 +8,10 @@ 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 {
@@ -28,6 +30,7 @@ interface ChatInputProps {
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
onTabMentionOpenChange?: (isOpen: boolean) => void
voice?: VoiceInputState
}
export interface ChatInputHandle {
@@ -49,6 +52,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
selectedTabs,
onToggleTab,
onTabMentionOpenChange,
voice,
},
ref,
) => {
@@ -259,6 +263,78 @@ 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}
@@ -273,38 +349,43 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
onClose={closeMention}
anchorRef={textareaRef}
/>
<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>
{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>
) : (
<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>
<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}
/>
)}
<div className="absolute right-1.5 bottom-1.5 flex items-center gap-1">
{renderVoiceButton()}
{renderSendButton()}
</div>
</form>
)
},

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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,
@@ -40,7 +41,9 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
if (dismissed) return null
const scheduleLabel =
scheduleType === 'daily' ? `daily at ${scheduleTime}` : 'every hour'
scheduleType === 'daily'
? i18n.t('chat.schedule.dailyAt', [scheduleTime])
: i18n.t('chat.schedule.everyHour')
const handleSchedule = () => {
track(BREADCRUMB_SCHEDULE_CLICKED_EVENT, {
@@ -75,7 +78,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">Run this automatically?</p>
<p className="font-medium text-sm">{i18n.t('chat.schedule.title')}</p>
<p className="mt-1 text-muted-foreground text-xs">
&ldquo;{suggestedName}&rdquo; &mdash; I can run this {scheduleLabel}
</p>
@@ -84,10 +87,10 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={handleSchedule}>
Schedule this task
{i18n.t('chat.schedule.scheduleButton')}
</Button>
<Button size="sm" variant="ghost" onClick={handleDismiss}>
Maybe later
{i18n.t('chat.schedule.maybeLater')}
</Button>
</div>
</div>

View File

@@ -27,6 +27,7 @@ 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'
@@ -70,6 +71,8 @@ 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.`
@@ -163,8 +166,34 @@ 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
@@ -208,8 +237,12 @@ 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
@@ -284,7 +317,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
: undefined
return {
const result = {
api: `${agentUrlRef.current}/chat`,
body: {
message,
@@ -305,6 +338,9 @@ 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'
@@ -316,8 +352,21 @@ 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
},
}),
})
@@ -411,6 +460,19 @@ 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
@@ -421,12 +483,47 @@ 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) => {
@@ -503,6 +600,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
providers,
selectedProvider,
isLoading: isLoadingProviders || isLoadingAgentUrl,
isSyncing: !isIntegrationsSynced,
isRestoringConversation,
agentUrlError,
chatError,

View File

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

View File

@@ -45,6 +45,10 @@ 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',
}
/**
@@ -72,6 +76,8 @@ 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

@@ -0,0 +1,172 @@
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,6 +29,30 @@ 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'
@@ -56,6 +80,10 @@ 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'
@@ -114,6 +142,21 @@ 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'
@@ -251,3 +294,18 @@ 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 } from 'lucide-react'
import { Bot, Github } from 'lucide-react'
import type { FC, SVGProps } from 'react'
import ProductLogoSvg from '@/assets/product_logo.svg'
import type { ProviderType } from './types'
@@ -32,6 +32,8 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
bedrock: Bedrock,
browseros: null,
moonshot: Kimi,
'chatgpt-pro': OpenAI,
'github-copilot': Github,
}
interface ProviderIconProps {

View File

@@ -20,6 +20,24 @@ 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',
@@ -129,6 +147,8 @@ 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' },
@@ -157,6 +177,8 @@ 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,6 +14,8 @@ export type ProviderType =
| 'bedrock'
| 'browseros'
| 'moonshot'
| 'chatgpt-pro'
| 'github-copilot'
/**
* LLM Provider configuration
@@ -56,6 +58,10 @@ 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,9 +158,7 @@ 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

@@ -0,0 +1,90 @@
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,8 +1,15 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } 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.
*
@@ -12,8 +19,10 @@ import { type McpServer, mcpServerStorage } from './mcpServerStorage'
*
* 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() {
export function useSyncRemoteIntegrations(): SyncStatus {
const { data: userMCPIntegrations, isLoading: isIntegrationsLoading } =
useGetUserMCPIntegrations()
const { data: serversList } = useGetMCPServersList()
@@ -21,13 +30,26 @@ export function useSyncRemoteIntegrations() {
const serversListRef = useRef(serversList)
integrationsRef.current = userMCPIntegrations
serversListRef.current = serversList
const hasSynced = useRef(false)
const hasSyncedRef = useRef(false)
const [syncState, setSyncState] = useState<SyncStatus>({
isSyncing: true,
hasSynced: false,
})
const integrationCount = userMCPIntegrations?.integrations?.length ?? 0
useEffect(() => {
if (isIntegrationsLoading || !integrationCount) return
if (hasSynced.current) return
// 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
const integrations = integrationsRef.current?.integrations
if (!integrations) return
@@ -40,26 +62,30 @@ export function useSyncRemoteIntegrations() {
!localServers.some((s) => s.managedServerName === remote.name),
)
if (missing.length === 0) return
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 ?? '',
}
})
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 ?? '',
}
})
await mcpServerStorage.setValue([...localServers, ...newServers])
}
await mcpServerStorage.setValue([...localServers, ...newServers])
hasSyncedRef.current = true
setSyncState({ isSyncing: false, hasSynced: true })
}
hasSynced.current = true
syncMissing()
}, [isIntegrationsLoading, integrationCount])
return syncState
}

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ interface ChatServerRequest {
windowId?: number
activeTab?: ActiveTab
signal?: AbortSignal
providerId?: string
}
interface ChatServerResponse {
@@ -75,11 +76,23 @@ 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 getDefaultProvider()) ?? createDefaultBrowserOSProvider()
const provider = await resolveProvider(request.providerId)
const conversationId = request.conversationId ?? crypto.randomUUID()
const personalization = await personalizationStorage.getValue()

View File

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

View File

@@ -0,0 +1,71 @@
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,6 +6,7 @@ export interface ScheduledJob {
scheduleTime?: string
scheduleInterval?: number
enabled: boolean
providerId?: string
createdAt: string
updatedAt: string
lastRunAt?: string

View File

@@ -19,6 +19,7 @@ type RemoteScheduledJob = {
scheduleTime: string | null
scheduleInterval: number | null
enabled: boolean
llmProviderId: string | null
createdAt: string
updatedAt: string
lastRunAt: string | null
@@ -32,6 +33,7 @@ function toComparable(job: ScheduledJob) {
...data,
scheduleTime: data.scheduleTime ?? null,
scheduleInterval: data.scheduleInterval ?? null,
providerId: data.providerId ?? null,
}
}
@@ -43,6 +45,7 @@ function remoteToComparable(job: RemoteScheduledJob) {
scheduleTime: job.scheduleTime,
scheduleInterval: job.scheduleInterval,
enabled: job.enabled,
providerId: job.llmProviderId,
}
}
@@ -59,6 +62,7 @@ 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
@@ -163,6 +167,7 @@ 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,
@@ -182,6 +187,7 @@ 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

@@ -0,0 +1,14 @@
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,18 +1,35 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
const GATEWAY_URL = 'https://llm.browseros.com'
const WAVEFORM_BAND_COUNT = 5
interface UseVoiceInputReturn {
export interface VoiceInputState {
isRecording: boolean
isTranscribing: boolean
audioLevels: number[]
error: string | null
onStartRecording: () => void
onStopRecording: () => void
}
export interface UseVoiceInputReturn {
isRecording: boolean
isTranscribing: boolean
transcript: string
audioLevel: number
audioLevels: number[]
error: string | null
startRecording: () => Promise<void>
startRecording: () => Promise<boolean>
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')
@@ -21,16 +38,19 @@ 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 error = await response
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(error.error || `Transcription failed: ${response.status}`)
throw new Error(
errorBody.error || `Transcription failed: ${response.status}`,
)
}
const result = await response.json()
const result: TranscribeResponse = await response.json()
return result.text || ''
}
@@ -39,6 +59,7 @@ 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)
@@ -48,7 +69,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
const analyserRef = useRef<AnalyserNode | null>(null)
const animationFrameRef = useRef<number | null>(null)
const stopAudioLevelMonitoring = useCallback(() => {
const stopAudioLevelMonitoring = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
@@ -59,8 +80,10 @@ 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) => {
@@ -71,9 +94,9 @@ export function useVoiceInput(): UseVoiceInputReturn {
}
stopAudioLevelMonitoring()
}
}, [stopAudioLevelMonitoring])
}, [])
const startAudioLevelMonitoring = useCallback((stream: MediaStream) => {
const startAudioLevelMonitoring = (stream: MediaStream) => {
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
analyser.fftSize = 256
@@ -87,20 +110,36 @@ export function useVoiceInput(): UseVoiceInputReturn {
const updateLevel = () => {
if (!analyserRef.current) return
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount)
analyserRef.current.getByteFrequencyData(dataArray)
const dataArray = new Uint8Array(analyserRef.current.fftSize)
analyserRef.current.getByteTimeDomainData(dataArray)
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
const normalized = Math.min(100, (average / 128) * 100)
setAudioLevel(Math.round(normalized))
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))
animationFrameRef.current = requestAnimationFrame(updateLevel)
}
updateLevel()
}, [])
}
const startRecording = useCallback(async () => {
const startRecording = async (): Promise<boolean> => {
try {
setError(null)
setTranscript('')
@@ -133,7 +172,14 @@ 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')
@@ -145,10 +191,11 @@ export function useVoiceInput(): UseVoiceInputReturn {
} else {
setError('Failed to start recording')
}
return false
}
}, [startAudioLevelMonitoring])
}
const stopRecording = useCallback(async () => {
const stopRecording = async () => {
const mediaRecorder = mediaRecorderRef.current
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
@@ -188,18 +235,19 @@ export function useVoiceInput(): UseVoiceInputReturn {
} finally {
setIsTranscribing(false)
}
}, [stopAudioLevelMonitoring])
}
const clearTranscript = useCallback(() => {
const clearTranscript = () => {
setTranscript('')
setError(null)
}, [])
}
return {
isRecording,
isTranscribing,
transcript,
audioLevel,
audioLevels,
error,
startRecording,
stopRecording,

View File

@@ -0,0 +1,134 @@
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

@@ -0,0 +1,169 @@
# 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

@@ -0,0 +1,134 @@
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

@@ -0,0 +1,134 @@
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

@@ -0,0 +1,134 @@
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: タブ

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