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" } };