Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Keep the entry bundle small by lazy-loading routes that aren't on the critical l

**Lazy (`React.lazy(() => import(...))`):**

- All settings/admin pages (`PreferencesSettingsPage`, `ModelsPage`, `DevicesSettingsPage`, `McpServersPage`, `IntegrationsPage`, dev-only routes).
- All settings/admin pages (`PreferencesSettingsPage`, `ModelsPage`, `DevicesSettingsPage`, `ConnectionsPage`, dev-only routes).
- Secondary features (`TasksPage`, `AutomationsPage`).
- `WaitlistPage` and SSO flows (only hit by a subset of users).

Expand Down
12 changes: 12 additions & 0 deletions backend/src/inference/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ import { Elysia } from 'elysia'
import type OpenAI from 'openai'
import * as inferenceClient from './client'
import { createInferenceRoutes, supportedModels } from './routes'
import { defaultModels } from '@shared/defaults/models'

describe('Thunderbolt model catalog parity', () => {
it('routes every Thunderbolt model shipped in frontend defaults', () => {
const shippedModelIds = defaultModels
.filter((model) => model.provider === 'thunderbolt')
.map((model) => model.model)

expect(shippedModelIds).not.toHaveLength(0)
expect(shippedModelIds.every((modelId) => supportedModels[modelId] !== undefined)).toBe(true)
})
})

describe('Inference Routes', () => {
let app: { handle: Elysia['handle'] }
Expand Down
2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Thunderbolt is an open-source, cross-platform AI client that can be deployed on-

## Who it's for today

Right now, Thunderbolt targets **enterprise customers deploying on-prem**. The backend currently requires authentication and search to function (web search can be disabled under *Settings → Integrations*). Individual users can self-host and sign up against their own backend.
Right now, Thunderbolt targets **enterprise customers deploying on-prem**. The backend currently requires authentication and search to function (web search can be disabled under *Settings → Connections*). Individual users can self-host and sign up against their own backend.

A hosted version for consumers is planned but does not yet have a release date.

Expand Down
24 changes: 13 additions & 11 deletions e2e/acp-add-custom-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ import { collectPageErrors, loginViaOidc } from './helpers'
* E2E for the "Add custom agent" CRUD path on `/settings/agents`.
*
* Since #933, "Add agent" is gated behind a successful "Test connection": the
* dialog opens a WebSocket to the entered URL and runs the ACP `initialize`
* form opens a WebSocket to the entered URL and runs the ACP `initialize`
* handshake. CI can't reach a real agent, so we mock the WebSocket (Playwright
* `routeWebSocket`) and answer `initialize` with a minimal valid result — this
* exercises the real `testAcpConnection` path without an upstream. The contract
* under test: dialog → connection test → DAL insert → PowerSync live query → UI
* row.
* under test: add panel → connection test → DAL insert → PowerSync live query →
* UI row. The redesign hosts the form in a slide-in detail panel (an `aside`,
* role `complementary`) rather than a dialog.
*/
test.describe('ACP add custom agent', () => {
test('submitting the dialog persists a new row to the list', async ({ page }) => {
test('submitting the add form persists a new row to the list', async ({ page }) => {
const errors = collectPageErrors(page)

// Mock the ACP endpoint: accept the socket and answer the JSON-RPC
Expand Down Expand Up @@ -45,23 +46,24 @@ test.describe('ACP add custom agent', () => {

await page.getByRole('button', { name: 'Add custom agent' }).click()

const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
// Scoped by the panel title: the app renders other `aside` landmarks.
const panel = page.getByRole('complementary').filter({ hasText: 'Add Custom Agent' })
await expect(panel).toBeVisible()

await page.getByLabel('Name').fill('Test Agent')
await page.getByLabel('URL').fill('wss://invalid.example.test/ws')
await page.getByLabel('Description').fill('Test description')

// Add Agent is gated behind a successful connection test (#933): run it and
// wait for the success state before submitting.
await dialog.getByRole('button', { name: 'Test connection' }).click()
await expect(dialog.getByText('Connection successful!')).toBeVisible({ timeout: 10_000 })
await panel.getByRole('button', { name: 'Test connection' }).click()
await expect(panel.getByText('Connection successful!')).toBeVisible({ timeout: 10_000 })

await page.getByRole('button', { name: 'Add agent' }).click()

// Dialog dismisses on success — if validation rejected the URL the dialog
// would stay open with an inline error.
await expect(dialog).toBeHidden({ timeout: 5_000 })
// The panel closes on success — if validation rejected the URL it would
// stay open with an inline error.
await expect(panel).toBeHidden({ timeout: 5_000 })

// The new row is rendered by name. PowerSync's live query feeds the list
// from the synced `agents` table so the row should appear without a manual
Expand Down
12 changes: 11 additions & 1 deletion shared/defaults/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { describe, expect, test } from 'bun:test'
import { hashValues } from '../lib/hash'
import { defaultModels, defaultModelsVersion, hashModel } from './models'

/**
Expand All @@ -20,16 +21,25 @@ import { defaultModels, defaultModelsVersion, hashModel } from './models'
const computeSnapshotHash = () =>
defaultModels.map((model, index) => `${index}:${model.id}:${hashModel(model)}`).join('|')

// `hashModel` deliberately hashes only user-editable fields (it drives the
// user-edit detection in reconciliation), so it is blind to metadata like
// `vendor` and `description`. Hash those separately here so a metadata-only
// defaults change still trips the snapshot and gets its version bump.
const computeMetadataHash = () =>
defaultModels.map((model, index) => `${index}:${hashValues([model.vendor, model.description])}`).join('|')

const expected = {
version: 2,
version: 3,
hash: '0:019af08a-c27b-7074-8aac-95315d1ef3fd:-1vf2pk|1:019f227e-d640-727d-ba12-d51bd7d0a3d6:bvaax2|2:019e7580-2b0e-719c-a43f-d2b56e7f31b4:-g7x2jr',
metadataHash: '0:vzhyk4|1:-x2wlw2|2:-cajkcl',
}

describe('defaultModels version snapshot', () => {
test('version and content are in sync — read the file header if this fails', () => {
expect({
version: defaultModelsVersion,
hash: computeSnapshotHash(),
metadataHash: computeMetadataHash(),
}).toEqual(expected)
})
})
6 changes: 4 additions & 2 deletions shared/defaults/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export const defaultModelDeepseekV4Flash: SharedModel = {
export const defaultModelGlm52: SharedModel = {
id: '019e7580-2b0e-719c-a43f-d2b56e7f31b4',
name: 'GLM 5.2',
// `provider` is the internal transport. The UI presents system-managed
// Tinfoil models as Thunderbolt so infrastructure does not leak into branding.
provider: 'tinfoil',
model: 'glm-5-2',
isSystem: 1,
Expand All @@ -136,7 +138,7 @@ export const defaultModelGlm52: SharedModel = {
url: null,
defaultHash: null,
vendor: 'zhipu',
description: 'Confidential chat via Tinfoil',
description: 'Confidential chat via Thunderbolt',
userId: null,
}

Expand Down Expand Up @@ -167,4 +169,4 @@ export const defaultModels: ReadonlyArray<SharedModel> = [
* The paired snapshot test in `models.test.ts` fails on any change to this
* file's defaults without a matching version bump.
*/
export const defaultModelsVersion = 2
export const defaultModelsVersion = 3
21 changes: 17 additions & 4 deletions src/acp/adapter-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,32 @@
*/

import { useAgentCommandsStore } from './agent-commands-store'
import { connectToAgent as defaultConnectToAgent } from './connect'
import type { ConnectToAgentContext, ConnectToAgentDeps } from './connect'
import type { connectToAgent as defaultConnectToAgent, ConnectToAgentContext, ConnectToAgentDeps } from './connect'
import type { Agent, AgentAdapter } from '@/types/acp'

const cache = new Map<string, Promise<AgentAdapter>>()

/** Preload the agent connection pipeline before the user's first send. */
export const preloadAgentConnection = (): void => {
void import('./connect')
}

/** DI seam so tests can inject a counting/fake `connectToAgent` without
* `mock.module()`. Production omits and binds to the real entry point. */
export type AdapterCacheDeps = {
connectToAgent?: typeof defaultConnectToAgent
}

/** Load the connect chunk on demand (kept out of the entry bundle) and connect. */
const lazilyConnectToAgent = async (
agent: Agent,
ctx: ConnectToAgentContext,
deps: ConnectToAgentDeps,
): Promise<AgentAdapter> => {
const { connectToAgent } = await import('./connect')
return connectToAgent(agent, ctx, deps)
}

/**
* Return the cached adapter for `agent`, connecting once on first use. Concurrent
* callers awaiting the same agent share a single in-flight connect. A failed
Expand All @@ -55,8 +69,7 @@ export const getOrConnectAdapter = async (
return cached
}

const connect = deps.connectToAgent ?? defaultConnectToAgent
const pending = connect(agent, ctx, deps)
const pending = deps.connectToAgent ? deps.connectToAgent(agent, ctx, deps) : lazilyConnectToAgent(agent, ctx, deps)
// Evict a failed connect so the poisoned promise isn't replayed on retry.
pending.catch(() => {
if (cache.get(agent.id) === pending) {
Expand Down
7 changes: 3 additions & 4 deletions src/ai/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ import {
type Tool,
type ToolSet,
} from 'ai'
import { type MCPClient } from '@ai-sdk/mcp'
import type { NamedMCPClient } from '@/lib/mcp-provider'
import type { MCPClient, NamedMCPClient } from '@/lib/mcp-provider'
import { isClosedConnectionError } from '@/lib/mcp-errors'
import { smoothStreamWordDelayMs } from '@/chats/chat-throttle'
import { detectStreamChunk } from './smooth-chunking'
Expand Down Expand Up @@ -140,14 +139,14 @@ export const getSystemTinfoilClient = async (): Promise<SecureClient> => {
* swallowed ONLY here because this is a speculative cache fill — the real send
* still surfaces attestation failures loudly through {@link createModel}.
*/
export const prewarmSystemModel = async (model: Pick<Model, 'provider' | 'isSystem'> | null | undefined) => {
export const runSystemModelPrewarm = async (model: Pick<Model, 'provider' | 'isSystem'> | null | undefined) => {
if (!model || model.provider !== 'tinfoil' || !model.isSystem) {
return
}
try {
await getSystemTinfoilClient()
} catch (error) {
console.warn('prewarmSystemModel: warm-up skipped', error)
console.warn('runSystemModelPrewarm: warm-up skipped', error)
}
}

Expand Down
17 changes: 17 additions & 0 deletions src/ai/prewarm-system-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { Model } from '@/types'

/**
* Warm a managed Tinfoil model without pulling the AI provider pipeline into
* the initial bundle for every other model and agent.
*/
export const prewarmSystemModel = async (model: Pick<Model, 'provider' | 'isSystem'> | null | undefined) => {
if (!model || model.provider !== 'tinfoil' || !model.isSystem) {
return
}
const { runSystemModelPrewarm } = await import('./fetch')
await runSystemModelPrewarm(model)
}
9 changes: 5 additions & 4 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,9 @@ const Settings = lazy(() => import('@/settings/index'))
const PreferencesSettingsPage = lazy(() => import('@/settings/preferences'))
const ModelsPage = lazy(() => import('@/settings/models'))
const DevicesSettingsPage = lazy(() => import('@/settings/devices'))
const McpServersPage = lazy(() => import('@/settings/mcp-servers'))
const ConnectionsPage = lazy(() => import('@/settings/connections'))
const SkillsPage = lazy(() => import('@/settings/skills'))
const AgentsSettingsPage = lazy(() => import('@/routes/settings/agents'))
const IntegrationsPage = lazy(() => import('@/settings/integrations'))

// Lazily import SSO components so non-enterprise deployments don't pay
// for the extra bundle size and attack surface.
Expand Down Expand Up @@ -222,10 +221,12 @@ const AppRoutes = ({ initData }: { initData: InitData }) => {
<Route path="preferences" element={<PreferencesSettingsPage />} />
<Route path="models" element={<ModelsPage />} />
<Route path="devices" element={<DevicesSettingsPage />} />
<Route path="mcp-servers" element={<McpServersPage />} />
<Route path="connections" element={<ConnectionsPage />} />
{/* Legacy routes — MCP servers and integrations merged into Connections. */}
<Route path="mcp-servers" element={<Navigate to="/settings/connections" replace />} />
<Route path="integrations" element={<Navigate to="/settings/connections" replace />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="agents" element={<AgentsSettingsPage />} />
<Route path="integrations" element={<IntegrationsPage />} />
{import.meta.env.DEV && <Route path="dev-settings" element={<DevSettingsPage />} />}
</Route>
</Route>
Expand Down
1 change: 1 addition & 0 deletions src/assets/openai.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/openrouter.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/tinfoil.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion src/chats/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SavePartialAssistantMessagesHandler } from './save-partial-assistant-me
import { useParams } from 'react-router'
import { v7 as uuidv7 } from 'uuid'
import { useHandleIntegrationCompletion } from '@/hooks/use-handle-integration-completion'
import { loadChatMessageList } from '@/components/chat/chat-messages-loader'

type ChatHydrateHandlerProps = PropsWithChildren<{
id: string
Expand Down Expand Up @@ -41,7 +42,13 @@ export default function ChatDetailPage() {

const isNew = params.chatThreadId === 'new'

const id = useMemo(() => (isNew ? uuidv7() : params.chatThreadId || null), [params.chatThreadId])
const id = useMemo(() => (isNew ? uuidv7() : params.chatThreadId || null), [isNew, params.chatThreadId])

useEffect(() => {
if (!isNew) {
void loadChatMessageList()
}
}, [isNew])

if (!id) {
return null
Expand Down
2 changes: 1 addition & 1 deletion src/chats/use-hydrate-chat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router'
import { useChatStore } from './chat-store'
import { createChatInstance } from './chat-instance'
import { prewarmSystemModel } from '@/ai/fetch'
import { prewarmSystemModel } from '@/ai/prewarm-system-model'

type UseHydrateChatStoreParams = {
id: string
Expand Down
2 changes: 1 addition & 1 deletion src/components/available-tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const AvailableTools: FC<AvailableToolsProps> = ({ tools, className }) =>
<div className={cn(className)}>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="tools" className="border-none">
<AccordionTrigger className="py-3 hover:no-underline">
<AccordionTrigger className="cursor-pointer py-3 hover:no-underline">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-foreground">Available Tools</div>
<div className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded-full">
Expand Down
20 changes: 20 additions & 0 deletions src/components/chat/chat-message-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { RefCallback } from 'react'

import { ChatMessages } from './chat-messages'

type ChatMessageListProps = {
scrollTargetRef: RefCallback<HTMLDivElement>
}

/** The lazily-split chat message subtree — loaded via `loadChatMessageList` so the
* message renderers stay out of the entry bundle until a chat actually opens. */
export const ChatMessageList = ({ scrollTargetRef }: ChatMessageListProps) => (
<>
<ChatMessages />
<div ref={scrollTargetRef} className="shrink-0 !mt-0 h-2 md:h-3" />
</>
)
6 changes: 6 additions & 0 deletions src/components/chat/chat-messages-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/** Load the message-rendering subtree separately from the blank new-chat shell. */
export const loadChatMessageList = () => import('./chat-message-list')
Loading
Loading