From f8d30ddabee7e8d3668c27371e4880c7ccf4be4b Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:09:58 -0700 Subject: [PATCH 01/10] feat(agent): centralize adapter visibility and hide hermes from chat targets --- .../sidepanel/index/sidepanel-chat-targets.ts | 7 +- .../agent/lib/chat/adapter-visibility.test.ts | 107 ++++++++++++++++++ .../apps/agent/lib/chat/adapter-visibility.ts | 23 ++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.test.ts create mode 100644 packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.ts diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts index b04cf6a24..c0af513eb 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts @@ -4,6 +4,9 @@ import type { HarnessAgentAdapter, } from '@/entrypoints/app/agents/agent-harness-types' import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types' +// Relative (not `@/`) so this module stays loadable under `bun test`, which +// resolves tsconfig `@/` aliases for erased type imports only, not values. +import { isAdapterHidden } from '../../../lib/chat/adapter-visibility' export type SidepanelTargetKind = 'llm' | 'acp' @@ -70,7 +73,9 @@ export function buildSidepanelChatTargets({ }: BuildSidepanelChatTargetsInput): SidepanelChatTarget[] { return [ ...providers.map(toLlmTarget), - ...agents.map((agent) => toAcpTargetForAgent(agent, adapters)), + ...agents + .filter((agent) => !isAdapterHidden(agent.adapter)) + .map((agent) => toAcpTargetForAgent(agent, adapters)), ] } diff --git a/packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.test.ts b/packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.test.ts new file mode 100644 index 000000000..f55b72ff9 --- /dev/null +++ b/packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'bun:test' +import type { + HarnessAdapterDescriptor, + HarnessAgent, + HarnessAgentAdapter, +} from '@/entrypoints/app/agents/agent-harness-types' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +// Relative value import: `bun test` resolves tsconfig paths from the package +// root, where `@/` is undefined — only erased `import type` works via `@/`. +import { buildSidepanelChatTargets } from '../../entrypoints/sidepanel/index/sidepanel-chat-targets' +import { isAdapterHidden, visibleAdapters } from './adapter-visibility' + +function makeAdapter(id: HarnessAgentAdapter): HarnessAdapterDescriptor { + return { + id, + name: id, + defaultModelId: 'model', + defaultReasoningEffort: 'medium', + modelControl: 'best-effort', + models: [], + reasoningEfforts: [], + } +} + +function makeAgent(id: string, adapter: HarnessAgentAdapter): HarnessAgent { + return { + id, + name: id, + adapter, + permissionMode: 'approve-all', + sessionKey: 'session', + createdAt: 0, + updatedAt: 0, + } +} + +function makeProvider(id: string): LlmProviderConfig { + return { + id, + type: 'browseros', + name: id, + modelId: 'model', + supportsImages: false, + contextWindow: 1000, + temperature: 0.2, + createdAt: 0, + updatedAt: 0, + } +} + +describe('isAdapterHidden', () => { + it('hides hermes', () => { + expect(isAdapterHidden('hermes')).toBe(true) + }) + + it('shows claude and codex', () => { + expect(isAdapterHidden('claude')).toBe(false) + expect(isAdapterHidden('codex')).toBe(false) + }) +}) + +describe('visibleAdapters', () => { + it('drops hermes descriptors and preserves the order of the rest', () => { + const result = visibleAdapters([ + makeAdapter('claude'), + makeAdapter('hermes'), + makeAdapter('codex'), + ]) + expect(result.map((adapter) => adapter.id)).toEqual(['claude', 'codex']) + }) +}) + +describe('buildSidepanelChatTargets adapter visibility', () => { + it('omits acp targets for hermes-backed agents but keeps claude/codex', () => { + const targets = buildSidepanelChatTargets({ + providers: [], + adapters: [ + makeAdapter('claude'), + makeAdapter('codex'), + makeAdapter('hermes'), + ], + agents: [ + makeAgent('a', 'claude'), + makeAgent('b', 'hermes'), + makeAgent('c', 'codex'), + ], + }) + expect( + targets + .filter((target) => target.kind === 'acp') + .map((target) => target.id), + ).toEqual(['a', 'c']) + }) + + it('keeps one llm target per provider', () => { + const targets = buildSidepanelChatTargets({ + providers: [makeProvider('p1'), makeProvider('p2')], + adapters: [], + agents: [], + }) + expect( + targets + .filter((target) => target.kind === 'llm') + .map((target) => target.id), + ).toEqual(['p1', 'p2']) + }) +}) diff --git a/packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.ts b/packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.ts new file mode 100644 index 000000000..0c6cd19d8 --- /dev/null +++ b/packages/browseros-agent/apps/agent/lib/chat/adapter-visibility.ts @@ -0,0 +1,23 @@ +import type { + HarnessAdapterDescriptor, + HarnessAgentAdapter, +} from '@/entrypoints/app/agents/agent-harness-types' + +/** + * Single source of truth for which harness adapters are exposed in the UI. + * Hermes is hidden today (kept in the backend/types). Re-enabling it later + * is a one-line change here, and every picker / settings list / create + * dialog inherits the result. + */ +const HIDDEN_ADAPTERS: ReadonlySet = + new Set(['hermes']) + +export function isAdapterHidden(adapter: HarnessAgentAdapter): boolean { + return HIDDEN_ADAPTERS.has(adapter) +} + +export function visibleAdapters( + adapters: HarnessAdapterDescriptor[], +): HarnessAdapterDescriptor[] { + return adapters.filter((adapter) => !isAdapterHidden(adapter.id)) +} From 7cb3789bd368bad5b9918f8dcf61389827ba9d18 Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:14:19 -0700 Subject: [PATCH 02/10] feat(agent): convert AI settings into AI & Agents master-detail shell --- .../components/sidebar/SettingsSidebar.tsx | 2 +- .../app/ai-settings/AISettingsPage.tsx | 499 +++--------------- .../app/ai-settings/BrowserOsAiPane.tsx | 444 ++++++++++++++++ .../ai-settings/ai-settings-sections.test.ts | 26 + .../app/ai-settings/ai-settings-sections.ts | 21 + 5 files changed, 563 insertions(+), 429 deletions(-) create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/BrowserOsAiPane.tsx create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.test.ts create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.ts diff --git a/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx b/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx index 4ba126cfb..6097a2d43 100644 --- a/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx +++ b/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx @@ -59,7 +59,7 @@ const primarySettingsSections: NavSection[] = [ { label: 'Provider Settings', items: [ - { name: 'BrowserOS AI', to: '/settings/ai', icon: Bot }, + { name: 'AI & Agents', to: '/settings/ai', icon: Bot }, { name: 'Chat & Council Provider', to: '/settings/chat', diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx index 05fc11483..bdf948c1f 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx @@ -1,445 +1,88 @@ -import { useQueryClient } from '@tanstack/react-query' -import { type FC, useMemo, useState } from 'react' -import { toast } from 'sonner' +import type { FC, ReactNode } from 'react' +import { useSearchParams } from 'react-router' +import { AdapterIcon, adapterLabel } from '@/entrypoints/app/agents/AdapterIcon' +import { useAgentAdapters } from '@/entrypoints/app/agents/useAgents' +import { visibleAdapters } from '@/lib/chat/adapter-visibility' +import { BrowserOSIcon } from '@/lib/llm-providers/providerIcons' +import { cn } from '@/lib/utils' import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { useSessionInfo } from '@/lib/auth/sessionStorage' -import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders' -import { - CHATGPT_PRO_OAUTH_COMPLETED_EVENT, - CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT, - CHATGPT_PRO_OAUTH_STARTED_EVENT, - GITHUB_COPILOT_OAUTH_COMPLETED_EVENT, - GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT, - GITHUB_COPILOT_OAUTH_STARTED_EVENT, - QWEN_CODE_OAUTH_COMPLETED_EVENT, - QWEN_CODE_OAUTH_DISCONNECTED_EVENT, - QWEN_CODE_OAUTH_STARTED_EVENT, -} from '@/lib/constants/analyticsEvents' -import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' -import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument' -import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation' -import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery' -import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates' -import { testProvider } from '@/lib/llm-providers/testProvider' -import type { LlmProviderConfig } from '@/lib/llm-providers/types' -import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' -import { - type OAuthProviderFlowConfig, - useOAuthProviderFlow, -} from '@/lib/llm-providers/useOAuthProviderFlow' -import { track } from '@/lib/metrics/track' -import { ConfiguredProvidersList } from './ConfiguredProvidersList' -import { DeviceCodeDialog } from './DeviceCodeDialog' -import { - DeleteRemoteLlmProviderDocument, - GetRemoteLlmProvidersDocument, -} from './graphql/aiSettingsDocument' -import type { IncompleteProvider } from './IncompleteProviderCard' -import { IncompleteProvidersList } from './IncompleteProvidersList' -import { LlmProvidersHeader } from './LlmProvidersHeader' -import { McpPromoBanner } from './McpPromoBanner' -import { NewProviderDialog } from './NewProviderDialog' -import { ProviderTemplatesSection } from './ProviderTemplatesSection' - -// All OAuth providers share the same flow via useOAuthProviderFlow -const OAUTH_PROVIDERS_CONFIG: Record = { - 'chatgpt-pro': { - providerType: 'chatgpt-pro', - displayName: 'ChatGPT Plus/Pro', - startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT, - completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT, - disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT, - }, - 'github-copilot': { - providerType: 'github-copilot', - displayName: 'GitHub Copilot', - startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT, - completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT, - disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT, - clientAuth: { - deviceCodeEndpoint: 'https://github.com/login/device/code', - tokenEndpoint: 'https://github.com/login/oauth/access_token', - clientId: 'Ov23li8tweQw6odWQebz', - scopes: 'read:user', - requiresPKCE: false, - contentType: 'json', - }, - }, - 'qwen-code': { - providerType: 'qwen-code', - displayName: 'Qwen Code', - startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT, - completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT, - disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT, - clientAuth: { - deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code', - tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token', - clientId: 'f0304373b74a44d2b584a3fb70ca9e56', - scopes: 'openid profile email model.completion', - requiresPKCE: true, - contentType: 'form', - }, - }, + type AiSettingsSection, + BROWSEROS_SECTION, + resolveAiSettingsSection, +} from './ai-settings-sections' +import { BrowserOsAiPane } from './BrowserOsAiPane' + +interface SectionItem { + id: AiSettingsSection + label: string + icon: ReactNode } /** - * AI Settings page for managing LLM providers - * @public + * AI & Agents settings shell. A `?section=`-driven master-detail: BrowserOS AI + * (the LLM-providers pane) plus one entry per visible harness adapter + * (Claude/Codex; Hermes filtered out). The detail pane swaps on the active + * section. */ export const AISettingsPage: FC = () => { - const { - providers, - defaultProviderId, - saveProvider, - setDefaultProvider, - deleteProvider, - } = useLlmProviders() - const { baseUrl: agentServerUrl } = useAgentServerUrl() - const { sessionInfo } = useSessionInfo() - const queryClient = useQueryClient() - - const userId = sessionInfo.user?.id - - const { data: profileData } = useGraphqlQuery( - GetProfileIdByUserIdDocument, - // biome-ignore lint/style/noNonNullAssertion: guarded by enabled - { userId: userId! }, - { enabled: !!userId }, - ) - const profileId = profileData?.profileByUserId?.rowId + const [searchParams, setSearchParams] = useSearchParams() + const { adapters } = useAgentAdapters() - const { data: remoteProvidersData } = useGraphqlQuery( - GetRemoteLlmProvidersDocument, - // biome-ignore lint/style/noNonNullAssertion: guarded by enabled - { profileId: profileId! }, - { enabled: !!profileId }, + const shownAdapters = visibleAdapters(adapters) + const activeSection = resolveAiSettingsSection( + searchParams.get('section'), + shownAdapters.map((adapter) => adapter.id), ) - const deleteRemoteProviderMutation = useGraphqlMutation( - DeleteRemoteLlmProviderDocument, + const items: SectionItem[] = [ { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [getQueryKeyFromDocument(GetRemoteLlmProvidersDocument)], - }) - }, + id: BROWSEROS_SECTION, + label: 'BrowserOS AI', + icon: , }, - ) - - const incompleteProviders = useMemo(() => { - if (!remoteProvidersData?.llmProviders?.nodes) return [] - const localProviderIds = new Set(providers.map((p) => p.id)) - return remoteProvidersData.llmProviders.nodes - .filter((node): node is NonNullable => node !== null) - .filter((node) => !localProviderIds.has(node.rowId)) - }, [remoteProvidersData, providers]) - - const [isNewDialogOpen, setIsNewDialogOpen] = useState(false) - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [templateValues, setTemplateValues] = useState< - Partial | undefined - >() - const [editingProvider, setEditingProvider] = - useState(null) - const [providerToDelete, setProviderToDelete] = - useState(null) - const [incompleteProviderToDelete, setIncompleteProviderToDelete] = - useState(null) - const [testingProviderId, setTestingProviderId] = useState( - null, - ) - - // OAuth flows — shared hook eliminates per-provider duplication - const chatgptPro = useOAuthProviderFlow( - OAUTH_PROVIDERS_CONFIG['chatgpt-pro'], - providers, - saveProvider, - ) - const copilot = useOAuthProviderFlow( - OAUTH_PROVIDERS_CONFIG['github-copilot'], - providers, - saveProvider, - ) - const qwenCode = useOAuthProviderFlow( - OAUTH_PROVIDERS_CONFIG['qwen-code'], - providers, - saveProvider, - ) - - const activeDeviceCode = - chatgptPro.pendingDeviceCode ?? - copilot.pendingDeviceCode ?? - qwenCode.pendingDeviceCode - const clearActiveDeviceCode = () => { - chatgptPro.clearDeviceCode() - copilot.clearDeviceCode() - qwenCode.clearDeviceCode() - } - - const oauthFlows: Record< - string, - { - startOAuthFlow: (url: string | undefined) => Promise - disconnect: () => Promise - disconnectedEvent: string - } - > = { - 'chatgpt-pro': { - startOAuthFlow: chatgptPro.startOAuthFlow, - disconnect: chatgptPro.disconnect, - disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT, - }, - 'github-copilot': { - startOAuthFlow: copilot.startOAuthFlow, - disconnect: copilot.disconnect, - disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT, - }, - 'qwen-code': { - startOAuthFlow: qwenCode.startOAuthFlow, - disconnect: qwenCode.disconnect, - disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT, - }, - } - - const handleAddProvider = () => { - setTemplateValues(undefined) - setIsNewDialogOpen(true) - } - - const handleUseTemplate = (template: ProviderTemplate) => { - // OAuth providers: trigger OAuth flow - const oauthFlow = oauthFlows[template.id] - if (oauthFlow) { - oauthFlow.startOAuthFlow(agentServerUrl ?? undefined) - return - } - - setTemplateValues({ - type: template.id, - name: template.name, - baseUrl: template.defaultBaseUrl, - modelId: template.defaultModelId, - supportsImages: template.supportsImages, - contextWindow: template.contextWindow, - temperature: 0.2, - }) - setIsNewDialogOpen(true) - } - - const handleEditProvider = (provider: LlmProviderConfig) => { - setEditingProvider(provider) - setIsEditDialogOpen(true) - } - - const handleDeleteProvider = (provider: LlmProviderConfig) => { - setProviderToDelete(provider) - } - - const confirmDeleteProvider = async () => { - if (!providerToDelete) return - - // Clear OAuth tokens on server for OAuth-based providers - const oauthFlow = oauthFlows[providerToDelete.type] - if (oauthFlow) { - await oauthFlow.disconnect() - track(oauthFlow.disconnectedEvent) - } - - await deleteProvider(providerToDelete.id) - deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id }) - setProviderToDelete(null) - } - - const handleAddKeysToIncomplete = (provider: IncompleteProvider) => { - const timestamp = Date.now() - setTemplateValues({ - id: provider.rowId, - type: provider.type as LlmProviderConfig['type'], - name: provider.name, - baseUrl: provider.baseUrl ?? undefined, - modelId: provider.modelId, - supportsImages: provider.supportsImages, - contextWindow: provider.contextWindow ?? 128000, - temperature: provider.temperature ?? 0.2, - resourceName: provider.resourceName ?? undefined, - region: provider.region ?? undefined, - createdAt: timestamp, - updatedAt: timestamp, - }) - setIsNewDialogOpen(true) - } - - const handleDeleteIncompleteProvider = (provider: IncompleteProvider) => { - setIncompleteProviderToDelete(provider) - } - - const confirmDeleteIncompleteProvider = () => { - if (incompleteProviderToDelete) { - deleteRemoteProviderMutation.mutate({ - rowId: incompleteProviderToDelete.rowId, - }) - setIncompleteProviderToDelete(null) - } - } - - const handleSaveProvider = async (provider: LlmProviderConfig) => { - await saveProvider(provider) - } - - const handleSelectProvider = (providerId: string) => { - setDefaultProvider(providerId) - } - - const handleTestProvider = async (provider: LlmProviderConfig) => { - if (!agentServerUrl) { - toast.error('Test Failed', { - description: ( - - Server URL not available - - ), - duration: 3000, - }) - return - } - - setTestingProviderId(provider.id) - - try { - const result = await testProvider(provider, agentServerUrl) - - if (result.success) { - toast.success('Test Successful', { - description: ( - - {result.message} - - ), - duration: 3000, - }) - } else { - toast.error('Test Failed', { - description: ( - - {result.message} - - ), - duration: 3000, - }) - } - } catch (error) { - toast.error('Test Failed', { - description: ( - - {error instanceof Error ? error.message : 'Unknown error'} - - ), - duration: 3000, - }) - } - - setTestingProviderId(null) + ...shownAdapters.map((adapter) => ({ + id: adapter.id, + label: adapter.name || adapterLabel(adapter.id), + icon: , + })), + ] + + const selectSection = (id: AiSettingsSection) => { + const next = new URLSearchParams(searchParams) + if (id === BROWSEROS_SECTION) next.delete('section') + else next.set('section', id) + setSearchParams(next, { replace: true }) } return ( -
- - - - - - - - - - - - - - - !open && setProviderToDelete(null)} - > - - - Delete Provider - - Are you sure you want to delete "{providerToDelete?.name}"? This - action cannot be undone. - - - - Cancel - - Delete - - - - - - !open && setIncompleteProviderToDelete(null)} - > - - - Delete Synced Provider - - Are you sure you want to delete " - {incompleteProviderToDelete?.name} - "? This will remove it from all your devices. - - - - Cancel - - Delete - - - - - - +
+ + +
+ {activeSection === BROWSEROS_SECTION ? : null} +
) } diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/BrowserOsAiPane.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/BrowserOsAiPane.tsx new file mode 100644 index 000000000..365c6780e --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/BrowserOsAiPane.tsx @@ -0,0 +1,444 @@ +import { useQueryClient } from '@tanstack/react-query' +import { type FC, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { useSessionInfo } from '@/lib/auth/sessionStorage' +import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders' +import { + CHATGPT_PRO_OAUTH_COMPLETED_EVENT, + CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT, + CHATGPT_PRO_OAUTH_STARTED_EVENT, + GITHUB_COPILOT_OAUTH_COMPLETED_EVENT, + GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT, + GITHUB_COPILOT_OAUTH_STARTED_EVENT, + QWEN_CODE_OAUTH_COMPLETED_EVENT, + QWEN_CODE_OAUTH_DISCONNECTED_EVENT, + QWEN_CODE_OAUTH_STARTED_EVENT, +} from '@/lib/constants/analyticsEvents' +import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' +import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument' +import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation' +import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery' +import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates' +import { testProvider } from '@/lib/llm-providers/testProvider' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' +import { + type OAuthProviderFlowConfig, + useOAuthProviderFlow, +} from '@/lib/llm-providers/useOAuthProviderFlow' +import { track } from '@/lib/metrics/track' +import { ConfiguredProvidersList } from './ConfiguredProvidersList' +import { DeviceCodeDialog } from './DeviceCodeDialog' +import { + DeleteRemoteLlmProviderDocument, + GetRemoteLlmProvidersDocument, +} from './graphql/aiSettingsDocument' +import type { IncompleteProvider } from './IncompleteProviderCard' +import { IncompleteProvidersList } from './IncompleteProvidersList' +import { LlmProvidersHeader } from './LlmProvidersHeader' +import { McpPromoBanner } from './McpPromoBanner' +import { NewProviderDialog } from './NewProviderDialog' +import { ProviderTemplatesSection } from './ProviderTemplatesSection' + +// All OAuth providers share the same flow via useOAuthProviderFlow +const OAUTH_PROVIDERS_CONFIG: Record = { + 'chatgpt-pro': { + providerType: 'chatgpt-pro', + displayName: 'ChatGPT Plus/Pro', + startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT, + completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT, + disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT, + }, + 'github-copilot': { + providerType: 'github-copilot', + displayName: 'GitHub Copilot', + startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT, + completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT, + disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT, + clientAuth: { + deviceCodeEndpoint: 'https://github.com/login/device/code', + tokenEndpoint: 'https://github.com/login/oauth/access_token', + clientId: 'Ov23li8tweQw6odWQebz', + scopes: 'read:user', + requiresPKCE: false, + contentType: 'json', + }, + }, + 'qwen-code': { + providerType: 'qwen-code', + displayName: 'Qwen Code', + startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT, + completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT, + disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT, + clientAuth: { + deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code', + tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token', + clientId: 'f0304373b74a44d2b584a3fb70ca9e56', + scopes: 'openid profile email model.completion', + requiresPKCE: true, + contentType: 'form', + }, + }, +} + +/** + * BrowserOS AI pane — manage LLM providers and the default model. + */ +export const BrowserOsAiPane: FC = () => { + const { + providers, + defaultProviderId, + saveProvider, + setDefaultProvider, + deleteProvider, + } = useLlmProviders() + const { baseUrl: agentServerUrl } = useAgentServerUrl() + const { sessionInfo } = useSessionInfo() + const queryClient = useQueryClient() + + const userId = sessionInfo.user?.id + + const { data: profileData } = useGraphqlQuery( + GetProfileIdByUserIdDocument, + // biome-ignore lint/style/noNonNullAssertion: guarded by enabled + { userId: userId! }, + { enabled: !!userId }, + ) + const profileId = profileData?.profileByUserId?.rowId + + const { data: remoteProvidersData } = useGraphqlQuery( + GetRemoteLlmProvidersDocument, + // biome-ignore lint/style/noNonNullAssertion: guarded by enabled + { profileId: profileId! }, + { enabled: !!profileId }, + ) + + const deleteRemoteProviderMutation = useGraphqlMutation( + DeleteRemoteLlmProviderDocument, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [getQueryKeyFromDocument(GetRemoteLlmProvidersDocument)], + }) + }, + }, + ) + + const incompleteProviders = useMemo(() => { + if (!remoteProvidersData?.llmProviders?.nodes) return [] + const localProviderIds = new Set(providers.map((p) => p.id)) + return remoteProvidersData.llmProviders.nodes + .filter((node): node is NonNullable => node !== null) + .filter((node) => !localProviderIds.has(node.rowId)) + }, [remoteProvidersData, providers]) + + const [isNewDialogOpen, setIsNewDialogOpen] = useState(false) + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [templateValues, setTemplateValues] = useState< + Partial | undefined + >() + const [editingProvider, setEditingProvider] = + useState(null) + const [providerToDelete, setProviderToDelete] = + useState(null) + const [incompleteProviderToDelete, setIncompleteProviderToDelete] = + useState(null) + const [testingProviderId, setTestingProviderId] = useState( + null, + ) + + // OAuth flows — shared hook eliminates per-provider duplication + const chatgptPro = useOAuthProviderFlow( + OAUTH_PROVIDERS_CONFIG['chatgpt-pro'], + providers, + saveProvider, + ) + const copilot = useOAuthProviderFlow( + OAUTH_PROVIDERS_CONFIG['github-copilot'], + providers, + saveProvider, + ) + const qwenCode = useOAuthProviderFlow( + OAUTH_PROVIDERS_CONFIG['qwen-code'], + providers, + saveProvider, + ) + + const activeDeviceCode = + chatgptPro.pendingDeviceCode ?? + copilot.pendingDeviceCode ?? + qwenCode.pendingDeviceCode + const clearActiveDeviceCode = () => { + chatgptPro.clearDeviceCode() + copilot.clearDeviceCode() + qwenCode.clearDeviceCode() + } + + const oauthFlows: Record< + string, + { + startOAuthFlow: (url: string | undefined) => Promise + disconnect: () => Promise + disconnectedEvent: string + } + > = { + 'chatgpt-pro': { + startOAuthFlow: chatgptPro.startOAuthFlow, + disconnect: chatgptPro.disconnect, + disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT, + }, + 'github-copilot': { + startOAuthFlow: copilot.startOAuthFlow, + disconnect: copilot.disconnect, + disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT, + }, + 'qwen-code': { + startOAuthFlow: qwenCode.startOAuthFlow, + disconnect: qwenCode.disconnect, + disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT, + }, + } + + const handleAddProvider = () => { + setTemplateValues(undefined) + setIsNewDialogOpen(true) + } + + const handleUseTemplate = (template: ProviderTemplate) => { + // OAuth providers: trigger OAuth flow + const oauthFlow = oauthFlows[template.id] + if (oauthFlow) { + oauthFlow.startOAuthFlow(agentServerUrl ?? undefined) + return + } + + setTemplateValues({ + type: template.id, + name: template.name, + baseUrl: template.defaultBaseUrl, + modelId: template.defaultModelId, + supportsImages: template.supportsImages, + contextWindow: template.contextWindow, + temperature: 0.2, + }) + setIsNewDialogOpen(true) + } + + const handleEditProvider = (provider: LlmProviderConfig) => { + setEditingProvider(provider) + setIsEditDialogOpen(true) + } + + const handleDeleteProvider = (provider: LlmProviderConfig) => { + setProviderToDelete(provider) + } + + const confirmDeleteProvider = async () => { + if (!providerToDelete) return + + // Clear OAuth tokens on server for OAuth-based providers + const oauthFlow = oauthFlows[providerToDelete.type] + if (oauthFlow) { + await oauthFlow.disconnect() + track(oauthFlow.disconnectedEvent) + } + + await deleteProvider(providerToDelete.id) + deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id }) + setProviderToDelete(null) + } + + const handleAddKeysToIncomplete = (provider: IncompleteProvider) => { + const timestamp = Date.now() + setTemplateValues({ + id: provider.rowId, + type: provider.type as LlmProviderConfig['type'], + name: provider.name, + baseUrl: provider.baseUrl ?? undefined, + modelId: provider.modelId, + supportsImages: provider.supportsImages, + contextWindow: provider.contextWindow ?? 128000, + temperature: provider.temperature ?? 0.2, + resourceName: provider.resourceName ?? undefined, + region: provider.region ?? undefined, + createdAt: timestamp, + updatedAt: timestamp, + }) + setIsNewDialogOpen(true) + } + + const handleDeleteIncompleteProvider = (provider: IncompleteProvider) => { + setIncompleteProviderToDelete(provider) + } + + const confirmDeleteIncompleteProvider = () => { + if (incompleteProviderToDelete) { + deleteRemoteProviderMutation.mutate({ + rowId: incompleteProviderToDelete.rowId, + }) + setIncompleteProviderToDelete(null) + } + } + + const handleSaveProvider = async (provider: LlmProviderConfig) => { + await saveProvider(provider) + } + + const handleSelectProvider = (providerId: string) => { + setDefaultProvider(providerId) + } + + const handleTestProvider = async (provider: LlmProviderConfig) => { + if (!agentServerUrl) { + toast.error('Test Failed', { + description: ( + + Server URL not available + + ), + duration: 3000, + }) + return + } + + setTestingProviderId(provider.id) + + try { + const result = await testProvider(provider, agentServerUrl) + + if (result.success) { + toast.success('Test Successful', { + description: ( + + {result.message} + + ), + duration: 3000, + }) + } else { + toast.error('Test Failed', { + description: ( + + {result.message} + + ), + duration: 3000, + }) + } + } catch (error) { + toast.error('Test Failed', { + description: ( + + {error instanceof Error ? error.message : 'Unknown error'} + + ), + duration: 3000, + }) + } + + setTestingProviderId(null) + } + + return ( +
+ + + + + + + + + + + + + + + !open && setProviderToDelete(null)} + > + + + Delete Provider + + Are you sure you want to delete "{providerToDelete?.name}"? This + action cannot be undone. + + + + Cancel + + Delete + + + + + + !open && setIncompleteProviderToDelete(null)} + > + + + Delete Synced Provider + + Are you sure you want to delete " + {incompleteProviderToDelete?.name} + "? This will remove it from all your devices. + + + + Cancel + + Delete + + + + + + +
+ ) +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.test.ts b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.test.ts new file mode 100644 index 000000000..ebd0ae5f2 --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test' +import { resolveAiSettingsSection } from './ai-settings-sections' + +describe('resolveAiSettingsSection', () => { + const visible = ['claude', 'codex'] + + it('returns the adapter section when it is a visible adapter', () => { + expect(resolveAiSettingsSection('claude', visible)).toBe('claude') + expect(resolveAiSettingsSection('codex', visible)).toBe('codex') + }) + + it('falls back to browseros when the param is missing', () => { + expect(resolveAiSettingsSection(null, visible)).toBe('browseros') + expect(resolveAiSettingsSection(undefined, visible)).toBe('browseros') + expect(resolveAiSettingsSection('browseros', visible)).toBe('browseros') + }) + + it('falls back to browseros for hidden or unknown sections', () => { + expect(resolveAiSettingsSection('hermes', visible)).toBe('browseros') + expect(resolveAiSettingsSection('bogus', visible)).toBe('browseros') + }) + + it('falls back to browseros before adapters load', () => { + expect(resolveAiSettingsSection('claude', [])).toBe('browseros') + }) +}) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.ts b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.ts new file mode 100644 index 000000000..da1150797 --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/ai-settings-sections.ts @@ -0,0 +1,21 @@ +/** + * AI Settings is a master-detail page. The active detail pane is driven by the + * `?section=` search param so deep links (e.g. `/settings/ai?section=claude`) + * open straight onto an adapter. `browseros` is the LLM-providers pane; any + * other value must match a currently-visible harness adapter id, otherwise we + * fall back to `browseros` (covers missing param, hidden Hermes, stale links, + * and the brief window before adapters load). + */ +export const BROWSEROS_SECTION = 'browseros' + +export type AiSettingsSection = string + +export function resolveAiSettingsSection( + raw: string | null | undefined, + visibleAdapterIds: readonly string[], +): AiSettingsSection { + if (raw && raw !== BROWSEROS_SECTION && visibleAdapterIds.includes(raw)) { + return raw + } + return BROWSEROS_SECTION +} From bbbaeb07a42d3156f46303e95d5ceac9f50d617f Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:18:29 -0700 Subject: [PATCH 03/10] feat(agent): add Claude/Codex settings panes with instance management --- .../app/agents/AgentsEmptyState.tsx | 4 +- .../app/ai-settings/AISettingsPage.tsx | 8 +- .../app/ai-settings/AdapterAgentsPane.tsx | 251 ++++++++++++++++++ 3 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AdapterAgentsPane.tsx diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsEmptyState.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsEmptyState.tsx index aeac58cde..5d079ddec 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsEmptyState.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsEmptyState.tsx @@ -16,8 +16,8 @@ export const AgentsEmptyState: FC = ({

No agents yet

- Spin up a Claude Code, Codex, or Hermes agent to chat with, schedule, or - run in the background. + Spin up a Claude Code or Codex agent to chat with, schedule, or run in + the background.

+ + + + {pageError ? ( + setPageError(null)} + /> + ) : null} + + setCreateOpen(true)} + onDeleteAgent={(agent) => { + void handleDelete(agent) + }} + onPinToggle={(agent, next) => { + if (!harnessAgentLookup.has(agent.agentId)) return + updateHarnessAgent.mutate({ + agentId: agent.agentId, + patch: { pinned: next }, + }) + }} + /> + + { + setCreateOpen(open) + if (!open) { + setCreateError(null) + createHarnessAgent.reset() + } + }} + onRuntimeChange={() => {}} + onHarnessAdapterChange={() => {}} + onHarnessModelChange={setModelId} + onHarnessReasoningChange={setReasoningEffort} + onHermesProviderChange={() => {}} + onNameChange={setNewName} + /> + + ) +} + +function AdapterHealthBadge({ + health, +}: { + health: { healthy: boolean; reason?: string } | undefined +}) { + if (!health) return null + return ( + + + {health.healthy ? 'Ready' : 'Unavailable'} + + ) +} From 5cef36786cec652974f6a88aebc0d7ae57b0fa59 Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:28:41 -0700 Subject: [PATCH 04/10] feat(agent): merge providers and agents into the home composer with BrowserOS default --- .../apps/agent/entrypoints/app/App.tsx | 1 - .../AgentCommandConversation.tsx | 18 -- .../app/agent-command/AgentCommandHome.tsx | 217 +++++++++--------- .../app/agent-command/ConversationInput.tsx | 92 ++++---- .../home-compose.helpers.test.ts | 45 ++++ .../app/agent-command/home-compose.helpers.ts | 33 +++ 6 files changed, 236 insertions(+), 170 deletions(-) create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.test.ts create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.ts diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx index 82d51a14d..1d632dd02 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx @@ -111,7 +111,6 @@ export const App: FC = () => { variant="page" backPath="/agents" agentPathPrefix="/agents" - createAgentPath="/agents" /> } /> diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandConversation.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandConversation.tsx index 1f93df515..40adaf8cc 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandConversation.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandConversation.tsx @@ -37,17 +37,12 @@ function AgentConversationController({ initialMessage, onInitialMessageConsumed, agents, - agentPathPrefix, - createAgentPath, }: { agentId: string initialMessage: string | null onInitialMessageConsumed: () => void agents: AgentEntry[] - agentPathPrefix: string - createAgentPath: string }) { - const navigate = useNavigate() const initialMessageSentRef = useRef(null) const onInitialMessageConsumedRef = useRef(onInitialMessageConsumed) const agent = agents.find((entry) => entry.agentId === agentId) @@ -158,10 +153,6 @@ function AgentConversationController({ void sendRef.current({ text: query }) }, [agentId, disabled, historyReady, initialMessage, initialMessageKey]) - const handleSelectAgent = (entry: AgentEntry) => { - navigate(`${agentPathPrefix}/${entry.agentId}`) - } - return (
{ const attachments = input.attachments.map((a) => a.payload) const attachmentPreviews = input.attachments.map((a) => ({ @@ -217,11 +205,9 @@ function AgentConversationController({ } void send({ text: input.text, attachments, attachmentPreviews }) }} - onCreateAgent={() => navigate(createAgentPath)} onStop={handleStop} streaming={streaming} disabled={disabled} - status="running" attachmentsEnabled={true} placeholder={ streaming @@ -239,7 +225,6 @@ interface AgentCommandConversationProps { variant?: 'command' | 'page' backPath?: string agentPathPrefix?: string - createAgentPath?: string } function inferAdapterFromEntry( @@ -260,7 +245,6 @@ export const AgentCommandConversation: FC = ({ variant = 'command', backPath = '/home', agentPathPrefix = '/home/agents', - createAgentPath = '/agents', }) => { const { agentId } = useParams<{ agentId: string }>() const [searchParams, setSearchParams] = useSearchParams() @@ -369,8 +353,6 @@ export const AgentCommandConversation: FC = ({ onInitialMessageConsumed={() => { setSearchParams(() => new URLSearchParams(), { replace: true }) }} - agentPathPrefix={agentPathPrefix} - createAgentPath={createAgentPath} />
diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx index 09a094c69..926b130b6 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx @@ -1,11 +1,9 @@ -import { Plus } from 'lucide-react' import { type FC, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router' +import type { Provider } from '@/components/chat/chatComponentTypes' import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import type { - AgentEntry, HarnessAdapterDescriptor, HarnessAgent, } from '@/entrypoints/app/agents/agent-harness-types' @@ -16,35 +14,22 @@ import { import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint' import { SignInHint } from '@/entrypoints/newtab/index/SignInHint' import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint' +import { + buildSidepanelChatTargets, + resolveSidepanelChatTarget, +} from '@/entrypoints/sidepanel/index/sidepanel-chat-targets' +import { toProviderOption } from '@/entrypoints/sidepanel/index/useChatSessionRequest' +import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' import { AgentCardDock } from './AgentCardDock' -import { useAgentCommandData } from './agent-command-layout' import { ConversationInput, type ConversationInputSendInput, } from './ConversationInput' import { orderHomeAgents } from './home-agent-card.helpers' +import { routeHomeSend } from './home-compose.helpers' import { setPendingInitialMessage } from './pending-initial-message' -function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) { - return ( - - -
- -
-
-

No agents yet

-

- Create an agent to start using BrowserOS as an agent-first new tab. -

-
- -
-
- ) -} +const MANAGE_AGENTS_PATH = '/settings/ai?section=claude' function RecentThreads({ activeAgentId, @@ -93,118 +78,126 @@ function RecentThreads({ export const AgentCommandHome: FC = () => { const navigate = useNavigate() const activeHint = useActiveHint() - // The conversation input consumes the compact AgentEntry list from - // the layout context. The Recent Agents grid below reads the richer - // harness payload directly. - const { agents: legacyAgents } = useAgentCommandData() + const { + providers: llmProviders, + defaultProviderId, + setDefaultProvider, + } = useLlmProviders() const { harnessAgents } = useHarnessAgents() const { adapters } = useAgentAdapters() - const [selectedAgentId, setSelectedAgentId] = useState(null) + const [selectedProvider, setSelectedProvider] = useState( + null, + ) + + const targets = useMemo( + () => + buildSidepanelChatTargets({ + providers: llmProviders, + adapters, + agents: harnessAgents, + }), + [llmProviders, adapters, harnessAgents], + ) + const providerOptions = useMemo( + () => targets.map(toProviderOption), + [targets], + ) + + // Default the picker to the user's default LLM provider (BrowserOS out of the + // box) so the composer works with zero agents. Re-resolve if the current + // selection disappears (e.g. its provider/agent was removed). + useEffect(() => { + if (targets.length === 0) return + const stillValid = + selectedProvider && + providerOptions.some( + (option) => + option.id === selectedProvider.id && + option.kind === selectedProvider.kind, + ) + if (stillValid) return + const fallback = resolveSidepanelChatTarget({ targets, defaultProviderId }) + setSelectedProvider(fallback ? toProviderOption(fallback) : null) + }, [targets, providerOptions, selectedProvider, defaultProviderId]) const orderedAgents = useMemo( () => orderHomeAgents(harnessAgents), [harnessAgents], ) - useEffect(() => { - if (legacyAgents.length === 0) { - if (selectedAgentId) setSelectedAgentId(null) + const handleSend = (input: ConversationInputSendInput) => { + if (!selectedProvider) return + const route = routeHomeSend(selectedProvider, input.text) + if (!route) return + if (route.kind === 'acp') { + // Stash text + attachments in the in-memory registry. Text also travels + // in `?q=` so a hard refresh / shareable URL still works for text-only + // prompts; attachments are registry-only (a multi-MB dataUrl can't ride + // a URL param). The chat screen prefers the registry when both exist. + setPendingInitialMessage({ + agentId: route.agentId, + text: input.text, + attachments: input.attachments, + createdAt: Date.now(), + }) + navigate(route.path) return } - if ( - !selectedAgentId || - !legacyAgents.some((agent) => agent.agentId === selectedAgentId) - ) { - setSelectedAgentId(legacyAgents[0].agentId) - } - }, [legacyAgents, selectedAgentId]) - - const handleSend = (input: ConversationInputSendInput) => { - if (!selectedAgentId) return - // Stash text + attachments in the in-memory registry. Text also - // travels in `?q=` so a hard refresh / shareable URL still works - // for text-only prompts; attachments are registry-only because a - // multi-megabyte dataUrl can't ride a URL search param. The chat - // screen prefers the registry when both are present. - setPendingInitialMessage({ - agentId: selectedAgentId, - text: input.text, - attachments: input.attachments, - createdAt: Date.now(), - }) - navigate( - `/home/agents/${selectedAgentId}?q=${encodeURIComponent(input.text)}`, - ) + // LLM target: mirror the sidepanel — selecting a provider makes it the + // default, which the in-tab chat at /home/chat restores on mount. + void setDefaultProvider(route.providerId) + navigate(route.path) } - const handleSelectAgent = (agent: AgentEntry) => { - setSelectedAgentId(agent.agentId) - } - - const selectedAgent = legacyAgents.find( - (agent) => agent.agentId === selectedAgentId, - ) - const selectedAgentReady = Boolean(selectedAgent) - const selectedAgentStatus = selectedAgent ? 'running' : undefined - const selectedAgentName = - selectedAgent?.name ?? orderedAgents[0]?.name ?? 'your agent' - - const hasAgents = legacyAgents.length > 0 - return (
- {hasAgents ? ( - <> -
-
-

- What should your agent{' '} - - work on - {' '} - next? -

-

- Start a task, continue a thread, or hand off to a different - agent — all without leaving this tab. -

-
+
+
+

+ What should your agent{' '} + + work on + {' '} + next? +

+

+ Pick BrowserOS AI or any agent, then start a task — all without + leaving this tab. +

+
-
- navigate('/agents')} - streaming={false} - disabled={!selectedAgentReady} - status={selectedAgentStatus} - attachmentsEnabled={true} - placeholder={ - selectedAgentReady - ? `Ask ${selectedAgentName} to handle a task...` - : 'Agent runtime is not running...' - } - /> -
-
+
+ +
+
+ {orderedAgents.length > 0 ? ( + <> - navigate('/agents')} + onOpenAgents={() => navigate(MANAGE_AGENTS_PATH)} onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)} /> - ) : ( - navigate('/agents')} /> - )} + ) : null}
{activeHint === 'signin' ? : null} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx index 27bececc6..fde26a01e 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx @@ -20,22 +20,24 @@ import { useRef, useState, } from 'react' +import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector' +import type { Provider } from '@/components/chat/chatComponentTypes' import { AppSelector } from '@/components/elements/AppSelector' import { TabPickerPopover } from '@/components/elements/tab-picker-popover' import { WorkspaceSelector } from '@/components/elements/workspace-selector' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' -import type { AgentEntry } from '@/entrypoints/app/agents/agent-harness-types' import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon' import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations' import { type StagedAttachment, stageAttachments } from '@/lib/attachments' import { Feature } from '@/lib/browseros/capabilities' import { useCapabilities } from '@/lib/browseros/useCapabilities' +import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons' +import type { ProviderType } from '@/lib/llm-providers/types' import { useMcpServers } from '@/lib/mcp/mcpServerStorage' import { cn } from '@/lib/utils' import { useVoiceInput } from '@/lib/voice/useVoiceInput' import { useWorkspace } from '@/lib/workspace/use-workspace' -import { AgentSelector } from './AgentSelector' export interface ConversationInputSendInput { text: string @@ -43,11 +45,14 @@ export interface ConversationInputSendInput { } interface ConversationInputProps { - agents: AgentEntry[] - selectedAgentId: string | null - onSelectAgent: (agent: AgentEntry) => void onSend: (input: ConversationInputSendInput) => void - onCreateAgent?: () => void + /** + * Merged provider/agent picker shown only on the `home` variant. Lets the + * composer target either an LLM provider (BrowserOS, etc.) or a named agent. + */ + providers?: Provider[] + selectedProvider?: Provider | null + onSelectProvider?: (provider: Provider) => void streaming: boolean disabled?: boolean status?: string @@ -174,26 +179,22 @@ function VoiceButton({ * conversation). */ function CalmContextControls({ - agents, - onCreateAgent, - onSelectAgent, - selectedAgentId, + providers, + selectedProvider, + onSelectProvider, selectedTabs, onToggleTab, showAgentSelector, - status, onAttachClick, attachDisabled, attachmentsEnabled, }: { - agents: AgentEntry[] - onCreateAgent?: () => void - onSelectAgent: (agent: AgentEntry) => void - selectedAgentId: string | null + providers?: Provider[] + selectedProvider?: Provider | null + onSelectProvider?: (provider: Provider) => void selectedTabs: chrome.tabs.Tab[] onToggleTab: (tab: chrome.tabs.Tab) => void showAgentSelector: boolean - status?: string onAttachClick: () => void attachDisabled: boolean attachmentsEnabled: boolean @@ -215,16 +216,30 @@ function CalmContextControls({ return (
- {showAgentSelector ? ( + {showAgentSelector && + providers && + selectedProvider && + onSelectProvider ? ( <> - + + +
) : null} = 10 || isStaging || !!disabled} attachmentsEnabled={attachmentsEnabled} @@ -718,3 +726,9 @@ function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
) } + +function TargetPillIcon({ provider }: { provider: Provider }) { + if (provider.kind === 'acp') return + if (provider.type === 'browseros') return + return +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.test.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.test.ts new file mode 100644 index 000000000..d93e65292 --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test' +import type { Provider } from '@/components/chat/chatComponentTypes' +import { routeHomeSend } from './home-compose.helpers' + +const llm: Provider = { + id: 'browseros', + name: 'BrowserOS', + type: 'browseros', + kind: 'llm', +} +const acp: Provider = { + id: 'agent-1', + name: 'Review bot', + type: 'acp', + kind: 'acp', + agentId: 'agent-1', +} + +describe('routeHomeSend', () => { + it('routes an LLM provider to the in-tab provider chat', () => { + expect(routeHomeSend(llm, 'hello')).toEqual({ + kind: 'llm', + providerId: 'browseros', + path: '/home/chat?q=hello', + }) + }) + + it('routes a named agent to its harness conversation', () => { + expect(routeHomeSend(acp, 'do a thing')).toEqual({ + kind: 'acp', + agentId: 'agent-1', + path: '/home/agents/agent-1?q=do%20a%20thing', + }) + }) + + it('encodes special characters in the query', () => { + expect(routeHomeSend(llm, 'a & b?')?.path).toBe( + '/home/chat?q=a%20%26%20b%3F', + ) + }) + + it('returns null for an empty prompt', () => { + expect(routeHomeSend(llm, ' ')).toBeNull() + }) +}) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.ts new file mode 100644 index 000000000..2db480b58 --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/home-compose.helpers.ts @@ -0,0 +1,33 @@ +import type { Provider } from '@/components/chat/chatComponentTypes' + +export type HomeSendRoute = + | { kind: 'llm'; providerId: string; path: string } + | { kind: 'acp'; agentId: string; path: string } + +/** + * Decide where a home-composer submission goes from the selected target. + * LLM providers run in the in-tab provider chat (`/home/chat`); named agents + * run in their harness conversation (`/home/agents/:id`). Returns null for an + * empty prompt. Side effects (setDefaultProvider, setPendingInitialMessage) + * are the caller's job — this stays a pure routing decision so it's testable. + */ +export function routeHomeSend( + provider: Provider, + text: string, +): HomeSendRoute | null { + const query = text.trim() + if (!query) return null + const encoded = encodeURIComponent(query) + if (provider.kind === 'acp' && provider.agentId) { + return { + kind: 'acp', + agentId: provider.agentId, + path: `/home/agents/${provider.agentId}?q=${encoded}`, + } + } + return { + kind: 'llm', + providerId: provider.id, + path: `/home/chat?q=${encoded}`, + } +} From 620e4d688f52a9992c6a6aa7138da424b663a1dc Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:32:44 -0700 Subject: [PATCH 05/10] refactor(agent): remove standalone Agents page in favor of home + settings --- .../components/sidebar/SidebarNavigation.tsx | 12 +- .../apps/agent/entrypoints/app/App.tsx | 30 +-- .../app/agent-command/AgentSelector.tsx | 158 ------------- .../app/agent-command/ConversationInput.tsx | 10 +- .../entrypoints/app/agents/AgentsHeader.tsx | 35 --- .../entrypoints/app/agents/AgentsPage.tsx | 218 ------------------ .../app/agents/agents-page-actions.ts | 122 ---------- .../app/agents/agents-page-hooks.ts | 96 +------- .../app/agents/agents-page-types.ts | 3 - .../app/agents/agents-page-utils.ts | 20 -- .../app/agents/hermes-supported-providers.ts | 30 --- 11 files changed, 19 insertions(+), 715 deletions(-) delete mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentSelector.tsx delete mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsHeader.tsx delete mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx delete mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts delete mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-supported-providers.ts diff --git a/packages/browseros-agent/apps/agent/components/sidebar/SidebarNavigation.tsx b/packages/browseros-agent/apps/agent/components/sidebar/SidebarNavigation.tsx index a10bc3bd9..605d88743 100644 --- a/packages/browseros-agent/apps/agent/components/sidebar/SidebarNavigation.tsx +++ b/packages/browseros-agent/apps/agent/components/sidebar/SidebarNavigation.tsx @@ -1,4 +1,4 @@ -import { CalendarClock, Cpu, Home, PlugZap, Settings } from 'lucide-react' +import { CalendarClock, Home, PlugZap, Settings } from 'lucide-react' import type { FC } from 'react' import { NavLink, useLocation } from 'react-router' import { @@ -31,12 +31,6 @@ const primaryNavItems: NavItem[] = [ feature: Feature.MANAGED_MCP_SUPPORT, }, { name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock }, - { - name: 'Agents', - to: '/agents', - icon: Cpu, - feature: Feature.ALPHA_FEATURES_SUPPORT, - }, { name: 'Settings', to: '/settings/ai', @@ -49,10 +43,6 @@ function isNavItemActive(item: NavItem, pathname: string): boolean { return pathname.startsWith('/settings') } - if (item.to === '/agents') { - return pathname === '/agents' || pathname.startsWith('/agents/') - } - return pathname === item.to } diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx index 1d632dd02..734bed58b 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx @@ -13,7 +13,6 @@ import { StepsLayout } from '../onboarding/steps/StepsLayout' import { AgentCommandConversation } from './agent-command/AgentCommandConversation' import { AgentCommandHome } from './agent-command/AgentCommandHome' import { AgentCommandLayout } from './agent-command/agent-command-layout' -import { AgentsPage } from './agents/AgentsPage' import { AISettingsPage } from './ai-settings/AISettingsPage' import { ConnectMCP } from './connect-mcp/ConnectMCP' import { CustomizationPage } from './customization/CustomizationPage' @@ -38,6 +37,13 @@ function getSurveyParams(): { maxTurns?: number; experimentId?: string } { return { maxTurns, experimentId } } +// Agent management moved into AI & Agents settings; conversations live under +// /home/agents. Keep old /agents links alive. +const LegacyAgentRedirect: FC = () => { + const params = useParams() + return +} + const OptionsRedirect: FC = () => { const params = useParams() const path = params['*'] || '' @@ -100,23 +106,6 @@ export const App: FC = () => { {/* Primary nav routes */} } /> } /> - {alphaEnabled ? ( - <> - } /> - }> - - } - /> - - - ) : null} {/* Settings with dedicated sidebar */} @@ -166,6 +155,11 @@ export const App: FC = () => { element={} /> } /> + } + /> + } /> } /> {/* Fallback to home */} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentSelector.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentSelector.tsx deleted file mode 100644 index a52415c5f..000000000 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentSelector.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { Bot, Check, ChevronDown, Plus } from 'lucide-react' -import type { FC } from 'react' -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { - type AgentEntry, - getModelDisplayName, -} from '@/entrypoints/app/agents/agent-harness-types' -import { cn } from '@/lib/utils' - -interface AgentSelectorProps { - agents: AgentEntry[] - selectedAgentId: string | null - onSelectAgent: (agent: AgentEntry) => void - onCreateAgent?: () => void - status?: string - /** - * `'pill'` renders the filled-pill variant used by the calm - * composer on `/home` — bordered, slightly elevated background, - * mono agent name, used as the visual anchor on the left of the - * footer chip row. Default `'ghost'` keeps the existing flat - * shadcn ghost-button trigger used by the chat surface. - */ - triggerVariant?: 'ghost' | 'pill' -} - -function getStatusDot(status?: string) { - if (status === 'running') return 'bg-emerald-500' - if (status === 'starting') return 'bg-amber-500 animate-pulse' - if (status === 'error') return 'bg-destructive' - return 'bg-muted-foreground/50' -} - -export const AgentSelector: FC = ({ - agents, - selectedAgentId, - onSelectAgent, - onCreateAgent, - status, - triggerVariant = 'ghost', -}) => { - const [open, setOpen] = useState(false) - const selectedAgent = agents.find( - (agent) => agent.agentId === selectedAgentId, - ) - - const triggerNode = - triggerVariant === 'pill' ? ( - - ) : ( - - ) - - return ( - - {triggerNode} - - - - - No agents found - - {agents.map((agent) => { - const isSelected = selectedAgentId === agent.agentId - const modelLabel = getModelDisplayName(agent.model) - return ( - { - onSelectAgent(agent) - setOpen(false) - }} - className={cn( - 'flex w-full items-center gap-3 rounded-md px-3 py-2', - isSelected && 'bg-[var(--accent-orange)]/10', - )} - > -
- -
-
- - {agent.name} - - {modelLabel ? ( - - {modelLabel} - - ) : null} -
- {isSelected ? ( - - ) : null} -
- ) - })} -
- {onCreateAgent ? ( -
- -
- ) : null} -
-
-
-
- ) -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx index fde26a01e..a9c07b02d 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationInput.tsx @@ -171,11 +171,11 @@ function VoiceButton({ /** * Calm-composer footer shared by both `/home` (`variant="home"`) and - * the chat surface at `/agents/:agentId` (`variant="conversation"`). - * Pill-shaped chips on an internal dashed divider, with a right- - * aligned keyboard hint. The agent selector is conditional via - * `showAgentSelector`: home shows it as a filled pill on the left, - * the chat surface hides it (the agent is locked once you're in the + * the chat surface at `/home/agents/:agentId` (`variant="conversation"`). + * Pill-shaped chips on an internal dashed divider, with a right-aligned + * keyboard hint. The merged provider/agent picker is conditional via + * `showAgentSelector`: home shows it as a filled pill on the left; the + * chat surface hides it (the target is locked once you're in the * conversation). */ function CalmContextControls({ diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsHeader.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsHeader.tsx deleted file mode 100644 index 441f64a5e..000000000 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsHeader.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Bot, Plus } from 'lucide-react' -import type { FC } from 'react' -import { Button } from '@/components/ui/button' - -interface AgentsHeaderProps { - onCreateAgent: () => void -} - -/** Header for the agents page. */ -export const AgentsHeader: FC = ({ onCreateAgent }) => { - return ( -
-
-
- -
-
-

Agents

-

- Claude Code, Codex, and Hermes agents — chat, schedule, and run them - in the background. -

-
- -
-
- ) -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx deleted file mode 100644 index 4230ab8b7..000000000 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { Loader2 } from 'lucide-react' -import { type FC, useMemo, useState } from 'react' -import { useNavigate } from 'react-router' -import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' -import { AgentList } from './AgentList' -import { AgentsHeader } from './AgentsHeader' -import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types' -import { createAgentPageActions } from './agents-page-actions' -import { - useDefaultAgentName, - useHarnessAgentDefaults, - useHermesProviderSelection, -} from './agents-page-hooks' -import { - type CreateAgentRuntime, - DEFAULT_CREATE_RUNTIME, - DEFAULT_HARNESS_ADAPTER, -} from './agents-page-types' -import { - getAgentsLoading, - getInlineError, - toHarnessListItem, -} from './agents-page-utils' -import { NewAgentDialog } from './NewAgentDialog' -import { InlineErrorAlert } from './PageAlerts' -import { - useAgentAdapters, - useCreateHarnessAgent, - useDeleteHarnessAgent, - useHarnessAgents, - useUpdateHarnessAgent, -} from './useAgents' - -export const AgentsPage: FC = () => { - const navigate = useNavigate() - const { providers, defaultProviderId } = useLlmProviders() - const { - adapters, - loading: adaptersLoading, - error: adaptersError, - } = useAgentAdapters() - const { - harnessAgents, - loading: harnessAgentsLoading, - error: harnessAgentsError, - } = useHarnessAgents() - const createHarnessAgent = useCreateHarnessAgent() - const deleteHarnessAgent = useDeleteHarnessAgent() - const updateHarnessAgent = useUpdateHarnessAgent() - - const [createOpen, setCreateOpen] = useState(false) - const [newName, setNewName] = useState('') - const [createRuntime, setCreateRuntime] = useState( - DEFAULT_CREATE_RUNTIME, - ) - const [harnessAdapterId, setHarnessAdapterId] = useState( - DEFAULT_HARNESS_ADAPTER, - ) - const [harnessModelId, setHarnessModelId] = useState('') - const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('') - const [createHermesProviderId, setCreateHermesProviderId] = useState('') - const [pageError, setPageError] = useState(null) - const [createError, setCreateError] = useState(null) - const [deletingAgentKey, setDeletingAgentKey] = useState(null) - - const { selectableHermesProviders } = useHermesProviderSelection({ - providers, - defaultProviderId, - createOpen, - createRuntime, - createHermesProviderId, - setCreateHermesProviderId, - }) - useDefaultAgentName(createOpen, setNewName) - useHarnessAgentDefaults({ - adapters, - createOpen, - harnessAdapterId, - setHarnessAdapterId, - setHarnessModelId, - setHarnessReasoningEffort, - }) - - const agentListItems = useMemo( - () => harnessAgents.map(toHarnessListItem), - [harnessAgents], - ) - const harnessAgentLookup = useMemo(() => { - const map = new Map() - for (const agent of harnessAgents) map.set(agent.id, agent) - return map - }, [harnessAgents]) - const agentActivity = useMemo(() => { - const map: Record< - string, - { - status: 'working' | 'idle' | 'asleep' | 'error' - lastUsedAt: number | null - } - > = {} - for (const agent of harnessAgents) { - if (!agent.status) continue - map[agent.id] = { - status: agent.status, - lastUsedAt: agent.lastUsedAt ?? null, - } - } - return map - }, [harnessAgents]) - const inlineError = getInlineError({ - pageError, - adaptersError, - harnessAgentsError, - }) - const agentsLoading = getAgentsLoading({ - adaptersLoading, - harnessAgentsLoading, - }) - const creatingAgent = createHarnessAgent.isPending - const deletingAgent = deleteHarnessAgent.isPending - - const handleHarnessAdapterChange = (adapter: HarnessAgentAdapter) => { - const descriptor = adapters.find((entry) => entry.id === adapter) - setHarnessAdapterId(adapter) - setHarnessModelId(descriptor?.defaultModelId ?? '') - setHarnessReasoningEffort(descriptor?.defaultReasoningEffort ?? '') - } - - const { handleCreate, handleDelete } = createAgentPageActions({ - createRuntime, - createHermesProviderId, - harnessModelId, - harnessReasoningEffort, - navigate, - newName, - selectableHermesProviders, - createHarnessAgent: createHarnessAgent.mutateAsync, - deleteHarnessAgent: deleteHarnessAgent.mutateAsync, - setCreateError, - setCreateOpen, - setDeletingAgentKey, - setNewName, - setPageError, - }) - - if (harnessAgentsLoading) { - return ( -
- -
- ) - } - - return ( -
-
- setCreateOpen(true)} /> - - {inlineError ? ( - setPageError(null)} - /> - ) : null} - - setCreateOpen(true)} - onDeleteAgent={(agent) => { - void handleDelete(agent) - }} - onPinToggle={(agent, next) => { - if (!harnessAgentLookup.has(agent.agentId)) return - updateHarnessAgent.mutate({ - agentId: agent.agentId, - patch: { pinned: next }, - }) - }} - /> - - { - setCreateOpen(open) - if (!open) { - setCreateError(null) - createHarnessAgent.reset() - setCreateHermesProviderId('') - } - }} - onRuntimeChange={setCreateRuntime} - onHarnessAdapterChange={handleHarnessAdapterChange} - onHarnessModelChange={setHarnessModelId} - onHarnessReasoningChange={setHarnessReasoningEffort} - onHermesProviderChange={setCreateHermesProviderId} - onNameChange={setNewName} - /> -
-
- ) -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts deleted file mode 100644 index f1ab92a92..000000000 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { NavigateFunction } from 'react-router' -import { - AGENT_CREATED_EVENT, - AGENT_DELETED_EVENT, -} from '@/lib/constants/analyticsEvents' -import { track } from '@/lib/metrics/track' -import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types' -import type { - AgentListItem, - CreateAgentRuntime, - ProviderOption, -} from './agents-page-types' - -export interface AgentPageActionInput { - createRuntime: CreateAgentRuntime - createHermesProviderId: string - harnessModelId: string - harnessReasoningEffort: string - navigate: NavigateFunction - newName: string - selectableHermesProviders: ProviderOption[] - createHarnessAgent: (input: { - name: string - adapter: HarnessAgentAdapter - modelId?: string - reasoningEffort?: string - providerType?: string - apiKey?: string - baseUrl?: string - }) => Promise - deleteHarnessAgent: (agentId: string) => Promise - setCreateError: (error: string | null) => void - setCreateOpen: (open: boolean) => void - setDeletingAgentKey: (key: string | null) => void - setNewName: (name: string) => void - setPageError: (error: string | null) => void -} - -export function createAgentPageActions(input: AgentPageActionInput) { - const runWithPageErrorHandling = async (fn: () => Promise) => { - input.setPageError(null) - try { - await fn() - } catch (err) { - input.setPageError(err instanceof Error ? err.message : String(err)) - } - } - - const handleHarnessCreate = async () => { - if (!input.newName.trim()) return - - const isHermes = input.createRuntime === 'hermes' - // Hermes pulls every provider field from the user's selected entry - // in the global LLM-providers list (managed under AI Settings). The - // backend rejects creation if any required field is missing. - const hermesProvider = isHermes - ? input.selectableHermesProviders.find( - (option) => option.id === input.createHermesProviderId, - ) - : undefined - const effectiveModelId = isHermes - ? hermesProvider?.modelId - : input.harnessModelId || undefined - - input.setCreateError(null) - try { - const agent = await input.createHarnessAgent({ - name: input.newName.trim(), - adapter: input.createRuntime as HarnessAgentAdapter, - modelId: effectiveModelId, - reasoningEffort: input.harnessReasoningEffort || undefined, - providerType: hermesProvider?.type, - apiKey: hermesProvider?.apiKey, - baseUrl: hermesProvider?.baseUrl, - }) - input.setCreateOpen(false) - input.setNewName('') - track(AGENT_CREATED_EVENT, { - runtime: input.createRuntime, - model_id: effectiveModelId, - reasoning_effort: input.harnessReasoningEffort || undefined, - provider_type: hermesProvider?.type, - }) - input.navigate(`/agents/${agent.id}`) - } catch (err) { - input.setCreateError(err instanceof Error ? err.message : String(err)) - } - } - - const handleCreate = () => { - const createByRuntime: Record Promise> = { - claude: handleHarnessCreate, - codex: handleHarnessCreate, - hermes: handleHarnessCreate, - } - void createByRuntime[input.createRuntime]() - } - - const handleDelete = async (agent: AgentListItem) => { - input.setDeletingAgentKey(agent.key) - await runWithPageErrorHandling(async () => { - const deleteBySource: Record< - AgentListItem['source'], - (agentId: string) => Promise - > = { - 'agent-harness': (agentId) => input.deleteHarnessAgent(agentId), - } - await deleteBySource[agent.source](agent.agentId) - track(AGENT_DELETED_EVENT, { - runtime: agent.source, - agent_id: agent.agentId, - }) - }) - input.setDeletingAgentKey(null) - } - - return { - handleCreate, - handleDelete, - runWithPageErrorHandling, - } -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-hooks.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-hooks.ts index b52fa035c..6819c6767 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-hooks.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-hooks.ts @@ -1,11 +1,4 @@ -import { type Dispatch, type SetStateAction, useEffect, useMemo } from 'react' -import type { LlmProviderConfig } from '@/lib/llm-providers/types' -import type { - HarnessAdapterDescriptor, - HarnessAgentAdapter, -} from './agent-harness-types' -import type { CreateAgentRuntime, ProviderOption } from './agents-page-types' -import { getHermesSupportedProviders } from './hermes-supported-providers' +import { type Dispatch, type SetStateAction, useEffect } from 'react' export function useDefaultAgentName( createOpen: boolean, @@ -16,90 +9,3 @@ export function useDefaultAgentName( setNewName((current) => current || 'agent') }, [createOpen, setNewName]) } - -export function useHarnessAgentDefaults(input: { - adapters: HarnessAdapterDescriptor[] - createOpen: boolean - harnessAdapterId: HarnessAgentAdapter - setHarnessAdapterId: Dispatch> - setHarnessModelId: Dispatch> - setHarnessReasoningEffort: Dispatch> -}): void { - const { - adapters, - createOpen, - harnessAdapterId, - setHarnessAdapterId, - setHarnessModelId, - setHarnessReasoningEffort, - } = input - - useEffect(() => { - if (!createOpen) return - const adapter = - adapters.find((entry) => entry.id === harnessAdapterId) ?? adapters[0] - if (!adapter) return - setHarnessAdapterId(adapter.id) - setHarnessModelId((current) => current || adapter.defaultModelId) - setHarnessReasoningEffort( - (current) => current || adapter.defaultReasoningEffort, - ) - }, [ - adapters, - createOpen, - harnessAdapterId, - setHarnessAdapterId, - setHarnessModelId, - setHarnessReasoningEffort, - ]) -} - -export function useHermesProviderSelection(input: { - providers: LlmProviderConfig[] - defaultProviderId: string - createOpen: boolean - createRuntime: CreateAgentRuntime - createHermesProviderId: string - setCreateHermesProviderId: Dispatch> -}) { - const { - providers, - defaultProviderId, - createOpen, - createRuntime, - createHermesProviderId, - setCreateHermesProviderId, - } = input - - const selectableHermesProviders = useMemo( - () => - getHermesSupportedProviders(providers).map((provider) => ({ - id: provider.id, - type: provider.type, - name: provider.name, - modelId: provider.modelId, - baseUrl: provider.baseUrl, - apiKey: provider.apiKey, - })), - [providers], - ) - - useEffect(() => { - if (selectableHermesProviders.length === 0) return - if (!createOpen || createRuntime !== 'hermes') return - if (createHermesProviderId) return - const fallbackId = - selectableHermesProviders.find((p) => p.id === defaultProviderId)?.id ?? - selectableHermesProviders[0].id - setCreateHermesProviderId(fallbackId) - }, [ - createHermesProviderId, - createOpen, - createRuntime, - defaultProviderId, - selectableHermesProviders, - setCreateHermesProviderId, - ]) - - return { selectableHermesProviders } -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts index ea9adeda4..015073ab0 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts @@ -22,6 +22,3 @@ export interface AgentListItem { canChat: boolean canDelete: boolean } - -export const DEFAULT_HARNESS_ADAPTER: HarnessAgentAdapter = 'claude' -export const DEFAULT_CREATE_RUNTIME: CreateAgentRuntime = 'claude' diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts index 9f2eb6f1d..09ee33ad6 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts @@ -20,23 +20,3 @@ export function toHarnessListItem(agent: HarnessAgent): AgentListItem { canDelete: true, } } - -export function getAgentsLoading(input: { - adaptersLoading: boolean - harnessAgentsLoading: boolean -}): boolean { - return input.adaptersLoading || input.harnessAgentsLoading -} - -export function getInlineError(input: { - pageError: string | null - adaptersError: Error | null - harnessAgentsError: Error | null -}): string | null { - return ( - input.pageError ?? - input.adaptersError?.message ?? - input.harnessAgentsError?.message ?? - null - ) -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-supported-providers.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-supported-providers.ts deleted file mode 100644 index ab1c57e1c..000000000 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-supported-providers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES, - type HermesSupportedBrowserosProviderType, -} from '@browseros/shared/constants/hermes' -import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types' - -export function isHermesSupportedProviderType( - providerType: ProviderType, -): providerType is HermesSupportedBrowserosProviderType { - return ( - HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly ProviderType[] - ).includes(providerType) -} - -/** - * Filters the user's global LLM providers down to ones Hermes can use. - * A provider qualifies when its type is in the Hermes-supported set - * AND it has an API key wired up. CLI-style providers (chatgpt-pro, - * github-copilot, qwen-code) and other unsupported types (browseros, - * ollama, lmstudio, bedrock, azure, google, moonshot) are filtered - * out — Hermes can't drive them today. - */ -export function getHermesSupportedProviders( - providers: LlmProviderConfig[], -): LlmProviderConfig[] { - return providers.filter( - (provider) => - !!provider.apiKey && isHermesSupportedProviderType(provider.type), - ) -} From c86f237ed6c7a32e632207ec2c70a6ce304bf350 Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:36:54 -0700 Subject: [PATCH 06/10] =?UTF-8?q?chore:=20self-review=20fixes=20=E2=80=94?= =?UTF-8?q?=20persist=20chat-target=20selection=20on=20home=20LLM=20pick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/agent-command/AgentCommandHome.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx index 926b130b6..1794f4a72 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx @@ -16,6 +16,7 @@ import { SignInHint } from '@/entrypoints/newtab/index/SignInHint' import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint' import { buildSidepanelChatTargets, + persistSidepanelChatTargetSelection, resolveSidepanelChatTarget, } from '@/entrypoints/sidepanel/index/sidepanel-chat-targets' import { toProviderOption } from '@/entrypoints/sidepanel/index/useChatSessionRequest' @@ -125,7 +126,7 @@ export const AgentCommandHome: FC = () => { [harnessAgents], ) - const handleSend = (input: ConversationInputSendInput) => { + const handleSend = async (input: ConversationInputSendInput) => { if (!selectedProvider) return const route = routeHomeSend(selectedProvider, input.text) if (!route) return @@ -143,9 +144,15 @@ export const AgentCommandHome: FC = () => { navigate(route.path) return } - // LLM target: mirror the sidepanel — selecting a provider makes it the - // default, which the in-tab chat at /home/chat restores on mount. - void setDefaultProvider(route.providerId) + // LLM target → /home/chat. That chat resolves its provider from the shared + // chat-target selection (preferred over the global default), so persist + // this pick there before navigating; also set it as the default to mirror + // the sidepanel's behaviour. + const target = targets.find( + (entry) => entry.kind === 'llm' && entry.id === route.providerId, + ) + await persistSidepanelChatTargetSelection(target) + await setDefaultProvider(route.providerId) navigate(route.path) } From 525a11391a02b956d09f4a9eb0d6cf97b74fc601 Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:47:18 -0700 Subject: [PATCH 07/10] fix(agent): use horizontal tabs in AI & Agents settings (drop second sidebar) --- .../app/ai-settings/AISettingsPage.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx index 53065684d..683194bf1 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx @@ -21,10 +21,9 @@ interface SectionItem { } /** - * AI & Agents settings shell. A `?section=`-driven master-detail: BrowserOS AI - * (the LLM-providers pane) plus one entry per visible harness adapter - * (Claude/Codex; Hermes filtered out). The detail pane swaps on the active - * section. + * AI & Agents settings shell. A `?section=`-driven tabbed page: a BrowserOS AI + * tab (the LLM-providers pane) plus one tab per visible harness adapter + * (Claude/Codex; Hermes filtered out). The pane below swaps on the active tab. */ export const AISettingsPage: FC = () => { const [searchParams, setSearchParams] = useSearchParams() @@ -57,8 +56,8 @@ export const AISettingsPage: FC = () => { } return ( -
-
- {activeSection === BROWSEROS_SECTION ? ( - + {activeAdapter ? ( + // Keyed by adapter id so switching panes remounts — local create-form + // state (model / reasoning) can't leak across adapters. + ) : ( - + )}
diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AdapterAgentsPane.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AdapterAgentsPane.tsx index cb0f95e05..08dae8565 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AdapterAgentsPane.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AdapterAgentsPane.tsx @@ -129,8 +129,9 @@ export const AdapterAgentsPane: FC = ({ }) } catch (err) { setPageError(err instanceof Error ? err.message : String(err)) + } finally { + setDeletingAgentKey(null) } - setDeletingAgentKey(null) } return ( From fdcb4aa59d68e56fca37ccb24c10693d7f725a81 Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Wed, 27 May 2026 16:56:31 -0700 Subject: [PATCH 09/10] fix(agent): drop overflow-x-auto on settings tab bar (removes phantom scrollbar) --- .../apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx index bc1c3d536..d0dc2267b 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx @@ -62,7 +62,7 @@ export const AISettingsPage: FC = () => { return (
-