diff --git a/AGENTS.md b/AGENTS.md index a8cfd87b4..511d39a5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/backend/src/inference/routes.test.ts b/backend/src/inference/routes.test.ts index 591215f3f..60173e4a3 100644 --- a/backend/src/inference/routes.test.ts +++ b/backend/src/inference/routes.test.ts @@ -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'] } diff --git a/docs/introduction.md b/docs/introduction.md index baae67711..98fea04b0 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -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. diff --git a/e2e/acp-add-custom-agent.spec.ts b/e2e/acp-add-custom-agent.spec.ts index 7cf2f3c29..414935ba5 100644 --- a/e2e/acp-add-custom-agent.spec.ts +++ b/e2e/acp-add-custom-agent.spec.ts @@ -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 @@ -45,8 +46,9 @@ 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') @@ -54,14 +56,14 @@ test.describe('ACP add custom agent', () => { // 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 diff --git a/shared/defaults/models.test.ts b/shared/defaults/models.test.ts index 484e13eaa..1375d60d9 100644 --- a/shared/defaults/models.test.ts +++ b/shared/defaults/models.test.ts @@ -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' /** @@ -20,9 +21,17 @@ 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', () => { @@ -30,6 +39,7 @@ describe('defaultModels version snapshot', () => { expect({ version: defaultModelsVersion, hash: computeSnapshotHash(), + metadataHash: computeMetadataHash(), }).toEqual(expected) }) }) diff --git a/shared/defaults/models.ts b/shared/defaults/models.ts index 35d0c9bc8..383b9a592 100644 --- a/shared/defaults/models.ts +++ b/shared/defaults/models.ts @@ -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, @@ -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, } @@ -167,4 +169,4 @@ export const defaultModels: ReadonlyArray = [ * 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 diff --git a/src/acp/adapter-cache.ts b/src/acp/adapter-cache.ts index 4ef424b85..f53352e20 100644 --- a/src/acp/adapter-cache.ts +++ b/src/acp/adapter-cache.ts @@ -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>() +/** 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 => { + 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 @@ -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) { diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index edc62e71e..8fa49f68a 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -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' @@ -140,14 +139,14 @@ export const getSystemTinfoilClient = async (): Promise => { * 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 | null | undefined) => { +export const runSystemModelPrewarm = async (model: Pick | 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) } } diff --git a/src/ai/prewarm-system-model.ts b/src/ai/prewarm-system-model.ts new file mode 100644 index 000000000..e96c230f8 --- /dev/null +++ b/src/ai/prewarm-system-model.ts @@ -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 | null | undefined) => { + if (!model || model.provider !== 'tinfoil' || !model.isSystem) { + return + } + const { runSystemModelPrewarm } = await import('./fetch') + await runSystemModelPrewarm(model) +} diff --git a/src/app.tsx b/src/app.tsx index 235110e57..d3c80f904 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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. @@ -222,10 +221,12 @@ const AppRoutes = ({ initData }: { initData: InitData }) => { } /> } /> } /> - } /> + } /> + {/* Legacy routes — MCP servers and integrations merged into Connections. */} + } /> + } /> } /> } /> - } /> {import.meta.env.DEV && } />} diff --git a/src/assets/openai.svg b/src/assets/openai.svg new file mode 100644 index 000000000..b31e39897 --- /dev/null +++ b/src/assets/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/openrouter.svg b/src/assets/openrouter.svg new file mode 100644 index 000000000..452db8ebe --- /dev/null +++ b/src/assets/openrouter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/tinfoil.svg b/src/assets/tinfoil.svg new file mode 100644 index 000000000..a3a418d12 --- /dev/null +++ b/src/assets/tinfoil.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/chats/detail.tsx b/src/chats/detail.tsx index fa8dae072..c8c67f314 100644 --- a/src/chats/detail.tsx +++ b/src/chats/detail.tsx @@ -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 @@ -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 diff --git a/src/chats/use-hydrate-chat-store.ts b/src/chats/use-hydrate-chat-store.ts index d8ad6c7c5..c30a40c58 100644 --- a/src/chats/use-hydrate-chat-store.ts +++ b/src/chats/use-hydrate-chat-store.ts @@ -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 diff --git a/src/components/available-tools.tsx b/src/components/available-tools.tsx index da0d0a4c9..778fcd5eb 100644 --- a/src/components/available-tools.tsx +++ b/src/components/available-tools.tsx @@ -48,7 +48,7 @@ export const AvailableTools: FC = ({ tools, className }) =>
- +
Available Tools
diff --git a/src/components/chat/chat-message-list.tsx b/src/components/chat/chat-message-list.tsx new file mode 100644 index 000000000..eff2edac4 --- /dev/null +++ b/src/components/chat/chat-message-list.tsx @@ -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 +} + +/** 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) => ( + <> + +
+ +) diff --git a/src/components/chat/chat-messages-loader.ts b/src/components/chat/chat-messages-loader.ts new file mode 100644 index 000000000..2ee775479 --- /dev/null +++ b/src/components/chat/chat-messages-loader.ts @@ -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') diff --git a/src/components/chat/chat-prompt-input.tsx b/src/components/chat/chat-prompt-input.tsx index 212792601..80f1c3f0e 100644 --- a/src/components/chat/chat-prompt-input.tsx +++ b/src/components/chat/chat-prompt-input.tsx @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { isAgentAvailable as isAgentAvailable_default } from '@/acp/agent-availability' +import { preloadAgentConnection } from '@/acp/adapter-cache' import { useCurrentChatSession } from '@/chats/chat-store' import { usePendingQuotes, usePendingQuotesStore } from '@/chats/pending-quotes-store' import { estimateTokensForText } from '@/ai/tokenizers' @@ -29,7 +30,7 @@ import { useChat as useChat_default } from '@ai-sdk/react' import { messageBookkeepingThrottleMs } from '@/chats/chat-throttle' import { useDraftInput } from '@/hooks/use-draft-input' import { AnimatePresence, m } from 'framer-motion' -import { AlertCircle, Loader2, Paperclip, Plus, X } from 'lucide-react' +import { AlertCircle, Loader2, Paperclip, Plug, Plus, X } from 'lucide-react' import { type ClipboardEvent, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useLocation as useLocation_default, useNavigate as useNavigate_default } from 'react-router' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -44,6 +45,7 @@ import { buildQuotePart } from '@/lib/quotes' import { QuoteChip } from './quote-chip' import { deleteAttachment, putAttachment } from '@/lib/file-blob-storage' import { FileCard } from './file-card' +import { loadChatMessageList } from './chat-messages-loader' /** Max size for a chat attachment stored locally and sent to the agent. */ const maxAttachmentBytes = 25 * 1024 * 1024 @@ -253,6 +255,16 @@ export const ChatPromptInput = forwardRef(null) + const hasPreloadedSendDependencies = useRef(false) + + const preloadSendDependencies = useCallback(() => { + if (hasPreloadedSendDependencies.current) { + return + } + hasPreloadedSendDependencies.current = true + preloadAgentConnection() + void loadChatMessageList() + }, []) const getTextarea = (): HTMLTextAreaElement | null => { textareaRef.current = formRef.current?.querySelector('textarea') ?? null @@ -568,6 +580,10 @@ export const ChatPromptInput = forwardRef Upload file + navigate('/settings/connections')} className="cursor-pointer"> + + Connections + {isConnecting ? ( @@ -780,7 +796,12 @@ export const ChatPromptInput = forwardRef setInput(value)} + onChange={(value: string) => { + if (value.length > 0) { + preloadSendDependencies() + } + setInput(value) + }} placeholder="Ask me anything..." showSubmitButton onSubmit={handleSubmit} diff --git a/src/components/chat/chat-skills-bar.tsx b/src/components/chat/chat-skills-bar.tsx index b176b509b..79247025a 100644 --- a/src/components/chat/chat-skills-bar.tsx +++ b/src/components/chat/chat-skills-bar.tsx @@ -3,11 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Plus } from 'lucide-react' -import { useEffect, useReducer } from 'react' +import { lazy, Suspense, useEffect, useReducer } from 'react' import { createPortal } from 'react-dom' import { useNavigate } from 'react-router' import { Button } from '@/components/ui/button' +import { MobileBlurBackdrop } from '@/components/ui/mobile-blur-backdrop' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { SearchInput } from '@/components/ui/search-input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -15,7 +16,6 @@ import { maxPinnedSkills } from '@/dal' import { useIsMobile } from '@/hooks/use-mobile' import { cn } from '@/lib/utils' import { skillDisplayName, skillMatchesQuery } from '@/skills/display' -import { ReorderPanel } from '@/skills/reorder-panel' import { chipSurfaceClass, SuggestionChip } from '@/skills/suggestion-chip' import { useSkillTelemetry } from '@/skills/telemetry' import { @@ -25,6 +25,9 @@ import { } from '@/skills/use-skills' import type { Skill } from '@/types' +const loadReorderPanel = () => import('@/skills/reorder-panel') +const ReorderPanel = lazy(() => loadReorderPanel().then((module) => ({ default: module.ReorderPanel }))) + type BarState = { reorderMode: boolean addOpen: boolean @@ -131,24 +134,26 @@ export const ChatSkillsBar = ({ return ( <> {isMobile && dispatch({ type: 'REORDER_CLOSED' })} />} - { - // `move` comes from dnd-kit's `active.id` / index lookup — unambiguous - // even for adjacent swaps, where a diff-based heuristic can't tell - // which side the user actually dragged. Await the mutation before - // firing telemetry so a rejection doesn't record a phantom event. - dispatch({ type: 'MUTATION_STARTED' }) - try { - await reorderPins(ids) - trackSkillEvent('skill_reordered', move.id, { from_index: move.from, to_index: move.to }) - } catch (error) { - console.error('reorderPins failed:', error) - dispatch({ type: 'MUTATION_FAILED', message: "Couldn't save the new order." }) - } - }} - onClose={() => dispatch({ type: 'REORDER_CLOSED' })} - /> + + { + // `move` comes from dnd-kit's `active.id` / index lookup — unambiguous + // even for adjacent swaps, where a diff-based heuristic can't tell + // which side the user actually dragged. Await the mutation before + // firing telemetry so a rejection doesn't record a phantom event. + dispatch({ type: 'MUTATION_STARTED' }) + try { + await reorderPins(ids) + trackSkillEvent('skill_reordered', move.id, { from_index: move.from, to_index: move.to }) + } catch (error) { + console.error('reorderPins failed:', error) + dispatch({ type: 'MUTATION_FAILED', message: "Couldn't save the new order." }) + } + }} + onClose={() => dispatch({ type: 'REORDER_CLOSED' })} + /> + ) } @@ -202,7 +207,10 @@ export const ChatSkillsBar = ({ onClick={() => onAddToChat(skill.name)} onAddInstruction={() => onAddInstruction(skill.instruction)} onEdit={() => void navigate('/settings/skills', { state: { startEditSkill: skill.id } })} - onReorder={() => dispatch({ type: 'REORDER_OPENED' })} + onReorder={() => { + void loadReorderPanel() + dispatch({ type: 'REORDER_OPENED' }) + }} onUnpin={() => handleTogglePin(skill, 'unpin')} /> ))} @@ -335,10 +343,8 @@ const MobileOverlay = ({ onDismiss }: { onDismiss: () => void }) => { }, [onDismiss]) return createPortal( - + } + onClose={onClose} + > +
+ Status +

Connected and available to agents.

+
+ +
+ Server URL +

https://example.com/mcp

+
+ + + + + +) + +export const DesktopSplitView: Story = { + render: (args) => ( +
+
+

Connections

+
Selected connection
+
+ {detailContents(args.onClose)} +
+ ), +} + +export const MobileModal: Story = { + args: { + isMobile: true, + }, + render: (args) => {detailContents(args.onClose)}, + parameters: { + viewport: { defaultViewport: 'mobile1' }, + }, +} diff --git a/src/components/detail-panel.test.tsx b/src/components/detail-panel.test.tsx new file mode 100644 index 000000000..bd948ebe5 --- /dev/null +++ b/src/components/detail-panel.test.tsx @@ -0,0 +1,72 @@ +/* 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 { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, mock } from 'bun:test' + +import { FormFooter } from './ui/form-footer' +import { DetailPanel, DetailPanelSurface } from './detail-panel' + +describe('DetailPanelSurface', () => { + afterEach(() => { + cleanup() + }) + + it('uses the shared responsive modal on mobile', () => { + const onClose = mock() + + render( + + +

Detail body

+ + + +
+
, + ) + + expect(document.querySelector('[data-slot="responsive-modal-content"]')).toBeInTheDocument() + expect(screen.getByText('Detail body')).toBeInTheDocument() + const scrollArea = screen.getByText('Detail body').parentElement + expect(scrollArea).toHaveClass('pt-6', 'md:pt-4', '[&_[data-slot=form-footer]]:sticky') + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('adds more mobile spacing below a subtitle', () => { + render( + {}}> + {}}> +

Server status

+
+
, + ) + + expect(screen.getByText('Server status').parentElement).toHaveClass('pt-8', 'md:pt-4') + }) + + it('keeps the desktop detail header outside the modal shell', () => { + const onClose = mock() + + render( + + +

Detail body

+
+
, + ) + + expect(document.querySelector('[data-slot="responsive-modal-content"]')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close details' })).toBeInTheDocument() + const surface = screen.getByText('Detail body').closest('.bg-sidebar') + expect(surface?.closest('[data-slot="slide-in-panel"]')).toHaveClass( + '[filter:drop-shadow(var(--shadow-glow-strong))]', + ) + expect(surface?.parentElement).toHaveClass('pb-12') + expect(surface?.parentElement).not.toHaveClass('pt-3') + expect(surface?.parentElement).not.toHaveClass('pl-4') + }) +}) diff --git a/src/components/detail-panel.tsx b/src/components/detail-panel.tsx index f6d5cfaa8..39d35e2da 100644 --- a/src/components/detail-panel.tsx +++ b/src/components/detail-panel.tsx @@ -2,12 +2,22 @@ * 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 { AnimatePresence, m } from 'framer-motion' import { X } from 'lucide-react' import type { ReactNode } from 'react' import { SlideInPanel } from '@/components/slide-in-panel' import { Button, mutedIconButtonClass } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog' +import { panelFieldSurfaceClass } from '@/components/ui/modal-styles' +import { + ResponsiveModalContentComposable, + ResponsiveModalDescription, + ResponsiveModalActions, + ResponsiveModalHeader, + ResponsiveModalTitle, + useResponsiveModalContext, +} from '@/components/ui/responsive-modal' +import { cn } from '@/lib/utils' /** * Shared anatomy for the slide-in detail panels (skills, agents, CLI): one @@ -44,43 +54,67 @@ type DetailPanelProps = { * surface card, on mobile inside the full-screen overlay — so content lies * flat on the surface with hairline dividers instead of nested cards. */ -export const DetailPanel = ({ icon, title, subtitle, actions, onClose, children }: DetailPanelProps) => ( -
- {/* Mobile keeps the list's title-row height (shared page chrome); the - desktop card gets a taller header so the title has room to breathe. */} -
-
- {icon} -
-

{title}

- {subtitle && {subtitle}} -
-
- {/* Desktop: pin the actions to the card's top-right corner, 8px from - both edges (right: 24px padding − 16px), independent of the taller - header so the X stays equidistant from top and right. */} -
- {actions} - -
-
+export const DetailPanel = ({ icon, title, subtitle, actions, onClose, children }: DetailPanelProps) => { + const { isMobile } = useResponsiveModalContext() - {/* The whole body scrolls as one area. */} -
{children}
-
-) + return ( +
+ {isMobile ? ( + <> + + {title} + {subtitle && {subtitle}} + + {actions && {actions}} + + ) : ( + // mt-2.5 brings the icon tile's top gap to 24px ((64 − 36) / 2 + 10), + // matching the panel's md:px-6 left padding. +
+
+ {icon} +
+

{title}

+ {subtitle && {subtitle}} +
+
+ {/* Pin the controls to the card's top-right corner, 8px from both + edges (right: 24px padding − 16px; top: 8px − the header's 10px + margin). Close sits outermost (the desktop convention); the + mobile shell instead splits them across the top corners. */} +
+ {actions} + +
+
+ )} + +
+ {children} +
+
+ ) +} type DetailPanelSurfaceProps = { open: boolean isMobile: boolean + onClose: () => void children: ReactNode } @@ -89,16 +123,31 @@ type DetailPanelSurfaceProps = { * right-side slide-in at a ~50/50 split with the list (half the viewport * minus half the sidebar), on one continuous surface card lifted off the * page by the app's soft glow shadow plus a faint border — bg-sidebar - * (near-white in light mode) like the chat composer, bottom padding floating - * the card off the window edge, right edge flush and square with only the - * left corners rounded. Mobile: a full-screen spring slide-over. + * (near-white in light mode) like the chat composer. The header inset above + * and bottom padding below float the card off the window edges by the same + * 48px; callers leave the outer + * flex row unclipped so the glow can extend beyond that inset naturally. Its + * right edge stays flush and square with only the left corners rounded. Mobile + * uses the same full-screen fade/scale modal as other responsive views. */ -export const DetailPanelSurface = ({ open, isMobile, children }: DetailPanelSurfaceProps) => { +export const DetailPanelSurface = ({ open, isMobile, onClose, children }: DetailPanelSurfaceProps) => { if (!isMobile) { return ( - -
-
+ // The warm 6% glow is invisible on the dark background (same rationale + // as the .dark elevation overrides in index.css), so dark mode swaps in + // a slightly stronger black ink at the same blur radius. + +
+
{children}
@@ -106,19 +155,8 @@ export const DetailPanelSurface = ({ open, isMobile, children }: DetailPanelSurf ) } return ( - - {open && ( - - {children} - - )} - + !nextOpen && onClose()}> + {children} + ) } diff --git a/src/components/device-approval.tsx b/src/components/device-approval.tsx index abe228146..6048af01e 100644 --- a/src/components/device-approval.tsx +++ b/src/components/device-approval.tsx @@ -31,12 +31,12 @@ type State = { } type Action = - | { type: 'setCode'; userCode: string } - | { type: 'verifyStart'; userCode: string } - | { type: 'settled'; status: 'confirming' | 'approved' | 'denied' } - | { type: 'submitStart'; action: 'approve' | 'deny' } - | { type: 'fail'; error: DeviceGrantFailure } - | { type: 'reset' } + | { type: 'CODE_CHANGED'; userCode: string } + | { type: 'VERIFY_STARTED'; userCode: string } + | { type: 'SETTLED'; status: 'confirming' | 'approved' | 'denied' } + | { type: 'SUBMIT_STARTED'; action: 'approve' | 'deny' } + | { type: 'FAILED'; error: DeviceGrantFailure } + | { type: 'RESET' } const init = (initialCode: string): State => ({ status: initialCode ? 'verifying' : 'enteringCode', @@ -47,18 +47,18 @@ const init = (initialCode: string): State => ({ const reducer = (state: State, action: Action): State => { switch (action.type) { - case 'setCode': + case 'CODE_CHANGED': return { ...state, userCode: action.userCode } - case 'verifyStart': + case 'VERIFY_STARTED': // Persist the canonical (normalized) code so approve/deny post exactly what was verified. return { ...state, status: 'verifying', userCode: action.userCode, error: null } - case 'settled': + case 'SETTLED': return { ...state, status: action.status, pendingAction: null } - case 'submitStart': + case 'SUBMIT_STARTED': return { ...state, status: 'submitting', pendingAction: action.action, error: null } - case 'fail': + case 'FAILED': return { ...state, status: 'failed', pendingAction: null, error: action.error } - case 'reset': + case 'RESET': return { status: 'enteringCode', userCode: '', pendingAction: null, error: null } } } @@ -72,13 +72,13 @@ const useDeviceApproval = (authClient: AuthClient, initialCode: string) => { const [state, dispatch] = useReducer(reducer, initialCode, init) const verify = async (code: string) => { - dispatch({ type: 'verifyStart', userCode: code }) + dispatch({ type: 'VERIFY_STARTED', userCode: code }) const result = await verifyDeviceCode(authClient, code) if (!result.ok) { - dispatch({ type: 'fail', error: result }) + dispatch({ type: 'FAILED', error: result }) return } - dispatch({ type: 'settled', status: result.status === 'pending' ? 'confirming' : result.status }) + dispatch({ type: 'SETTLED', status: result.status === 'pending' ? 'confirming' : result.status }) } // Verify-on-mount when the code arrived via the QR/link. Ref-guarded so Strict Mode's @@ -103,27 +103,27 @@ const useDeviceApproval = (authClient: AuthClient, initialCode: string) => { } const runAction = async (action: 'approve' | 'deny') => { - dispatch({ type: 'submitStart', action }) + dispatch({ type: 'SUBMIT_STARTED', action }) const call = action === 'approve' ? approveDeviceCode : denyDeviceCode const result = await call(authClient, state.userCode) if (!result.ok) { - dispatch({ type: 'fail', error: result }) + dispatch({ type: 'FAILED', error: result }) return } - dispatch({ type: 'settled', status: action === 'approve' ? 'approved' : 'denied' }) + dispatch({ type: 'SETTLED', status: action === 'approve' ? 'approved' : 'denied' }) } return { state, - setCode: (userCode: string) => dispatch({ type: 'setCode', userCode }), + setCode: (userCode: string) => dispatch({ type: 'CODE_CHANGED', userCode }), submitCode, approve: () => runAction('approve'), deny: () => runAction('deny'), - reset: () => dispatch({ type: 'reset' }), + reset: () => dispatch({ type: 'RESET' }), } } -const iconWrapper = 'mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full' +const iconWrapperClass = 'mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full' /** Non-dismissable modal shell shared by every approval-page state. */ const ApprovalShell = ({ children }: { children: ReactNode }) => ( @@ -146,7 +146,7 @@ const DeviceApprovalContent = ({ initialCode }: { initialCode: string }) => { {state.status === 'verifying' && ( -
+
Checking sign-in request… @@ -157,7 +157,7 @@ const DeviceApprovalContent = ({ initialCode }: { initialCode: string }) => { {state.status === 'enteringCode' && (
-
+
Sign in to the CLI @@ -186,7 +186,7 @@ const DeviceApprovalContent = ({ initialCode }: { initialCode: string }) => { {(state.status === 'confirming' || isSubmitting) && ( <> -
+
Approve CLI sign-in? @@ -240,7 +240,7 @@ const DeviceApprovalContent = ({ initialCode }: { initialCode: string }) => { {state.status === 'denied' && ( <> -
+
Sign-in denied @@ -259,7 +259,7 @@ const DeviceApprovalContent = ({ initialCode }: { initialCode: string }) => { {state.status === 'failed' && state.error && ( <> -
+
@@ -298,7 +298,7 @@ export const DeviceApproval = () => { return ( -
+
Loading… @@ -310,6 +310,8 @@ export const DeviceApproval = () => { if (!session?.user) { // Preserve the code across the login redirect so the approval page comes back pre-filled. + // Render-phase storage write: idempotent (same key, same value), so Strict Mode's double + // render is harmless, and it must happen before the below unmounts this page. if (searchParams.get('user_code')) { saveDeviceApprovalReturn(`${location.pathname}${location.search}`) } diff --git a/src/components/floating-header.tsx b/src/components/floating-header.tsx index 1d759d021..1c23cc730 100644 --- a/src/components/floating-header.tsx +++ b/src/components/floating-header.tsx @@ -10,10 +10,8 @@ import { Header } from '@/components/ui/header' * * The scrim fades the page background from the very top of the viewport down * past the floating header, so content scrolling beneath stays legible behind - * the header controls. Painted background-on-background, it is invisible - * until content actually scrolls under it. It holds full opacity through the - * top 40% (where the header buttons sit) before fading, so text scrolling - * underneath can't bleed through the controls. + * the header controls. A subtle backdrop blur softens content passing behind + * the controls, then fades with the scrim so there is no hard blur boundary. * * The header floats over the content instead of consuming layout height — * pages own the full viewport and pad by `--header-inset` where needed. @@ -21,8 +19,12 @@ import { Header } from '@/components/ui/header' export const FloatingHeader = () => ( <>
diff --git a/src/components/logout-modal.tsx b/src/components/logout-modal.tsx index d110974c3..d4a4dad1b 100644 --- a/src/components/logout-modal.tsx +++ b/src/components/logout-modal.tsx @@ -6,11 +6,12 @@ import { HardDrive, Loader2, Trash2 } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' +import { FormFooter } from '@/components/ui/form-footer' import { ResponsiveModal, + ResponsiveModalCancel, ResponsiveModalContent, ResponsiveModalDescription, - ResponsiveModalFooter, ResponsiveModalHeader, ResponsiveModalTitle, } from '@/components/ui/responsive-modal' @@ -93,10 +94,8 @@ export const LogoutModal = ({ open, onOpenChange, clearLocalData = defaultClearL /> - - + + handleOpenChange(false)} disabled={isLoggingOut} /> diff --git a/src/components/onboarding/onboarding-dialog.tsx b/src/components/onboarding/onboarding-dialog.tsx index 22313091a..7c5038f5c 100644 --- a/src/components/onboarding/onboarding-dialog.tsx +++ b/src/components/onboarding/onboarding-dialog.tsx @@ -3,7 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useEffect, useState } from 'react' -import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Dialog, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { ResponsiveModalContentComposable } from '@/components/ui/responsive-modal' import { useDatabase } from '@/contexts' import { deleteIntegrationCredentials } from '@/dal' import type { OAuthProvider } from '@/lib/auth' @@ -102,27 +103,22 @@ export const OnboardingDialog = () => { return ( - Onboarding Wizard Complete the setup process to get started with Thunderbolt
-
+
-
+
{state.currentStep === 1 && } {state.currentStep === 2 && ( { )} {state.currentStep === 5 && }
-
+
{ />
- +
) } diff --git a/src/components/onboarding/onboarding-feature-card.tsx b/src/components/onboarding/onboarding-feature-card.tsx index a419fe29b..c3c014a40 100644 --- a/src/components/onboarding/onboarding-feature-card.tsx +++ b/src/components/onboarding/onboarding-feature-card.tsx @@ -14,9 +14,9 @@ export const OnboardingFeatureCard = ({ icon: Icon, title, description }: Onboar return (
-
-

{title}

-

{description}

+
+

{title}

+

{description}

) diff --git a/src/components/onboarding/onboarding-privacy-step.tsx b/src/components/onboarding/onboarding-privacy-step.tsx index 452fb0047..a8bcb3e7d 100644 --- a/src/components/onboarding/onboarding-privacy-step.tsx +++ b/src/components/onboarding/onboarding-privacy-step.tsx @@ -26,8 +26,8 @@ export const OnboardingPrivacyStep = ({ state, actions }: OnboardingPrivacyStepP } return ( -
-
+
+
} title="Welcome to Thunderbolt!" @@ -55,7 +55,7 @@ export const OnboardingPrivacyStep = ({ state, actions }: OnboardingPrivacyStepP
-
+
{icon}

{title}

{description && ( -

{description}

+

{description}

)}
) diff --git a/src/components/pending-device-modal.tsx b/src/components/pending-device-modal.tsx index 9f7e4557a..0f0e10175 100644 --- a/src/components/pending-device-modal.tsx +++ b/src/components/pending-device-modal.tsx @@ -8,7 +8,7 @@ import { Smartphone } from 'lucide-react' import { ApproveDeviceDialog } from '@/components/approve-device-dialog' import { RevokeDeviceDialog } from '@/components/revoke-device-dialog' import { Button } from '@/components/ui/button' -import { ResponsiveModal, ResponsiveModalContent } from '@/components/ui/responsive-modal' +import { ResponsiveModal, ResponsiveModalContent, ResponsiveModalTitle } from '@/components/ui/responsive-modal' import { IconCircle } from '@/components/onboarding/icon-circle' import { useApproveDevice } from '@/hooks/use-approve-device' import { useDenyDevice } from '@/hooks/use-deny-device' @@ -58,7 +58,9 @@ export const PendingDeviceModal = () => { -

New device waiting

+ + New device waiting +

A new device is requesting access to your encrypted data.

{pendingDeviceToNotify && ( diff --git a/src/components/remove-bridge-dialog.tsx b/src/components/remove-bridge-dialog.tsx index 418349459..d40d181ee 100644 --- a/src/components/remove-bridge-dialog.tsx +++ b/src/components/remove-bridge-dialog.tsx @@ -20,6 +20,8 @@ type RemoveBridgeDialogProps = { isPending: boolean } +/** Confirms permanent removal of a revoked bridge device — the one frontend flow + * that hard-deletes (a sanctioned device-removal exception to soft-delete). */ export const RemoveBridgeDialog = ({ open, onOpenChange, onConfirm, isPending }: RemoveBridgeDialogProps) => ( diff --git a/src/components/revoked-device-modal.tsx b/src/components/revoked-device-modal.tsx index d5d89ebec..939f7255f 100644 --- a/src/components/revoked-device-modal.tsx +++ b/src/components/revoked-device-modal.tsx @@ -6,11 +6,11 @@ import { HardDrive, Loader2, Trash2 } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' +import { FormFooter } from '@/components/ui/form-footer' import { ResponsiveModal, ResponsiveModalContent, ResponsiveModalDescription, - ResponsiveModalFooter, ResponsiveModalHeader, ResponsiveModalTitle, } from '@/components/ui/responsive-modal' @@ -64,7 +64,7 @@ export const RevokedDeviceModal = ({ open }: RevokedDeviceModalProps) => { /> - + - + ) } diff --git a/src/components/settings/agents/add-custom-agent-dialog.test.tsx b/src/components/settings/agents/add-custom-agent-form.test.tsx similarity index 82% rename from src/components/settings/agents/add-custom-agent-dialog.test.tsx rename to src/components/settings/agents/add-custom-agent-form.test.tsx index 0d4027105..6e2bbf1c3 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-form.test.tsx @@ -5,28 +5,22 @@ import '@testing-library/jest-dom' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' -import { AddCustomAgentDialog, type AddCustomAgentPayload, type TestAcpConnectionFn } from './add-custom-agent-dialog' +import { AddCustomAgentForm, type AddCustomAgentPayload, type TestAcpConnectionFn } from './add-custom-agent-form' afterEach(() => { cleanup() }) -describe('AddCustomAgentDialog', () => { +describe('AddCustomAgentForm', () => { const notIos = () => false const succeedingProbe: TestAcpConnectionFn = async () => ({ success: true }) it('keeps Add Agent disabled until both name and URL are filled and the connection test succeeds', async () => { const onSubmit = mock(async () => {}) - const onOpenChange = mock(() => {}) + const onClose = mock(() => {}) render( - , + , ) const submit = screen.getByRole('button', { name: /add agent/i }) @@ -47,15 +41,9 @@ describe('AddCustomAgentDialog', () => { it('invokes onSubmit with websocket transport and trimmed values', async () => { const onSubmit = mock(async (_: AddCustomAgentPayload) => {}) - const onOpenChange = mock(() => {}) + const onClose = mock(() => {}) render( - , + , ) fireEvent.change(screen.getByLabelText(/name/i), { target: { value: ' My Agent ' } }) @@ -78,8 +66,8 @@ describe('AddCustomAgentDialog', () => { description: 'Demo', transport: 'websocket', }) - // Closes dialog on success. - expect(onOpenChange).toHaveBeenCalledWith(false) + // Closes the panel on success. + expect(onClose).toHaveBeenCalledTimes(1) }) it('keeps the dialog open with submit re-enabled when onSubmit rejects', async () => { @@ -87,15 +75,9 @@ describe('AddCustomAgentDialog', () => { const onSubmit = mock(async () => { throw new Error('insert failed') }) - const onOpenChange = mock(() => {}) + const onClose = mock(() => {}) render( - , + , ) fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'My Agent' } }) @@ -109,8 +91,8 @@ describe('AddCustomAgentDialog', () => { }) expect(onSubmit).toHaveBeenCalledTimes(1) - // The dialog stays open with the form intact so the user can retry. - expect(onOpenChange).not.toHaveBeenCalled() + // The form stays open and intact so the user can retry. + expect(onClose).not.toHaveBeenCalled() expect(screen.getByLabelText(/name/i)).toHaveValue('My Agent') expect(screen.getByRole('button', { name: /add agent/i })).not.toBeDisabled() expect(consoleError).toHaveBeenCalled() @@ -119,8 +101,8 @@ describe('AddCustomAgentDialog', () => { it('shows the iOS rejection inline for ws:// at render time, keeps Add disabled, and does NOT call onSubmit', () => { const onSubmit = mock(async () => {}) - const onOpenChange = mock(() => {}) - render( true} />) + const onClose = mock(() => {}) + render( true} />) fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'iOS Agent' } }) fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'ws://example.com/ws' } }) @@ -133,8 +115,8 @@ describe('AddCustomAgentDialog', () => { it('shows an inline error for http:// (unsupported scheme) at render time and keeps Add disabled', () => { const onSubmit = mock(async () => {}) - const onOpenChange = mock(() => {}) - render() + const onClose = mock(() => {}) + render() fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Bad Agent' } }) fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'http://example.com' } }) @@ -146,8 +128,8 @@ describe('AddCustomAgentDialog', () => { it('shows an inline error for unsupported schemes at render time and keeps Add disabled', () => { const onSubmit = mock(async () => {}) - const onOpenChange = mock(() => {}) - render() + const onClose = mock(() => {}) + render() fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Bad Agent' } }) fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'ftp://example.com' } }) @@ -158,22 +140,16 @@ describe('AddCustomAgentDialog', () => { }) }) -describe('AddCustomAgentDialog — connection status', () => { +describe('AddCustomAgentForm — connection status', () => { const notIos = () => false const renderWithProbe = (testAcpConnection: TestAcpConnectionFn) => { const onSubmit = mock(async () => {}) - const onOpenChange = mock(() => {}) + const onClose = mock(() => {}) render( - , + , ) - return { onSubmit, onOpenChange } + return { onSubmit, onClose } } const fillNameAndUrl = () => { @@ -261,7 +237,7 @@ describe('AddCustomAgentDialog — connection status', () => { }) }) -describe('AddCustomAgentDialog — iroh', () => { +describe('AddCustomAgentForm — iroh', () => { const notIos = () => false const irohTarget = 'a'.repeat(52) const appNodeId = 'b'.repeat(52) @@ -270,18 +246,17 @@ describe('AddCustomAgentDialog — iroh', () => { const renderIroh = () => { const onSubmit = mock(async (_: AddCustomAgentPayload) => {}) - const onOpenChange = mock(() => {}) + const onClose = mock(() => {}) render( - ({ success: true })} loadAppNodeId={loadAppNodeId} />, ) - return { onSubmit, onOpenChange } + return { onSubmit, onClose } } it('hides Test Connection for an iroh target and gates Add on name + valid target only', async () => { @@ -316,9 +291,8 @@ describe('AddCustomAgentDialog — iroh', () => { it('surfaces an error when the app pairing identity fails to load', async () => { const onSubmit = mock(async () => {}) render( - {}} + {}} onSubmit={onSubmit} isIos={notIos} loadAppNodeId={async () => { @@ -335,16 +309,15 @@ describe('AddCustomAgentDialog — iroh', () => { await waitFor(() => expect(panel.textContent).toMatch(/relay unreachable/i)) }) - it('re-loads the app NodeId after a reset and an iroh target is re-entered (no stuck "Loading")', async () => { + it('re-loads the app NodeId after the target is cleared and re-entered (no stuck "Loading")', async () => { let calls = 0 const countingLoad = async () => { calls += 1 return appNodeId } render( - {}} + {}} onSubmit={async () => {}} isIos={notIos} testAcpConnection={async () => ({ success: true })} @@ -358,9 +331,9 @@ describe('AddCustomAgentDialog — iroh', () => { await waitFor(() => expect(screen.getByTestId('iroh-pairing-panel').textContent).toContain(appNodeId)) expect(calls).toBe(1) - // Cancel resets the form (RESET → appNodeId back to idle, url cleared). + // Clearing the target disarms the loader (appNodeId back to idle). await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: '' } }) }) // Re-entering an iroh target must re-fire the load rather than strand on "Loading". @@ -372,7 +345,7 @@ describe('AddCustomAgentDialog — iroh', () => { }) it('submits with transport: iroh and the target stored as url', async () => { - const { onSubmit, onOpenChange } = renderIroh() + const { onSubmit, onClose } = renderIroh() fireEvent.change(screen.getByLabelText(/name/i), { target: { value: ' Laptop Bridge ' } }) await act(async () => { @@ -390,6 +363,6 @@ describe('AddCustomAgentDialog — iroh', () => { description: null, transport: 'iroh', }) - expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onClose).toHaveBeenCalledTimes(1) }) }) diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-form.tsx similarity index 53% rename from src/components/settings/agents/add-custom-agent-dialog.tsx rename to src/components/settings/agents/add-custom-agent-form.tsx index f2ce5b053..3b729b983 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-form.tsx @@ -5,15 +5,10 @@ import { useReducer } from 'react' import { Check, Loader2, X } from 'lucide-react' import { Button } from '@/components/ui/button' +import { FormFooter } from '@/components/ui/form-footer' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { - ResponsiveModalContentComposable, - ResponsiveModalDescription, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from '@/components/ui/responsive-modal' -import { Dialog } from '@/components/ui/dialog' +import { ResponsiveModalCancel } from '@/components/ui/responsive-modal' import { StatusCard } from '@/components/ui/status-card' import { testAcpConnection as defaultTestAcpConnection } from '@/acp' import { irohClientNodeId } from '@/acp/iroh/iroh-transport' @@ -28,15 +23,14 @@ export type AddCustomAgentPayload = { transport: CustomAgentTransport } -/** Async probe signature the dialog uses to test a remote agent endpoint. +/** Async probe signature the form uses to test a remote agent endpoint. * Production wires the real `testAcpConnection`; tests inject a stub. */ export type TestAcpConnectionFn = (opts: { url: string }) => Promise<{ success: true } | { success: false; error: string }> -type AddCustomAgentDialogProps = { - open: boolean - onOpenChange: (open: boolean) => void +type AddCustomAgentFormProps = { + onClose: () => void onSubmit: (payload: AddCustomAgentPayload) => Promise | void /** Test/DI override for the iOS guard. Production callers omit this. */ isIos?: () => boolean @@ -48,7 +42,7 @@ type AddCustomAgentDialogProps = { loadAppNodeId?: () => Promise } -type AgentDialogState = { +type AgentFormState = { name: string url: string description: string @@ -60,8 +54,8 @@ type AgentDialogState = { connectionError: string | null } -/** User-meaningful dialog events; the reducer maps each to a state delta. */ -type AgentDialogAction = +/** User-meaningful form events; the reducer maps each to a state delta. */ +type AgentFormAction = | { type: 'NAME_CHANGED'; value: string } | { type: 'URL_CHANGED'; value: string } | { type: 'DESCRIPTION_CHANGED'; value: string } @@ -70,9 +64,8 @@ type AgentDialogAction = | { type: 'CONNECTION_TEST_STARTED' } | { type: 'CONNECTION_TEST_SUCCEEDED' } | { type: 'CONNECTION_TEST_FAILED'; error: string } - | { type: 'RESET'; next: AgentDialogState } -const emptyState: AgentDialogState = { +const emptyState: AgentFormState = { name: '', url: '', description: '', @@ -83,7 +76,7 @@ const emptyState: AgentDialogState = { connectionError: null, } -const agentDialogReducer = (state: AgentDialogState, action: AgentDialogAction): AgentDialogState => { +const agentFormReducer = (state: AgentFormState, action: AgentFormAction): AgentFormState => { switch (action.type) { case 'NAME_CHANGED': return { ...state, name: action.value } @@ -103,20 +96,17 @@ const agentDialogReducer = (state: AgentDialogState, action: AgentDialogAction): return { ...state, isTestingConnection: false, connectionStatus: 'success' } case 'CONNECTION_TEST_FAILED': return { ...state, isTestingConnection: false, connectionStatus: 'error', connectionError: action.error } - case 'RESET': - return action.next } } -export const AddCustomAgentDialog = ({ - open, - onOpenChange, +export const AddCustomAgentForm = ({ + onClose, onSubmit, isIos, testAcpConnection = defaultTestAcpConnection, loadAppNodeId = irohClientNodeId, -}: AddCustomAgentDialogProps) => { - const [state, dispatch] = useReducer(agentDialogReducer, emptyState) +}: AddCustomAgentFormProps) => { + const [state, dispatch] = useReducer(agentFormReducer, emptyState) const trimmedName = state.name.trim() const trimmedUrl = state.url.trim() @@ -142,14 +132,6 @@ export const AddCustomAgentDialog = ({ // while an iroh target is selected) and re-arms when the target is re-entered. const appNodeId = useAppNodeId(isIroh, loadAppNodeId) - const handleOpenChange = (next: boolean) => { - if (!next) { - // On close, reset so a reopen without remount lands in a predictable shape. - dispatch({ type: 'RESET', next: emptyState }) - } - onOpenChange(next) - } - const handleTestConnection = async () => { dispatch({ type: 'CONNECTION_TEST_STARTED' }) const result = await testAcpConnection({ url: trimmedUrl }) @@ -176,126 +158,109 @@ export const AddCustomAgentDialog = ({ transport: validation.transport, }) } catch (error) { - // Keep the dialog open with the form intact so the user can retry — - // and say why nothing happened. + // Keep the form intact so the user can retry — and say why nothing happened. console.error('Failed to add custom agent', error) dispatch({ type: 'SUBMIT_FAILED', message: "Couldn't add the agent. Please try again." }) return } - dispatch({ type: 'RESET', next: emptyState }) - onOpenChange(false) + // The parent unmounts the form on close, so state resets by remount. + onClose() } + // Renders as plain panel content — the agents page hosts it inside the + // shared DetailPanelSurface (same aside idiom as the skills create form), + // which owns the "Add custom agent" header and close affordance. return ( - - - - Add custom agent - - Connect a remote agent that speaks the Agent Client Protocol. - - -
-
- - dispatch({ type: 'NAME_CHANGED', value: e.target.value })} - autoComplete="off" - /> -
-
- - dispatch({ type: 'URL_CHANGED', value: e.target.value })} - autoComplete="off" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - /> -

- A WebSocket endpoint, or paste an iroh ticket from your bridge for a peer-to-peer connection (a bare - NodeId works only if the peer is discoverable). -

-
- {isIroh && } -
- - dispatch({ type: 'DESCRIPTION_CHANGED', value: e.target.value })} - autoComplete="off" - /> -
- {canTestConnection && ( - - )} - {state.connectionStatus === 'success' && ( - - - Connection successful! - - } - description="Successfully connected to the agent." - className="border-green-200/50 dark:border-green-500/20" - /> - )} - {state.connectionStatus === 'error' && ( - - - Connection failed - - } - description={state.connectionError || 'Could not connect to the agent.'} - className="bg-red-50/50 dark:bg-red-500/10 border-red-200/50 dark:border-red-500/20" - /> - )} - {urlError && ( -

- {urlError} -

- )} +
+

Connect a remote agent that speaks the Agent Client Protocol.

+
+
+ + dispatch({ type: 'NAME_CHANGED', value: e.target.value })} + autoComplete="off" + />
-
- {state.submitError && ( -

- {state.submitError} -

- )} - - +
+ + dispatch({ type: 'URL_CHANGED', value: e.target.value })} + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +

+ A WebSocket endpoint, or paste an iroh ticket from your bridge for a peer-to-peer connection (a bare NodeId + works only if the peer is discoverable). +

+
+ {isIroh && } +
+ + dispatch({ type: 'DESCRIPTION_CHANGED', value: e.target.value })} + autoComplete="off" + />
- -
+ {canTestConnection && ( + + )} + {state.connectionStatus === 'success' && ( + } + title="Connection successful!" + description="Successfully connected to the agent." + /> + )} + {state.connectionStatus === 'error' && ( + } + title="Connection failed" + description={state.connectionError || 'Could not connect to the agent.'} + /> + )} + {urlError && ( +

+ {urlError} +

+ )} +
+ + {state.submitError && ( +

+ {state.submitError} +

+ )} + + +
+
) } diff --git a/src/components/settings/agents/agent-detail.test.tsx b/src/components/settings/agents/agent-detail.test.tsx index 819d9bc3d..edd72bb5e 100644 --- a/src/components/settings/agents/agent-detail.test.tsx +++ b/src/components/settings/agents/agent-detail.test.tsx @@ -140,12 +140,12 @@ describe('AgentDetail — custom agents', () => { await act(async () => { fireEvent.pointerDown(screen.getByRole('button', { name: 'More' }), { button: 0 }) }) - const removeItem = await waitForElement(() => screen.queryByRole('menuitem', { name: /remove agent/i })) + const removeItem = await waitForElement(() => screen.queryByRole('menuitem', { name: /delete agent/i })) await act(async () => { fireEvent.click(removeItem) await getClock().runAllAsync() }) - const confirmButton = await waitForElement(() => screen.queryByRole('button', { name: 'Remove agent' })) + const confirmButton = await waitForElement(() => screen.queryByRole('button', { name: 'Delete agent' })) await act(async () => { fireEvent.click(confirmButton) }) diff --git a/src/components/settings/agents/agent-detail.tsx b/src/components/settings/agents/agent-detail.tsx index a0fd85a8e..056eae271 100644 --- a/src/components/settings/agents/agent-detail.tsx +++ b/src/components/settings/agents/agent-detail.tsx @@ -5,7 +5,7 @@ import { toCompilableQuery } from '@powersync/drizzle-driver' import { useQuery } from '@powersync/tanstack-react-query' import dayjs from 'dayjs' -import { Loader2, MoreVertical, Trash2 } from 'lucide-react' +import { Loader2, Trash2 } from 'lucide-react' import { useState } from 'react' import { Link } from 'react-router' @@ -13,7 +13,7 @@ import '@/lib/dayjs' import { testAcpConnection as defaultTestAcpConnection } from '@/acp' import { iconForAgent } from '@/components/agent-icon' import { DetailDivider, DetailPanel, DetailSectionTitle } from '@/components/detail-panel' -import { AgentIconTile } from '@/components/settings/agents/agent-list-row' +import { IconTile } from '@/components/settings/icon-tile' import { EditableField, FieldLabel } from '@/components/settings/agents/editable-field' import { inferTransport, validateAgentUrl } from '@/components/settings/agents/validate-agent-url' import { @@ -25,8 +25,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { Button, mutedIconButtonClass } from '@/components/ui/button' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { DetailActionsMenu } from '@/components/settings/detail-actions-menu' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useDatabase } from '@/contexts' import { getAllMcpServers } from '@/dal' @@ -35,7 +36,7 @@ import { cn } from '@/lib/utils' import { useLibrarySkills } from '@/skills/use-skills' import type { Agent } from '@/types/acp' import { acpEndpointLabel, agentProvenanceLine } from './agent-provenance' -import type { TestAcpConnectionFn } from './add-custom-agent-dialog' +import type { TestAcpConnectionFn } from './add-custom-agent-form' /** On-demand probe result: the panel never polls on open — Status starts at * `idle` and reflects the last explicit "Test connection" run. `error` holds @@ -99,8 +100,8 @@ export const AgentDetail = ({ await onDelete() } catch (error) { // Keep the confirm dialog open so the failure is visible and retryable. - console.error('Failed to remove agent', error) - setRemoveError("Couldn't remove the agent. Please try again.") + console.error('Failed to delete agent', error) + setRemoveError("Couldn't delete the agent. Please try again.") return } setConfirmOpen(false) @@ -108,32 +109,25 @@ export const AgentDetail = ({ } const managementMenu = isEditable && ( - - - - - - setConfirmOpen(true)} className="cursor-pointer"> - - Remove agent - - - + + setConfirmOpen(true)} className="cursor-pointer"> + + Delete agent + + ) return ( + {/* The logo reads slightly smaller than the lucide glyphs at equal box size, so it gets a half-step bump. */} } title={agent.name} subtitle={agentProvenanceLine(agent)} @@ -157,7 +151,7 @@ export const AgentDetail = ({ > - Remove {agent.name}? + Delete {agent.name}? This removes the connection from Thunderbolt only. Nothing on the remote server is changed. @@ -170,7 +164,7 @@ export const AgentDetail = ({ Cancel @@ -217,7 +211,7 @@ const BuiltInBody = () => {
MCP servers {mcpServers.length} {mcpServers.length === 1 ? 'MCP server' : 'MCP servers'} @@ -226,7 +220,7 @@ const BuiltInBody = () => {
Integrations Manage integrations diff --git a/src/components/settings/agents/agent-list-row.tsx b/src/components/settings/agents/agent-list-row.tsx index e5e50c04a..f4c9dfb14 100644 --- a/src/components/settings/agents/agent-list-row.tsx +++ b/src/components/settings/agents/agent-list-row.tsx @@ -5,15 +5,8 @@ import { ChevronRight } from 'lucide-react' import type { ReactNode } from 'react' -import { Card } from '@/components/ui/card' -import { cn } from '@/lib/utils' - -/** The square icon box that leads every agent list row and detail header. */ -export const AgentIconTile = ({ children }: { children: ReactNode }) => ( -
- {children} -
-) +import { IconTile } from '@/components/settings/icon-tile' +import { SettingsSelectableRow } from '@/components/settings/settings-list' type AgentListRowProps = { icon: ReactNode @@ -50,25 +43,22 @@ export const AgentListRow = ({ subtitleTestId, chevronTestId, }: AgentListRowProps) => ( - - - +
+ {subtitle}} + leading={{icon}} + isSelected={isSelected} + isDimmed={isDimmed} + onSelect={onOpen} + ariaLabel={ariaLabel} + trailingIcon={ +
) diff --git a/src/components/settings/agents/agent-list.tsx b/src/components/settings/agents/agent-list.tsx index a06fedf81..afd92a2fa 100644 --- a/src/components/settings/agents/agent-list.tsx +++ b/src/components/settings/agents/agent-list.tsx @@ -3,14 +3,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { Agent } from '@/types/acp' +import { SettingsSectionLabel } from '@/components/settings/settings-list' import { AgentRow } from './agent-row' -const SectionLabel = ({ children }: { children: string }) => ( -

- {children} -

-) - type AgentListProps = { /** Unified list from `useAllAgents` (built-in first, then system, then customs). */ agents: Agent[] @@ -49,11 +44,11 @@ export const AgentList = ({ agents, selectedId, onOpenAgent }: AgentListProps) = return (
- Your agents + Your agents
{rows(yourAgents)}
- System agents + System agents
{rows(systemAgents)}
diff --git a/src/components/settings/agents/thunderbolt-cli.tsx b/src/components/settings/agents/thunderbolt-cli.tsx index 8d904e25f..420f244cb 100644 --- a/src/components/settings/agents/thunderbolt-cli.tsx +++ b/src/components/settings/agents/thunderbolt-cli.tsx @@ -17,7 +17,8 @@ import { installThunderboltCli, } from '@/lib/cli-install' import { getPlatform, isTauri } from '@/lib/platform' -import { AgentIconTile, AgentListRow } from './agent-list-row' +import { AgentListRow } from './agent-list-row' +import { IconTile } from '@/components/settings/icon-tile' /** Shell one-liner to build the CLI from source when no prebuilt binary applies. */ const manualBuildCommand = 'cd cli && bun install && bun run build && ./install.sh' @@ -122,9 +123,9 @@ export const ThunderboltCliDetail = ({ return ( + } title="Thunderbolt CLI" subtitle={cliProvenanceLine} diff --git a/src/components/settings/detail-actions-menu.tsx b/src/components/settings/detail-actions-menu.tsx new file mode 100644 index 000000000..d0e792a2a --- /dev/null +++ b/src/components/settings/detail-actions-menu.tsx @@ -0,0 +1,37 @@ +/* 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 { MoreVertical, SquarePen, Trash2 } from 'lucide-react' +import type { ReactNode } from 'react' + +import { Button, mutedIconButtonClass } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' + +/** The detail panels' shared ⋯ header menu; children are its menu items. */ +export const DetailActionsMenu = ({ children }: { children: ReactNode }) => ( + + + + + + {children} + + +) + +/** The standard Edit + Delete item pair for `DetailActionsMenu`. */ +export const DetailEditDeleteMenuItems = ({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) => ( + <> + + + Edit + + + + Delete + + +) diff --git a/src/components/settings/detail-field.tsx b/src/components/settings/detail-field.tsx new file mode 100644 index 000000000..673ab2ad7 --- /dev/null +++ b/src/components/settings/detail-field.tsx @@ -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 { ComponentProps, ReactNode } from 'react' + +import { DetailSectionTitle } from '@/components/detail-panel' +import { cn } from '@/lib/utils' + +type DetailFieldProps = ComponentProps<'section'> & { + label: ReactNode +} + +/** A labeled value block for detail panels, using the shared section-title style. */ +export const DetailField = ({ label, className, children, ...props }: DetailFieldProps) => ( +
+ {label} + {children} +
+) diff --git a/src/components/settings/edit-delete-context-menu.tsx b/src/components/settings/edit-delete-context-menu.tsx new file mode 100644 index 000000000..fa378c8f0 --- /dev/null +++ b/src/components/settings/edit-delete-context-menu.tsx @@ -0,0 +1,21 @@ +/* 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 { SquarePen, Trash2 } from 'lucide-react' + +import { ContextMenuContent, ContextMenuItem } from '@/components/ui/context-menu' + +/** Shared right-click Edit/Delete menu for settings list rows. */ +export const EditDeleteContextMenuContent = ({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) => ( + + + + Edit + + + + Delete + + +) diff --git a/src/components/settings/icon-tile.tsx b/src/components/settings/icon-tile.tsx new file mode 100644 index 000000000..8665b080a --- /dev/null +++ b/src/components/settings/icon-tile.tsx @@ -0,0 +1,19 @@ +/* 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 { ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +/** The square icon box that leads settings list rows and detail headers. */ +export const IconTile = ({ className, children }: { className?: string; children: ReactNode }) => ( +
+ {children} +
+) diff --git a/src/components/settings/settings-empty-state.tsx b/src/components/settings/settings-empty-state.tsx new file mode 100644 index 000000000..a12d528d5 --- /dev/null +++ b/src/components/settings/settings-empty-state.tsx @@ -0,0 +1,47 @@ +/* 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 { ComponentProps, ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +type SettingsEmptyStateProps = ComponentProps<'div'> & { + icon?: ReactNode + title?: ReactNode + description: ReactNode + action?: ReactNode +} + +/** Dashed-border empty-state card for a settings list with no items yet. */ +export const SettingsEmptyState = ({ + icon, + title, + description, + action, + className, + ...props +}: SettingsEmptyStateProps) => ( +
+ {icon} +
+ {title &&

{title}

} +
{description}
+
+ {action} +
+) + +/** Centered "no matches" note shown when a settings-page search filters everything out. */ +export const SettingsNoResults = ({ className, ...props }: ComponentProps<'p'>) => ( +

+) diff --git a/src/components/settings/settings-list.stories.tsx b/src/components/settings/settings-list.stories.tsx new file mode 100644 index 000000000..6633f6b25 --- /dev/null +++ b/src/components/settings/settings-list.stories.tsx @@ -0,0 +1,98 @@ +/* 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 { Meta, StoryObj } from '@storybook/react-vite' +import { Bot, Plus, Zap } from 'lucide-react' +import { fn } from 'storybook/test' + +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { SettingsEmptyState, SettingsNoResults } from './settings-empty-state' +import { SettingsListBody, SettingsListPane, SettingsSectionLabel, SettingsSelectableRow } from './settings-list' + +const meta = { + title: 'Settings/ListPatterns', + component: SettingsSelectableRow, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +

+ +
+ ), + ], + args: { + title: 'Research assistant', + ariaLabel: 'Open Research assistant', + onSelect: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const RowStates: Story = { + render: () => ( + + Agents + + } + isSelected + trailing={} + onSelect={fn()} + ariaLabel="Open Research assistant" + /> + } + trailing={} + onSelect={fn()} + ariaLabel="Open Writing assistant" + /> + } + isDimmed + onSelect={fn()} + ariaLabel="Open Unavailable assistant" + /> + + + ), +} + +export const EmptyState: Story = { + render: () => ( + + Connections + } + title="No connections yet" + description="Connect a service to make it available to your agents." + action={ + + } + /> + + ), +} + +export const NoSearchResults: Story = { + render: () => ( + + Connections + No matching connections. + + ), +} diff --git a/src/components/settings/settings-list.test.tsx b/src/components/settings/settings-list.test.tsx new file mode 100644 index 000000000..78474c763 --- /dev/null +++ b/src/components/settings/settings-list.test.tsx @@ -0,0 +1,32 @@ +/* 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 '@testing-library/jest-dom' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { SettingsSelectableRow } from './settings-list' + +afterEach(cleanup) + +describe('SettingsSelectableRow', () => { + it('keeps trailing controls outside the row selection button', () => { + const onSelect = mock(() => {}) + const onTrailing = mock(() => {}) + render( + Toggle} + />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Toggle' })) + expect(onTrailing).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Open Example' })) + expect(onSelect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/settings/settings-list.tsx b/src/components/settings/settings-list.tsx new file mode 100644 index 000000000..2f99bcbd2 --- /dev/null +++ b/src/components/settings/settings-list.tsx @@ -0,0 +1,94 @@ +/* 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 { ComponentProps, ReactNode } from 'react' + +import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' + +/** The scrollable list column of a settings page: centered, width-capped shell. */ +export const SettingsListPane = ({ className, ...props }: ComponentProps<'section'>) => ( +
+) + +/** The scrolling region inside `SettingsListPane` (header stays pinned above it). */ +export const SettingsListBody = ({ className, ...props }: ComponentProps<'div'>) => ( +
+) + +/** Uppercase section heading between groups of settings rows. */ +export const SettingsSectionLabel = ({ className, ...props }: ComponentProps<'h2'>) => ( +

+) + +type SettingsSelectableRowProps = Omit, 'onSelect' | 'title'> & { + title: ReactNode + subtitle?: ReactNode + leading?: ReactNode + /** Interactive controls (e.g. a switch) in their own lane outside the row's tap target. */ + trailing?: ReactNode + /** Decorative affordance (e.g. a chevron) rendered inside the tap target so clicking it selects the row. */ + trailingIcon?: ReactNode + isSelected?: boolean + isDimmed?: boolean + onSelect: () => void + ariaLabel: string +} + +/** Shared settings row with a full-height trailing-control lane. */ +export const SettingsSelectableRow = ({ + title, + subtitle, + leading, + trailing, + trailingIcon, + isSelected = false, + isDimmed = false, + onSelect, + ariaLabel, + className, + ...props +}: SettingsSelectableRowProps) => ( + + + {trailing &&
{trailing}
} +
+) diff --git a/src/components/sidebar-footer.test.tsx b/src/components/sidebar-footer.test.tsx index 4636a4cd2..0638f7193 100644 --- a/src/components/sidebar-footer.test.tsx +++ b/src/components/sidebar-footer.test.tsx @@ -53,12 +53,15 @@ const renderWithProviders = (authClient: AuthClient) => { describe('SidebarFooter', () => { describe('anonymous users', () => { - it('shows the Sign In affordance (treats anonymous as logged-out)', () => { + it('shows an icon-only Sign In affordance (treats anonymous as logged-out)', () => { const authClient = createMockAuthClient({ session: { user: { id: 'anon-1', email: 'temp@anon.com', name: 'Anonymous', isAnonymous: true } }, }) renderWithProviders(authClient) - expect(screen.getByText('Sign in')).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Sign in' }) + expect(button).toBeInTheDocument() + expect(button.textContent).toBe('') + expect(button.className).toContain('w-[var(--touch-height-lg)]') }) it('does NOT leak the synthetic anonymous email into the UI', () => { @@ -85,7 +88,7 @@ describe('SidebarFooter', () => { }) renderWithProviders(authClient) expect(screen.getByText('Real User')).toBeInTheDocument() - expect(screen.queryByText('Sign in')).toBeNull() + expect(screen.queryByRole('button', { name: 'Sign in' })).toBeNull() }) it('falls back to the email when the account has no display name', () => { @@ -99,22 +102,24 @@ describe('SidebarFooter', () => { it('collapses to an icon-only perfect circle when the display label is blank', () => { // OTP sign-ups get name '' (not null); the old pill kept its horizontal // padding and rendered a 48×32 oval around the cloud icon. Blank labels - // must produce the square rounded-full control that matches ThemeToggle. + // must produce a square rounded-full icon-only control. const authClient = createMockAuthClient({ session: { user: { id: 'real-3', email: '', name: '', isAnonymous: false } }, }) renderWithProviders(authClient) const button = screen.getByRole('button', { name: 'Account menu' }) - expect(button.className).toContain('size-[var(--touch-height-default)]') + expect(button.className).toContain('h-[var(--touch-height-lg)]') + expect(button.className).toContain('w-[var(--touch-height-lg)]') expect(button.querySelector('span')).toBeNull() }) }) describe('fully logged-out users', () => { - it('shows the Sign In affordance', () => { + it('shows the icon-only Sign In affordance', () => { const authClient = createMockAuthClient({ session: null }) renderWithProviders(authClient) - expect(screen.getByText('Sign in')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument() + expect(screen.queryByText('Sign in')).toBeNull() }) }) diff --git a/src/components/sidebar-footer.tsx b/src/components/sidebar-footer.tsx index c07f83bc5..25a8f9b73 100644 --- a/src/components/sidebar-footer.tsx +++ b/src/components/sidebar-footer.tsx @@ -25,7 +25,6 @@ import type { User } from '@shared/types/auth' import { LogoutModal } from '@/components/logout-modal' import { BrandGradientIcon } from '@/components/ui/brand-gradient-icon' import { SyncSetupModal } from '@/components/sync-setup/sync-setup-modal' -import { ThemeToggle } from '@/components/theme-toggle' import { Button } from '@/components/ui/button' import { MobileBlurBackdrop } from '@/components/ui/mobile-blur-backdrop' import { NavLink } from '@/components/ui/nav-link' @@ -33,6 +32,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { SidebarFooter as ShadcnSidebarFooter, useSidebar } from '@/components/ui/sidebar' import { Switch } from '@/components/ui/switch' import { useAuth, useSignInModal } from '@/contexts' +import { useIsNativeMobile } from '@/hooks/use-mobile' import { usePowerSyncStatus, type PowerSyncConnectionStatus } from '@/hooks/use-powersync-status' import { useSyncEnabledToggle } from '@/hooks/use-sync-enabled-toggle' import { reconnectSync } from '@/db/powersync/sync-state' @@ -48,10 +48,7 @@ const openLink = (url: string) => window.open(url, '_blank', 'noopener,noreferre type SidebarFooterProps = { className?: string - /** Chats/Settings pill. On the mobile overlay it renders here, at the right - * of the footer row, so section switching sits in thumb reach; desktop - * ignores it (the pill lives in the sidebar header there). */ - navToggle?: ReactNode + hasContentBelow?: boolean } type AccountMenuItem = { @@ -98,7 +95,7 @@ const GradientCloud = ({ className }: { className?: string }) => ( /** * Single cloud glyph carrying both auth and sync state: - * - logged out → muted outline cloud (paired with a "Sign in" label) + * - logged out → muted outline cloud * - logged in, sync off → muted CloudOff ("connected account, not syncing") * - syncing, connecting → spinner * - syncing, offline → amber CloudAlert ("will sync when back online") @@ -153,10 +150,11 @@ export const syncStatusText = ( return 'Connected' } -export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { +export const SidebarFooter = ({ className, hasContentBelow = false }: SidebarFooterProps) => { const authClient = useAuth() const navigate = useNavigate() const { isMobile, setOpenMobile, state } = useSidebar() + const isNativeMobile = useIsNativeMobile() const { openSignInModal } = useSignInModal() const [logoutModalOpen, setLogoutModalOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false) @@ -214,16 +212,14 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { const stateIcon = - // Accounts without a name/email label collapse to an icon-only control; it - // must be a perfect circle matching the theme toggle beside it. + // Accounts without a name/email label collapse to an icon-only perfect circle. const accountLabel = (displayName ?? displayEmail ?? '').trim() - // Same height as the theme toggle beside it; full-radius, hugging its - // content on the left edge of the footer. + // Full-radius and sized to hug its content on the left edge of the footer. const pillClassName = (hasLabel: boolean) => cn( - 'flex h-[var(--touch-height-default)] max-w-full min-w-0 cursor-pointer items-center rounded-full', - hasLabel ? 'w-fit gap-2 px-3' : 'size-[var(--touch-height-default)] justify-center', + 'flex h-[var(--touch-height-lg)] max-w-full min-w-0 cursor-pointer items-center rounded-full md:h-[var(--touch-height-default)]', + hasLabel ? 'w-fit gap-2 px-3' : 'w-[var(--touch-height-lg)] justify-center md:w-[var(--touch-height-default)]', 'text-[length:var(--font-size-body)] transition-colors outline-none', 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', menuOpen && 'bg-sidebar-accent text-sidebar-accent-foreground', @@ -240,9 +236,8 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { } if (!user) { return ( - ) } @@ -260,8 +255,6 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { ) } - // Collapsed desktop rail: the theme toggle stacks above the account/sync - // button so both stay reachable at icon-rail width. const collapsedButtonClass = 'flex size-[var(--touch-height-default)] cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-sidebar-accent' const collapsedControl = !user ? ( @@ -287,12 +280,17 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { return ( - + {isDesktopCollapsed ? ( -
- {/* Dev-only quick toggle; users switch themes in Preferences → - User Experience. */} - {import.meta.env.DEV && } +
{isPending ? (
@@ -301,29 +299,22 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { collapsedControl )}
- ) : ( + ) : isMobile ? (
-
{renderAccountControl()}
-
- {/* Dev-only quick toggle; users switch themes in Preferences → - User Experience. */} - {import.meta.env.DEV && } - {isMobile && navToggle} - {/* Mobile-only: desktop's New Chat list item covers this. Brand - gradient, matching the primary Button variant. */} - {isMobile && ( - - )} -
+
{renderAccountControl()}
+
+ ) : ( +
{renderAccountControl()}
)} diff --git a/src/components/slide-in-panel.tsx b/src/components/slide-in-panel.tsx index 8b9b5af1c..e856acc6c 100644 --- a/src/components/slide-in-panel.tsx +++ b/src/components/slide-in-panel.tsx @@ -4,6 +4,8 @@ import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + // Linear's spring curve — fast start, smooth tail, no overshoot. const slideEasing = 'cubic-bezier(0.32, 0.72, 0, 1)' @@ -18,12 +20,26 @@ const slideEasing = 'cubic-bezier(0.32, 0.72, 0, 1)' * the inner content div reuses it as a stable width while the outer collapses * to 0, and a percentage would resolve against the collapsed parent. */ -export const SlideInPanel = ({ open, width, children }: { open: boolean; width: string; children: ReactNode }) => ( +export const SlideInPanel = ({ + open, + width, + className, + children, +}: { + open: boolean + width: string + className?: string + children: ReactNode +}) => (