From 74ed4efeadb0d0dcb53d3cf393b1aae1f116b3dc Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 3 Oct 2026 14:38:17 -0500 Subject: [PATCH] Add Storybook coverage for connection pool integration Mount production selectors, composer controls, usage waits, run evidence, retry details, and experimental settings in 19 fixture stories. Share the production retry card and router setting so their previews stay current. Synchronize the public agent runtime binding type and guard direct-account repair against router bindings. Verified UI and server typechecks, 110 targeted UI tests, token gates, the Storybook build, all 19 browser previews, and selector/adoption interactions. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTION-ROUTERS.md | 15 +++ packages/shared/src/types/agent.ts | 2 +- ui/src/components/RunRetryDetails.tsx | 20 ++++ .../ConnectionIntentInteractionBody.tsx | 2 +- ui/src/pages/AgentDetail.tsx | 28 +---- ui/src/pages/InstanceExperimentalSettings.tsx | 22 +++- .../stories/connection-pools.stories.tsx | 101 ++++++++++++++++++ 7 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 ui/src/components/RunRetryDetails.tsx create mode 100644 ui/storybook/stories/connection-pools.stories.tsx diff --git a/doc/connections/AI-CONNECTION-ROUTERS.md b/doc/connections/AI-CONNECTION-ROUTERS.md index 645cc9544d5..078bd6f7329 100644 --- a/doc/connections/AI-CONNECTION-ROUTERS.md +++ b/doc/connections/AI-CONNECTION-ROUTERS.md @@ -55,3 +55,18 @@ recovery context. These records stay in the instance database. Tests: `cd server && pnpm exec vitest run src/__tests__/ai-connection-router.test.ts` plus the existing AI connection, retry accounting, run-dispatch and UI suites. + +## UI review in Storybook + +Run `pnpm --filter @paperclipai/ui storybook` from Core, then open +**AI Connections / Connection pools**. The 19 stories use production components +for pool selection, legacy-session adoption, unavailable/read-only selections, +mixed-provider composer models and effort, mobile composer settings, usage waits, +run selections and override notes, scheduled retries, and the experimental flag. +`AllCoreSurfaces` provides an overview; individual stories expose the expanded +menus and adoption dialog. Run `pnpm --filter @paperclipai/ui build-storybook` +to build the preview. + +Pool configuration stories belong to the private Cloud plugin's own Storybook +in `extensions/plugin-connection-pool/storybook/`. Both previews use fictional +accounts; they do not call live providers or mutate a Paperclip instance. diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 443e61f217e..2cbde14d9c6 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -25,7 +25,7 @@ export interface AgentPermissions extends Record { } export type AgentRuntimeConfig = Record & { - aiConnection?: import("../ai-connections.js").AiConnectionBinding; + aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding; }; export type AgentInstructionsBundleMode = "managed" | "external"; diff --git a/ui/src/components/RunRetryDetails.tsx b/ui/src/components/RunRetryDetails.tsx new file mode 100644 index 00000000000..5e92c8eba7c --- /dev/null +++ b/ui/src/components/RunRetryDetails.tsx @@ -0,0 +1,20 @@ +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; +import { describeRunRetryState } from "@/lib/runRetryState"; + +/** Shared with the run page so Storybook exercises the production retry copy. */ +export function RunRetryDetails({ run, agentRouteId }: { + run: Parameters[0]; + agentRouteId: string; +}) { + const retry = describeRunRetryState(run); + if (!retry) return null; + return
+
+ {retry.badgeLabel} + {retry.retryOfRunId && {retry.retryOfRunId.slice(0, 8)}} +
+ {retry.detail &&

{retry.detail}

} + {retry.secondary &&

{retry.secondary}

} +
; +} diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 3a7f50c9dbd..ef4d80ddf00 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -198,7 +198,7 @@ export function ConnectionIntentInteractionBody({ } else { const agent = await agentsApi.get(interaction.payload.requestingAgentId, interaction.companyId); const current = agent.runtimeConfig.aiConnection; - if (!current || current.mode === "responsible_user" || current.connectionId !== previous.id || current.grantId !== previous.grantId) { + if (!current || current.mode === "responsible_user" || current.mode === "router" || current.connectionId !== previous.id || current.grantId !== previous.grantId) { throw new Error("The agent’s AI connection changed. Reload the task and try again."); } if (result.generation !== setupGeneration.current) return; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index ebb1159eeb6..03741a3ae1a 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -64,7 +64,7 @@ import { buildSameOriginWebSocketUrl } from "../lib/websocket-url"; import { tryCreateWebSocket } from "../lib/websocket"; import { formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { cn } from "../lib/utils"; -import { describeRunRetryState } from "../lib/runRetryState"; +import { RunRetryDetails } from "../components/RunRetryDetails"; import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { PageTabBar } from "../components/PageTabBar"; @@ -3569,7 +3569,6 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter; const sessionId = run.sessionIdAfter || run.sessionIdBefore; const hasNonZeroExit = run.exitCode !== null && run.exitCode !== 0; - const retryState = describeRunRetryState(run); return (
@@ -3800,30 +3799,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } {run.signal && (signal: {run.signal})}
)} - {retryState && ( -
-
- - {retryState.badgeLabel} - - {retryState.retryOfRunId ? ( - - {retryState.retryOfRunId.slice(0, 8)} - - ) : null} -
- {retryState.detail ?

{retryState.detail}

: null} - {retryState.secondary ?

{retryState.secondary}

: null} -
- )} + {/* Right column: metrics */} diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 7f4018c5bde..a6a587959ff 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -118,6 +118,22 @@ function ExperimentalToggleCard({ ); } +/** The actual router opt-in card, also mounted directly in Storybook. */ +export function AiConnectionRoutersSetting({ checked, onCheckedChange, disabled = false, managed }: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + managed?: ManagedSettingMetadata; +}) { + return ; +} + export function InstanceExperimentalSettings() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); @@ -305,15 +321,11 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle agent chat experimental setting" /> - toggleMutation.mutate({ enableAiConnectionRouters: checked })} disabled={toggleMutation.isPending} - settingKey="enableAiConnectionRouters" managed={managedKeys.enableAiConnectionRouters} - ariaLabel="Toggle AI connection routers experimental setting" /> ({ + id: m.binding.connectionId!, grantId: m.binding.grantId!, companyId, provider: m.binding.provider, method: m.binding.method!, name: index === 0 ? "My OpenAI subscription" : "My Claude subscription", ownership: "personal", ownerUserId: "user-board", isDefault: true, status: "connected", +})) }; +const models = [{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol · Codex" }, { id: "claude-sonnet-5", label: "Claude Sonnet 5 · Claude ACP" }]; +const agent: Agent = { ...storybookAgents.find(a => a.id === agentId)!, adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runtimeConfig: { aiConnection: { mode: "router", connectionId: poolId } } }; +type Scenario = "overview" | "selector" | "legacy" | "adoption" | "unavailable" | "read-only" | "models" | "effort" | "mobile" | "claude-model" | "quota" | "checking" | "run-codex" | "run-claude" | "fallback" | "retry" | "flag-off" | "flag-on" | "flag-managed"; + +function PoolComposer({ scenario }: { scenario: Scenario }) { + const [settings, setSettings] = useState({ model: scenario === "claude-model" ? "claude-sonnet-5" : "gpt-5.6-sol", effort: null, fast: false }); + return
+

Choose settings for the next turn. The task keeps its account and harness.

+ setSettings({ model: null, effort: null, fast: false })} + modelOptionsOverride={models} mobile={scenario === "mobile"} initialOpen={scenario !== "overview"} + initialView={scenario === "models" || scenario === "mobile" ? "models" : "settings"} /> +
; +} +function PoolWait({ checking = false }: { checking?: boolean }) { + const [issue] = useState(() => ({ ...storybookIssues[0]!, status: "in_progress", executionState: null, + scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "ai_connection_pool_wait", scheduledRetryAt: new Date(Date.now() + 60_000).toISOString(), scheduledRetryAttempt: 1 } as Issue["scheduledRetry"], + })); + return
undefined} checkingNow={checking} /> undefined} checkingNow={checking} />
; +} +function PoolRun({ scenario }: { scenario: Scenario }) { + const claude = scenario === "run-claude", member = pool.members[claude ? 1 : 0]!; + const selection: AiConnectionRouterSelection = { poolId, memberId: member.id, binding: member.binding, + runtimeConfig: { ...member.profile, ...(claude ? {} : { modelReasoningEffort: "high" }) }, + notes: scenario === "fallback" ? ["Model override is unavailable for this member; using its default.", "Effort override is unavailable for the effective model; using its default."] : [], + }; + return
+ {scenario === "retry" && }
; +} +function PoolSurfaces({ scenario = "overview" }: { scenario?: Scenario }) { + const [client] = useState(() => { + const query = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + query.setQueryData(["ai-connections", companyId, agentId], { ...accounts, pools: scenario === "unavailable" ? [] : accounts.pools }); + query.setQueryData(queryKeys.instance.settings, {}); + return query; + }); + const [binding, setBinding] = useState(scenario === "adoption" ? undefined : { mode: "router", connectionId: poolId }); + const [enabled, setEnabled] = useState(scenario === "flag-on"); + const overview = scenario === "overview"; + return
+

Experimental connection pools

Core integration · production components with fixture accounts. Configure the pool itself in Cloud’s plugin Storybook.

+ {(overview || scenario.startsWith("flag")) && } + {(overview || ["selector", "legacy", "adoption", "unavailable", "read-only"].includes(scenario)) &&

Agent connection

} + {(overview || ["models", "effort", "mobile", "claude-model"].includes(scenario)) &&

Composer model and effort

} + {(overview || scenario === "quota" || scenario === "checking") &&

Usage recovery

} + {(overview || ["run-codex", "run-claude", "fallback", "retry"].includes(scenario)) &&

Run account and runtime

} +
; +} +const meta = { title: "AI Connections/Connection pools", component: PoolSurfaces, parameters: { layout: "fullscreen", docs: { description: { component: "All user-facing Core additions for the private connection-pool plugin. These stories mount the production selector, composer, quota banners, run details/retry metadata, and instance setting. Accounts are fixtures; no live provider calls or instance mutations occur." } } }, render: args => } satisfies Meta; +export default meta; +type Story = StoryObj; +export const AllCoreSurfaces: Story = { args: { scenario: "overview" } }; +export const ConnectionSelector: Story = { args: { scenario: "selector" }, play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), ""); await expect(c.getByText("Responsible user’s connection", { exact: false })).toBeVisible(); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), poolId); await expect(c.getByText(/New tasks rotate/)).toBeVisible(); } }; +export const CompatibleLegacyHarness: Story = { args: { scenario: "legacy" } }; +export const ExistingSessionAdoption: Story = { args: { scenario: "adoption" }, play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.click(c.getByRole("button", { name: "Choose a managed connection" })); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), poolId); await expect(await within(canvasElement.ownerDocument.body).findByRole("dialog")).toHaveTextContent("Task-pinned connection pool"); } }; +export const PoolUnavailable: Story = { args: { scenario: "unavailable" } }; +export const ReadOnlySelection: Story = { args: { scenario: "read-only" } }; +export const ComposerModelsFromBothProviders: Story = { args: { scenario: "models" } }; +export const ComposerCodexEffort: Story = { args: { scenario: "effort" } }; +export const ComposerClaudeModelOnly: Story = { args: { scenario: "claude-model" } }; +export const MobileComposer: Story = { args: { scenario: "mobile" } }; +export const PoolExhaustedBannerAndStrip: Story = { args: { scenario: "quota" } }; +export const UsageRecheckInProgress: Story = { args: { scenario: "checking" } }; +export const PinnedCodexRun: Story = { args: { scenario: "run-codex" } }; +export const PinnedClaudeRun: Story = { args: { scenario: "run-claude" } }; +export const IndependentOverrideFallbackNotes: Story = { args: { scenario: "fallback" } }; +export const ScheduledQuotaRetry: Story = { args: { scenario: "retry" } }; +export const ExperimentalOffByDefault: Story = { args: { scenario: "flag-off" } }; +export const ExperimentalEnabled: Story = { args: { scenario: "flag-on" } }; +export const ExperimentalManagedByCloud: Story = { args: { scenario: "flag-managed" } };