Skip to content

Commit 4beb95a

Browse files
authored
fix: stop browser translation from crashing the web app (#573)
1 parent 350becf commit 4beb95a

15 files changed

Lines changed: 589 additions & 12 deletions

‎frontend/apps/web/src/components/agents/AgentConfigMcpServersField.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ function McpServerRow({
347347
{registryServer ? (
348348
<p className="text-muted-foreground -mt-3 text-sm">
349349
{registryServerLabel(registryServer)}
350-
{registryServer.description ? ` — ${registryServer.description}` : ''}
350+
{registryServer.description && <span>{` — ${registryServer.description}`}</span>}
351351
</p>
352352
) : (
353353
<Skeleton
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/** @vitest-environment happy-dom */
2+
3+
import { OmnaraClientProvider } from '@omnara/react'
4+
import { type ConfiguredModelSummary, createOmnaraClient } from '@omnara/sdk'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6+
import {
7+
createMemoryHistory,
8+
createRootRoute,
9+
createRouter,
10+
RouterProvider,
11+
} from '@tanstack/react-router'
12+
import { act, createContext, type ReactNode, useContext } from 'react'
13+
import { createRoot, type Root } from 'react-dom/client'
14+
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'
15+
16+
import { AgentConfigModelField } from '@/components/agents/AgentConfigModelField'
17+
import { ActiveOrgContext } from '@/lib/active-org-context'
18+
import { type FakeApi, fakeApi, jsonResponse, neverResponds } from '@/test/fake-api'
19+
import { currentUserOrg, fakeId } from '@/test/fixtures'
20+
import { enableReactActEnvironment } from '@/test/react-act'
21+
import { translateTextNodes } from '@/test/translate-text-nodes'
22+
23+
const TestContentContext = createContext<ReactNode>(null)
24+
function TestContent() {
25+
return useContext(TestContentContext)
26+
}
27+
const testRouter = createRouter({
28+
routeTree: createRootRoute({ component: TestContent }),
29+
history: createMemoryHistory(),
30+
})
31+
32+
const timestamp = '2026-01-01T00:00:00Z'
33+
const activeOrg = currentUserOrg({ id: fakeId('org'), name: 'Test org' })
34+
const projectId = fakeId('proj')
35+
const providerId = fakeId('mpc')
36+
37+
function configuredModel(id: string, name: string): ConfiguredModelSummary {
38+
return {
39+
id,
40+
org_id: activeOrg.id,
41+
model_provider_config_id: providerId,
42+
name,
43+
provider_config: 'omnara-openrouter',
44+
provider_model_slug: name,
45+
created_at: timestamp,
46+
updated_at: timestamp,
47+
}
48+
}
49+
50+
const pricedModel = configuredModel(fakeId('mdl'), 'openai/gpt-5.6-sol')
51+
const unpricedModel = configuredModel(`mdl_${'b'.repeat(26)}`, 'x-ai/grok-5')
52+
53+
function modelGrant(model: ConfiguredModelSummary) {
54+
return {
55+
grant: {
56+
id: model.id.replace('mdl_', 'pmog_'),
57+
org_id: activeOrg.id,
58+
project_id: projectId,
59+
configured_model_id: model.id,
60+
supported_reasoning_efforts: [],
61+
input_modalities: [],
62+
output_modalities: [],
63+
created_at: timestamp,
64+
updated_at: timestamp,
65+
},
66+
model,
67+
}
68+
}
69+
70+
let container: HTMLDivElement
71+
let root: Root
72+
let api: FakeApi
73+
let Providers: ({ children }: { children: ReactNode }) => ReactNode
74+
let restoreActEnvironment: () => void
75+
76+
beforeAll(async () => {
77+
restoreActEnvironment = enableReactActEnvironment()
78+
await testRouter.load()
79+
})
80+
81+
afterAll(() => {
82+
restoreActEnvironment()
83+
})
84+
85+
beforeEach(() => {
86+
api = fakeApi([
87+
{
88+
method: 'GET',
89+
path: `/api/v1/orgs/${activeOrg.id}/projects/${projectId}/model-grants`,
90+
respond: (request) =>
91+
request.url.searchParams.has('name')
92+
? neverResponds()
93+
: jsonResponse({ data: [pricedModel, unpricedModel].map(modelGrant), next_cursor: null }),
94+
},
95+
{
96+
method: 'GET',
97+
path: `/api/v1/orgs/${activeOrg.id}/model-provider-configs`,
98+
respond: () =>
99+
jsonResponse({
100+
data: [
101+
{
102+
id: providerId,
103+
org_id: activeOrg.id,
104+
management_kind: 'cluster',
105+
name: 'omnara-openrouter',
106+
api_format: 'openai-responses',
107+
api_variant: 'openai',
108+
base_url: 'https://openrouter.example.com',
109+
endpoint_path: '/responses',
110+
request_timeout_ms: 120000,
111+
idle_timeout_ms: 300000,
112+
auth_kind: 'bearer_token',
113+
auth_options: {},
114+
credential_secret_id: fakeId('sec'),
115+
created_at: timestamp,
116+
updated_at: timestamp,
117+
},
118+
],
119+
next_cursor: null,
120+
}),
121+
},
122+
{
123+
method: 'GET',
124+
path: `/api/v1/orgs/${activeOrg.id}/model-provider-configs/${providerId}/model-catalog`,
125+
respond: () =>
126+
jsonResponse({
127+
status: 'ok',
128+
models: [
129+
{
130+
slug: pricedModel.provider_model_slug,
131+
pricing: { input_usd_per_million: '1.25', output_usd_per_million: '10' },
132+
},
133+
],
134+
}),
135+
},
136+
{
137+
method: 'GET',
138+
path: `/api/v1/orgs/${activeOrg.id}/projects`,
139+
respond: () => jsonResponse({ data: [], next_cursor: null }),
140+
},
141+
])
142+
const client = createOmnaraClient({ baseUrl: 'https://omnara.test/api/v1' })
143+
client.setConfig({ fetch: api.fetch })
144+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
145+
Providers = function Providers({ children }) {
146+
return (
147+
<OmnaraClientProvider client={client}>
148+
<QueryClientProvider client={queryClient}>
149+
<ActiveOrgContext.Provider
150+
value={{ orgs: [activeOrg], activeOrg, setActiveOrgId: () => undefined }}
151+
>
152+
<TestContentContext value={children}>
153+
<RouterProvider router={testRouter} />
154+
</TestContentContext>
155+
</ActiveOrgContext.Provider>
156+
</QueryClientProvider>
157+
</OmnaraClientProvider>
158+
)
159+
}
160+
container = document.createElement('div')
161+
document.body.append(container)
162+
root = createRoot(container)
163+
})
164+
165+
afterEach(() => {
166+
act(() => {
167+
root.unmount()
168+
})
169+
container.remove()
170+
})
171+
172+
async function renderField(model: ConfiguredModelSummary) {
173+
await act(async () => {
174+
root.render(
175+
<Providers>
176+
<AgentConfigModelField
177+
orgId={activeOrg.id}
178+
projectId={projectId}
179+
value={{ providerConfig: model.provider_config, modelName: model.name }}
180+
onChange={() => undefined}
181+
/>
182+
</Providers>,
183+
)
184+
await new Promise((resolve) => setTimeout(resolve, 0))
185+
})
186+
}
187+
188+
async function renderTranslatedPricedField() {
189+
await renderField(pricedModel)
190+
await vi.waitFor(() => {
191+
expect(container.textContent).toContain('$1.25 in · $10.00 out per 1M tokens')
192+
})
193+
translateTextNodes(container)
194+
}
195+
196+
it('keeps searching after a browser translator rewrites the selected model pricing', async () => {
197+
await renderTranslatedPricedField()
198+
199+
await act(async () => {
200+
container
201+
.querySelector('#agent-config-model')
202+
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true }))
203+
await new Promise((resolve) => setTimeout(resolve, 300))
204+
})
205+
206+
expect(api.requests.some(({ url }) => url.searchParams.get('name') === '*g*')).toBe(true)
207+
expect(container.querySelector('#agent-config-model')).not.toBeNull()
208+
})
209+
210+
it('switches to an unpriced model after a browser translator rewrites the pricing', async () => {
211+
await renderTranslatedPricedField()
212+
213+
await renderField(unpricedModel)
214+
215+
expect(container.querySelector('#agent-config-model')?.textContent).toBe(
216+
'x-ai/grok-5 · omnara-openrouter',
217+
)
218+
expect(container.textContent).toContain('— per 1M tokens')
219+
})

‎frontend/apps/web/src/components/agents/AgentConfigModelField.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,9 @@ export function AgentConfigModelField({
163163
{selected && pricingPending && !selected.pricing ? (
164164
<Skeleton className="h-3 w-36" />
165165
) : selected ? (
166-
<>
166+
<span>
167167
<ModelPricingSummary pricing={selected.pricing} /> per 1M tokens
168-
</>
168+
</span>
169169
) : null}
170170
</p>
171171
<ResourceNameFieldError value={value.providerConfig} fieldLabel="Provider config name" />

‎frontend/apps/web/src/components/agents/AgentSidebar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ function AgentMcpConnectionItem({ connection }: { connection: AgentMcpConnection
481481
<div className="text-muted-foreground break-all">{connection.endpoint_url}</div>
482482
<div className="text-muted-foreground">
483483
{stateLabel}
484-
{connection.protocol_version ? ` · MCP ${connection.protocol_version}` : ''}
484+
{connection.protocol_version && <span>{` · MCP ${connection.protocol_version}`}</span>}
485485
</div>
486486
{connection.initialize_error && (
487487
<div className="text-destructive break-words">{connection.initialize_error}</div>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/** @vitest-environment happy-dom */
2+
3+
import { OmnaraClientProvider } from '@omnara/react'
4+
import { createOmnaraClient, type CronTrigger } from '@omnara/sdk'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6+
import { act } from 'react'
7+
import { createRoot, type Root } from 'react-dom/client'
8+
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'
9+
10+
import { CronTriggersList } from '@/components/agents/CronTriggersSection'
11+
import { fakeApi, jsonResponse } from '@/test/fake-api'
12+
import { fakeId } from '@/test/fixtures'
13+
import { enableReactActEnvironment } from '@/test/react-act'
14+
import { translateTextNodes } from '@/test/translate-text-nodes'
15+
16+
const orgId = fakeId('org')
17+
const projectId = fakeId('proj')
18+
const timestamp = '2026-01-01T00:00:00Z'
19+
const triggersPath = `/api/v1/orgs/${orgId}/projects/${projectId}/cron-triggers`
20+
21+
let container: HTMLDivElement
22+
let root: Root
23+
let restoreActEnvironment: () => void
24+
25+
beforeAll(() => {
26+
restoreActEnvironment = enableReactActEnvironment()
27+
})
28+
29+
afterAll(() => {
30+
restoreActEnvironment()
31+
})
32+
33+
beforeEach(() => {
34+
container = document.createElement('div')
35+
document.body.append(container)
36+
root = createRoot(container)
37+
})
38+
39+
afterEach(() => {
40+
act(() => {
41+
root.unmount()
42+
})
43+
container.remove()
44+
})
45+
46+
it('pauses a schedule after a browser translator rewrites its next run', async () => {
47+
let trigger: CronTrigger = {
48+
id: fakeId('cron'),
49+
org_id: orgId,
50+
project_id: projectId,
51+
name: 'nightly-report',
52+
target: { type: 'profile', agent_profile_id: fakeId('aprf') },
53+
cron: '0 0 1 1 *',
54+
timezone: 'UTC',
55+
message_template: 'Write the report.',
56+
enabled: true,
57+
last_fired_at: null,
58+
next_fire_at: '2026-12-31T00:00:00Z',
59+
failure_report: null,
60+
created_at: timestamp,
61+
updated_at: timestamp,
62+
}
63+
const api = fakeApi([
64+
{
65+
method: 'GET',
66+
path: triggersPath,
67+
respond: () => jsonResponse({ data: [trigger], next_cursor: null }),
68+
},
69+
{
70+
method: 'PATCH',
71+
path: `${triggersPath}/${trigger.id}`,
72+
respond: () => {
73+
trigger = { ...trigger, enabled: false, next_fire_at: null }
74+
return jsonResponse(trigger)
75+
},
76+
},
77+
])
78+
const client = createOmnaraClient({ baseUrl: 'https://omnara.test/api/v1' })
79+
client.setConfig({ fetch: api.fetch })
80+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
81+
82+
act(() => {
83+
root.render(
84+
<OmnaraClientProvider client={client}>
85+
<QueryClientProvider client={queryClient}>
86+
<CronTriggersList
87+
orgId={orgId}
88+
projectId={projectId}
89+
canManage
90+
filters={{}}
91+
emptyMessage="No schedules"
92+
/>
93+
</QueryClientProvider>
94+
</OmnaraClientProvider>,
95+
)
96+
})
97+
await vi.waitFor(() => {
98+
expect(container.textContent).toContain('next')
99+
})
100+
translateTextNodes(container)
101+
102+
act(() => {
103+
container
104+
.querySelector<HTMLButtonElement>('[aria-label="Disable schedule nightly-report"]')
105+
?.click()
106+
})
107+
await vi.waitFor(() => {
108+
expect(container.querySelector('[aria-label="Enable schedule nightly-report"]')).not.toBeNull()
109+
})
110+
expect(container.textContent).not.toContain('next')
111+
})

‎frontend/apps/web/src/components/agents/CronTriggersSection.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ export function CronTriggersList({
9090
</div>
9191
<p className="text-muted-foreground truncate text-xs">
9292
<span className="font-mono">{trigger.cron}</span> · {trigger.timezone}
93-
{trigger.target.type === 'agent' &&
94-
` · ${cronTriggerDeliveryModeLabel(trigger.target.delivery_mode ?? 'queued')}`}
95-
{trigger.next_fire_at && nextFireLabel(trigger.next_fire_at)}
93+
{trigger.target.type === 'agent' && (
94+
<span>{` · ${cronTriggerDeliveryModeLabel(trigger.target.delivery_mode ?? 'queued')}`}</span>
95+
)}
96+
{trigger.next_fire_at && <span>{nextFireLabel(trigger.next_fire_at)}</span>}
9697
</p>
9798
{trigger.failure_report && (
9899
<p className="text-destructive break-words text-xs">

‎frontend/apps/web/src/components/models/ModelPricing.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ export function ModelPricingSummary({
99
pricing: DiscoveredModelPricing | undefined
1010
className?: string
1111
}) {
12-
if (!pricing) return <span className={className}>—</span>
1312
return (
1413
<span className={className}>
15-
{formatUsdPerMillion(pricing.input_usd_per_million)} in ·{' '}
16-
{formatUsdPerMillion(pricing.output_usd_per_million)} out
14+
{pricing
15+
? `${formatUsdPerMillion(pricing.input_usd_per_million)} in · ${formatUsdPerMillion(pricing.output_usd_per_million)} out`
16+
: '—'}
1717
</span>
1818
)
1919
}

0 commit comments

Comments
 (0)