diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 58bf3c5e9eb..facfb020010 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -513,6 +513,9 @@ connection work or enforce a real tenant boundary. Follow these rules: controls and per-action Test dialog. Do not build a parallel permissions list or provider-specific testing page. Discovery errors stay inline on Connect; an empty returned catalog belongs to the ordinary saved-connection state. +- The per-action Test dialog shows structured MCP results as readable fields, + tables, or cards when possible. It keeps the full raw response available for + diagnosis and opens it by default when the result cannot be rendered safely. - Enable every discovered tool automatically. Put later Allowed / Ask first / Off controls in management, separate from connection access. Reconnect and refresh retain existing restrictions; new tools are Allowed under the existing access rules. diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index bc721c5c5e4..48b83038891 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -177,8 +177,12 @@ function buildAgentIconMask(iconName: string | null): string | null { if (cached) return cached; const Icon = getAgentIcon(iconName); - const iconNode = resolveLucideIconNode(Icon); - if (!Array.isArray(iconNode) || iconNode.length === 0) return null; + // Lucide's component internals differ between browser and server builds. + // Keep the agent marker visible when its private iconNode is unavailable. + const iconNode = resolveLucideIconNode(Icon) ?? [ + ["rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }], + ["path", { d: "M8 10h.01M16 10h.01M8 15h8" }], + ]; const body = iconNode.map(([tag, attrs]) => { const attrString = Object.entries(attrs) diff --git a/ui/src/pages/apps/agent-selector-contract.test.ts b/ui/src/pages/apps/agent-selector-contract.test.ts index de0e9f64b66..f55ad1983a0 100644 --- a/ui/src/pages/apps/agent-selector-contract.test.ts +++ b/ui/src/pages/apps/agent-selector-contract.test.ts @@ -10,7 +10,7 @@ describe("Apps agent selector contract", () => { const appConnect = source("./AppsConnect.tsx"); const connectionSetupFlow = source("../../features/connections/ConnectionSetupFlow.tsx"); const permissions = source("./app-detail/PermissionsPanel.tsx"); - const tester = source("./app-detail/TestPanel.tsx"); + const tester = source("./app-detail/ActionTestDialog.tsx"); const profiles = source("../tools/ProfilesTab.tsx"); const audit = source("../tools/AuditTab.tsx"); diff --git a/ui/src/pages/apps/app-detail/ActionTestDialog.test.tsx b/ui/src/pages/apps/app-detail/ActionTestDialog.test.tsx new file mode 100644 index 00000000000..5be7ec1e3b5 --- /dev/null +++ b/ui/src/pages/apps/app-detail/ActionTestDialog.test.tsx @@ -0,0 +1,295 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ToolCatalogEntry } from "@paperclipai/shared"; +import { ActionTestDialog, errorHints } from "./ActionTestDialog"; + +const listTestAgentsMock = vi.hoisted(() => vi.fn()); +const getTestAgentAccessMock = vi.hoisted(() => vi.fn()); +const runTestCallMock = vi.hoisted(() => vi.fn()); +const getTestCallStatusMock = vi.hoisted(() => vi.fn()); +const declineActionRequestMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/tools", () => ({ + toolsApi: { + listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId), + getTestAgentAccess: (connectionId: string, agentId: string) => getTestAgentAccessMock(connectionId, agentId), + runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input), + getTestCallStatus: (connectionId: string, requestId: string) => getTestCallStatusMock(connectionId, requestId), + declineActionRequest: (companyId: string, requestId: string) => declineActionRequestMock(companyId, requestId), + }, +})); +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => {children}, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const entry = { + id: "asana-list-projects", + companyId: "company-1", + applicationId: "app-asana", + connectionId: "conn-1", + entryKind: "tool", + toolName: "list_projects", + title: "List projects", + description: "Find Asana projects.", + inputSchema: { type: "object", properties: {} }, + outputSchema: null, + annotations: null, + riskLevel: "read", + isReadOnly: true, + isWrite: false, + isDestructive: false, + status: "active", + addedAt: new Date("2026-09-01T00:00:00Z"), + version: null, + schemaHash: null, + firstSeenAt: new Date("2026-09-01T00:00:00Z"), + lastSeenAt: new Date("2026-10-01T00:00:00Z"), + reviewedAt: null, + reviewedByAgentId: null, + reviewedByUserId: null, + createdAt: new Date("2026-09-01T00:00:00Z"), + updatedAt: new Date("2026-10-01T00:00:00Z"), +} as ToolCatalogEntry; + +const askFirstEntry = { + ...entry, + id: "asana-create-task", + toolName: "create_task", + title: "Create task", + riskLevel: "write", + isReadOnly: false, + isWrite: true, +} as ToolCatalogEntry; + +let container: HTMLDivElement; +let root: ReturnType; +let client: QueryClient; + +async function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { result = callback(); }); + await result; +} + +async function flushReact() { + for (let i = 0; i < 4; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + } +} + +async function renderDialog(testEntry = entry) { + await act(() => root.render( + + undefined} /> + , + )); + await flushReact(); +} + +async function openAndRun(testEntry = entry) { + await renderDialog(testEntry); + const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === "Run"); + expect(button).toBeTruthy(); + await act(() => button!.click()); + await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 260)); }); + await flushReact(); +} + +function allowAskFirst() { + getTestAgentAccessMock.mockResolvedValue({ + access: { + connectionId: "conn-1", toolCount: 1, allowedCount: 0, askFirstCount: 1, offCount: 0, + lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null, + tools: [{ toolName: "create_task", gatewayToolName: "asana__create_task", displayName: "Create task", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] }], + }, + }); + runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "ask-invocation", actionRequestId: "request-1" }); +} + +function requestStatus(phase: "waiting" | "running" | "done" | "denied") { + return { + actionRequestId: "request-1", invocationId: "ask-invocation", phase, + parameters: {}, requestedAt: "2026-10-01T00:00:00.000Z", + resolvedAt: phase === "waiting" ? null : "2026-10-01T00:00:05.000Z", + durationMs: phase === "done" ? 1200 : null, + }; +} + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + listTestAgentsMock.mockReset().mockResolvedValue({ + agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }], + }); + getTestAgentAccessMock.mockReset().mockResolvedValue({ + access: { + connectionId: "conn-1", toolCount: 1, allowedCount: 1, askFirstCount: 0, offCount: 0, + lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null, + tools: [{ toolName: "list_projects", gatewayToolName: "asana__list_projects", displayName: "List projects", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] }], + }, + }); + runTestCallMock.mockReset(); + getTestCallStatusMock.mockReset().mockResolvedValue(requestStatus("waiting")); + declineActionRequestMock.mockReset(); + window.sessionStorage.clear(); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + window.sessionStorage.clear(); +}); + +describe("Permissions action Test dialog", () => { + it("renders structured MCP rows and keeps the full response behind the raw disclosure", async () => { + runTestCallMock.mockResolvedValue({ + decision: "allowed", invocationId: "structured", + result: { + content: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}', + data: { + content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}' }], + structuredContent: { data: [{ name: "Q4 launch" }, { name: "Customer onboarding" }] }, + isError: false, transport: "mcp_http", + }, + }, + }); + await openAndRun(); + expect(runTestCallMock).toHaveBeenCalledWith("conn-1", { agentId: "agent-ceo", toolName: "list_projects", parameters: {} }); + expect(document.body.textContent).toContain("Worked. 2 rows came back."); + expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch"); + expect(document.body.textContent).not.toContain("structuredContent"); + const raw = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Show raw response"); + await act(() => raw!.click()); + expect(document.body.textContent).toContain("structuredContent"); + }); + + it("parses JSON text when structured content is absent", async () => { + runTestCallMock.mockResolvedValue({ + decision: "allowed", invocationId: "text", + result: { content: '{"data":[{"name":"Q4 launch"}]}', data: { content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"}]}' }], structuredContent: null, isError: false } }, + }); + await openAndRun(); + expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch"); + }); + + it("uses structured content when an MCP result has no content blocks", async () => { + runTestCallMock.mockResolvedValue({ + decision: "allowed", invocationId: "structured-only", + result: { content: "", data: { structuredContent: { results: [{ name: "Northstar launch plan" }] }, isError: false } }, + }); + await openAndRun(); + expect(document.body.textContent).toContain("Worked. 1 row came back."); + expect(document.body.querySelector("table")?.textContent).toContain("Northstar launch plan"); + expect(document.body.textContent).not.toContain("structuredContent"); + }); + + it("shows wide search results as readable cards with a safe page link", async () => { + const rows = [{ + id: "11111111-2222-4333-8444-555555555555", + title: "Northstar launch plan", + url: "https://www.notion.so/11111111222243338444555555555555", + type: "page", + highlight: "The launch plan covers milestones, owners, and a detailed summary that should not stretch a table row in the dialog.", + timestamp: "2026-09-10T14:30:00.000Z", + }]; + runTestCallMock.mockResolvedValue({ + decision: "allowed", invocationId: "notion-wide", + result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], structuredContent: null, isError: false } }, + }); + await openAndRun(); + expect(document.body.querySelector("table")).toBeNull(); + expect(document.body.textContent).toContain("Northstar launch plan"); + expect(document.body.textContent).toContain("The launch plan covers milestones"); + expect(document.body.querySelector('a[href="https://www.notion.so/11111111222243338444555555555555"]')?.textContent).toBe("Open link"); + }); + + it("shows a non-link URL as a field in a result card", async () => { + const rows = [{ title: "Northstar launch plan", url: "notion://workspace/launch", description: "A long description that keeps the card layout useful even when the provider supplies a URL that cannot open in a browser." }]; + runTestCallMock.mockResolvedValue({ + decision: "allowed", invocationId: "non-link-url", + result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], isError: false } }, + }); + await openAndRun(); + expect(document.body.querySelector('a[href="notion://workspace/launch"]')).toBeNull(); + expect(document.body.textContent).toContain("notion://workspace/launch"); + }); + + it("keeps an ask-first request visible after reopening the dialog", async () => { + allowAskFirst(); + await openAndRun(askFirstEntry); + expect(document.body.textContent).toContain("Sent for your OK."); + expect(document.body.textContent).toContain("Cancel this request"); + expect(document.body.querySelector('a[href="/apps/conn-1/review"]')).toBeTruthy(); + + await act(() => root.unmount()); + root = createRoot(container); + await renderDialog(askFirstEntry); + expect(runTestCallMock).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Sent for your OK."); + }); + + it("shows an approved request running and then the completed result", async () => { + allowAskFirst(); + getTestCallStatusMock.mockResolvedValue(requestStatus("running")); + await openAndRun(askFirstEntry); + expect(document.body.textContent).toContain("Approved · running"); + expect(document.body.textContent).not.toContain("Cancel this request"); + + getTestCallStatusMock.mockResolvedValue({ ...requestStatus("done"), result: [{ name: "Northstar launch plan" }] }); + await act(async () => { await client.invalidateQueries({ queryKey: ["tools"] }); }); + await flushReact(); + expect(document.body.textContent).toContain("Worked."); + expect(document.body.textContent).toContain("Northstar launch plan"); + expect(runTestCallMock).toHaveBeenCalledTimes(1); + }); + + it("shows a denied request without offering cancellation", async () => { + allowAskFirst(); + getTestCallStatusMock.mockResolvedValue(requestStatus("denied")); + await openAndRun(askFirstEntry); + expect(document.body.textContent).toContain("Denied — see Review for why"); + expect(document.body.textContent).not.toContain("Cancel this request"); + }); + + it("shows a tool error and opens its raw response", async () => { + runTestCallMock.mockResolvedValue({ + decision: "allowed", invocationId: "error", + result: { content: "Asana denied access to this workspace.", data: { content: [{ type: "text", text: "Asana denied access to this workspace." }], isError: true }, error: "MCP tool returned an error result" }, + }); + await openAndRun(); + expect(document.body.textContent).toContain("It didn't work."); + expect(document.body.textContent).toContain("Asana denied access to this workspace."); + expect(document.body.textContent).toContain("Hide raw response"); + }); + + it("opens the raw response when an MCP block has an unexpected shape", async () => { + runTestCallMock.mockResolvedValue({ + decision: "allowed", invocationId: "malformed", + result: { content: "", data: { content: [{ type: "text", payload: "missing text" }], structuredContent: null, isError: false } }, + }); + await openAndRun(); + expect(document.body.textContent).toContain("Hide raw response"); + expect(document.body.textContent).toContain("missing text"); + }); + + it("keeps useful and generic error hints", () => { + expect(errorHints("Not found", "NOT_FOUND").join(" ")).toMatch(/check|verify|ID/i); + expect(errorHints("Unexpected provider failure", null).length).toBeGreaterThan(0); + }); +}); diff --git a/ui/src/pages/apps/app-detail/TestPanel.tsx b/ui/src/pages/apps/app-detail/ActionTestDialog.tsx similarity index 77% rename from ui/src/pages/apps/app-detail/TestPanel.tsx rename to ui/src/pages/apps/app-detail/ActionTestDialog.tsx index ed3557fd8bf..698b834e0a0 100644 --- a/ui/src/pages/apps/app-detail/TestPanel.tsx +++ b/ui/src/pages/apps/app-detail/ActionTestDialog.tsx @@ -4,7 +4,6 @@ import { AlertTriangle, Ban, Check, - ChevronDown, ChevronsUpDown, Clock, Loader2, @@ -28,12 +27,6 @@ import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Dialog, @@ -50,7 +43,6 @@ import { } from "@/components/JsonSchemaForm"; import { cn, relativeTime } from "@/lib/utils"; import { appTabHref } from "../app-tabs"; -import { formatActionPermissionSummary } from "./action-permission-summary"; // --------------------------------------------------------------------------- // Small format helpers @@ -67,13 +59,6 @@ function relTime(date: Date): string { return t.charAt(0).toUpperCase() + t.slice(1); } -/** Sub-line copy: first sentence of the catalog description, no trailing period. */ -function actionSubLine(entry: ToolCatalogEntry): string | null { - if (!entry.description) return null; - const firstSentence = entry.description.split(/(?<=\.)\s/)[0] ?? entry.description; - return firstSentence.replace(/\.+$/, "").trim() || null; -} - // --------------------------------------------------------------------------- // Decision badges // --------------------------------------------------------------------------- @@ -88,9 +73,7 @@ const TEST_ACCESS_STALE_TIME_MS = 5 * 60_000; const TEST_ACCESS_GC_TIME_MS = 30 * 60_000; /** - * Focused action tester used by the combined Permissions page. The modal keeps - * the existing schema form and result renderer, but scopes agent selection and - * test state to the action the user opened. + * Action tester opened from an action row on the Permissions page. */ export function ActionTestDialog({ connectionId, @@ -185,7 +168,6 @@ export function ActionTestDialog({ onSelect={setRequestedAgentId} connectionId={connectionId} appName={appName} - inline /> @@ -236,262 +218,6 @@ function DecisionBadge({ decision }: { decision: ToolConnectionTestDecision }) { ); } -// --------------------------------------------------------------------------- -// Panel -// --------------------------------------------------------------------------- - -export function TestPanel({ - connectionId, - appName, - active, - quarantined = [], -}: { - connectionId: string; - appName: string; - /** Active (non-quarantined, non-removed) catalog entries. */ - active: ToolCatalogEntry[]; - /** New, not-yet-reviewed actions — shown as Off so they're reachable to test. */ - quarantined?: ToolCatalogEntry[]; -}) { - const hasActions = active.length > 0 || quarantined.length > 0; - const testAgentsQuery = useQuery({ - queryKey: queryKeys.tools.testAgents(connectionId), - queryFn: () => toolsApi.listTestAgents(connectionId), - enabled: !!connectionId && hasActions, - }); - - const agents = useMemo( - () => [...(testAgentsQuery.data?.agents ?? [])].sort( - (a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name), - ), - [testAgentsQuery.data], - ); - - const [requestedAgentId, setRequestedAgentId] = useState(null); - // The API returns only agents this user may write to. Prefer the highest - // agent in that accessible slice of the org tree, regardless of whether a - // lower-ranked agent happens to have a broader app policy today. - const agentId = requestedAgentId && agents.some((agent) => agent.id === requestedAgentId) - ? requestedAgentId - : agents[0]?.id ?? null; - const selectedAgentBase = agents.find((agent) => agent.id === agentId) ?? null; - const testAgentAccessQuery = useQuery({ - queryKey: queryKeys.tools.testAgentAccess(connectionId, agentId ?? "__none__"), - queryFn: () => toolsApi.getTestAgentAccess(connectionId, agentId!), - enabled: !!connectionId && !!agentId && hasActions, - staleTime: TEST_ACCESS_STALE_TIME_MS, - gcTime: TEST_ACCESS_GC_TIME_MS, - refetchOnWindowFocus: false, - }); - const selectedAgent = useMemo(() => ( - selectedAgentBase && testAgentAccessQuery.data - ? { ...selectedAgentBase, effectiveAccess: testAgentAccessQuery.data.access } - : null - ), [selectedAgentBase, testAgentAccessQuery.data]); - - // Per-action decision for the selected agent, keyed by both the upstream and - // gateway tool names so we can match whatever the catalog stores. - const decisionByTool = useMemo(() => { - const map = new Map(); - for (const tool of selectedAgent?.effectiveAccess.tools ?? []) { - map.set(tool.toolName, tool.decision); - map.set(tool.gatewayToolName, tool.decision); - } - return map; - }, [selectedAgent]); - - const decisionFor = (entry: ToolCatalogEntry): ToolConnectionTestDecision => - decisionByTool.get(entry.toolName) ?? "off"; - - // Search + read/write filter. - const [query, setQuery] = useState(""); - const [kindFilter, setKindFilter] = useState<"all" | "read" | "write">("all"); - - const byName = (a: ToolCatalogEntry, b: ToolCatalogEntry) => - (a.title ?? a.toolName).localeCompare(b.title ?? b.toolName); - const readActions = active.filter((e) => e.isReadOnly).sort(byName); - const writeActions = active.filter((e) => !e.isReadOnly).sort(byName); - - const matches = (entry: ToolCatalogEntry) => { - if (kindFilter === "read" && !entry.isReadOnly) return false; - if (kindFilter === "write" && entry.isReadOnly) return false; - const needle = query.trim().toLowerCase(); - if (!needle) return true; - return ( - (entry.title ?? entry.toolName).toLowerCase().includes(needle) || - (entry.description ?? "").toLowerCase().includes(needle) - ); - }; - - const quarantinedActions = [...quarantined].sort(byName); - - const visibleRead = readActions.filter(matches); - const visibleWrite = writeActions.filter(matches); - const visibleQuarantined = quarantinedActions.filter(matches); - const visibleCount = visibleRead.length + visibleWrite.length + visibleQuarantined.length; - - if (!hasActions) { - return ; - } - - if (testAgentsQuery.isLoading) { - return ( -
-
- - Loading agents… -
- - - -
- ); - } - - if (testAgentsQuery.isError) { - return ( - { void testAgentsQuery.refetch(); }} - /> - ); - } - - if (agents.length === 0) { - return ( -
-

No agents to test as

-

- Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "} - - Permissions - {" "} - to test it here. -

-
- ); - } - - if (testAgentAccessQuery.isError && !testAgentAccessQuery.data) { - return ( - { void testAgentAccessQuery.refetch(); }} - /> - ); - } - - if (testAgentAccessQuery.isLoading || !selectedAgent) { - return ( -
-
- - Loading agent permissions… -
- - - -
- ); - } - - const sharedRowProps = { - connectionId, - appName, - allAgents: agents, - onSelectAgent: setRequestedAgentId, - }; - - return ( -
- {selectedAgent && ( - - )} - -
-

Actions

-
-
- - setQuery(event.target.value)} - /> -
- setKindFilter("all")} /> - setKindFilter("read")} /> - setKindFilter("write")} /> -
-

{visibleCount} matches · sorted A–Z

-
- - {visibleCount === 0 ? ( -
- No actions match “{query}”. Clear the search to see them all. -
- ) : ( -
- {visibleRead.length > 0 && selectedAgent && ( - - )} - {visibleWrite.length > 0 && selectedAgent && ( - - )} - {visibleQuarantined.length > 0 && selectedAgent && ( - "off" as const} - agent={selectedAgent} - {...sharedRowProps} - /> - )} -
- )} -
- ); -} - -// --------------------------------------------------------------------------- -// Empty state -// --------------------------------------------------------------------------- - -function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) { - return ( -
-

Nothing to test yet

-

- Once {appName} is connected, the actions it offers will show up here so you can try them out. -

- -
- ); -} - function TestLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { return (
@@ -504,66 +230,21 @@ function TestLoadError({ message, onRetry }: { message: string; onRetry: () => v } // --------------------------------------------------------------------------- -// Test-as header + agent picker +// Agent picker // --------------------------------------------------------------------------- -function TestAsHeader({ - appName, - agents, - selectedAgent, - onSelect, - connectionId, -}: { - appName: string; - agents: ToolConnectionTestAgent[]; - selectedAgent: TestAgentWithAccess; - onSelect: (agentId: string) => void; - connectionId: string; -}) { - return ( -
-
-

Test an action

-

- Run a real action as an agent. -

-
-
-
-

Agent

- -
- - {formatActionPermissionSummary(selectedAgent.effectiveAccess)} - -
-
- ); -} - function AgentPicker({ agents, selectedAgent, onSelect, connectionId, appName, - inline, }: { agents: ToolConnectionTestAgent[]; selectedAgent: ToolConnectionTestAgent; onSelect: (agentId: string) => void; connectionId: string; appName: string; - inline?: boolean; }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -577,17 +258,14 @@ function AgentPicker({ - +
@@ -661,114 +339,6 @@ function AgentPicker({ ); } -function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { - return ( - - ); -} - -// --------------------------------------------------------------------------- -// Action group + rows -// --------------------------------------------------------------------------- - -type RowSharedProps = { - connectionId: string; - appName: string; - allAgents: ToolConnectionTestAgent[]; - onSelectAgent: (agentId: string) => void; -}; - -function ActionGroup({ - heading, - subheading, - entries, - decisionFor, - agent, - ...shared -}: { - heading: string; - subheading?: string; - entries: ToolCatalogEntry[]; - decisionFor: (entry: ToolCatalogEntry) => ToolConnectionTestDecision; - agent: TestAgentWithAccess; -} & RowSharedProps) { - return ( -
-

{heading}

- {subheading &&

{subheading}

} -
- {entries.map((entry) => ( - - ))} -
-
- ); -} - -function ActionRow({ - entry, - decision, - agent, - ...shared -}: { - entry: ToolCatalogEntry; - decision: ToolConnectionTestDecision; - agent: TestAgentWithAccess; -} & RowSharedProps) { - const [open, setOpen] = useState(() => Boolean(loadStoredAskFirstOutcome(shared.connectionId, entry, agent))); - const title = entry.title ?? entry.toolName; - const sub = actionSubLine(entry); - - useEffect(() => { - if (loadStoredAskFirstOutcome(shared.connectionId, entry, agent)) { - setOpen(true); - } - }, [shared.connectionId, entry, agent]); - - return ( - - - - - -
- -
-
-
- ); -} - // --------------------------------------------------------------------------- // The actual tester (form + run + result) // --------------------------------------------------------------------------- @@ -854,7 +424,11 @@ function ActionTester({ entry: ToolCatalogEntry; decision: ToolConnectionTestDecision; agent: TestAgentWithAccess; -} & RowSharedProps) { + connectionId: string; + appName: string; + allAgents: ToolConnectionTestAgent[]; + onSelectAgent: (agentId: string) => void; +}) { const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const rawSchema = (entry.inputSchema ?? { type: "object", properties: {} }) as JsonSchemaNode; @@ -1202,6 +776,59 @@ function isEmptyResult(value: unknown): boolean { return false; } +/** The gateway keeps the original MCP blocks inside `data`; the top-level + * `content` is only a flattened convenience string. Prefer structured output, + * then parse individual text blocks when they happen to contain JSON. */ +function resultPreview(value: unknown): { items: Array<{ label: string | null; value: unknown }>; summary: unknown; rawFallback?: boolean } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { items: [{ label: null, value }], summary: value }; + } + const wrapper = value as Record; + const data = wrapper.data && typeof wrapper.data === "object" && !Array.isArray(wrapper.data) + ? wrapper.data as Record + : null; + const result = data && (Array.isArray(data.content) || data.structuredContent != null) ? data : wrapper; + const blocks = Array.isArray(result.content) ? result.content : null; + if (!blocks && result.structuredContent == null) { + return { items: [{ label: null, value }], summary: value }; + } + + const parseText = (text: string): unknown => { + try { return JSON.parse(text); } catch { return text; } + }; + const structured = result.structuredContent; + const items: Array<{ label: string | null; value: unknown }> = []; + const structuredValue = typeof structured === "string" ? parseText(structured) : structured; + if (structured !== null && structured !== undefined) { + items.push({ label: null, value: structuredValue }); + } + for (const block of blocks ?? []) { + if (!block || typeof block !== "object" || Array.isArray(block)) { + return { items: [{ label: null, value }], summary: value, rawFallback: true }; + } + const content = block as Record; + if (content.type === "text" && typeof content.text === "string") { + const parsed = parseText(content.text); + if (structured === null || structured === undefined || safeStringify(parsed) !== safeStringify(structuredValue)) { + items.push({ label: (blocks?.length ?? 0) > 1 || (structured !== null && structured !== undefined) ? "Text" : null, value: parsed }); + } + } else if (content.type === "image" || content.type === "audio") { + items.push({ label: content.type === "image" ? "Image" : "Audio", value: typeof content.mimeType === "string" ? content.mimeType : "Media attachment" }); + } else if (content.type === "resource_link") { + items.push({ label: "Resource", value: content.title ?? content.name ?? content.uri ?? "Resource link" }); + } else if (content.type === "resource" && content.resource && typeof content.resource === "object") { + const resource = content.resource as Record; + items.push({ label: "Resource", value: typeof resource.text === "string" ? parseText(resource.text) : resource.uri ?? "Embedded resource" }); + } else { + return { items: [{ label: null, value }], summary: value, rawFallback: true }; + } + } + if (items.length === 0 && typeof wrapper.content === "string") { + items.push({ label: null, value: parseText(wrapper.content) }); + } + return { items, summary: structured !== null && structured !== undefined ? structuredValue : items[0]?.value }; +} + function writeVerb(entry: ToolCatalogEntry): string | null { const n = `${entry.toolName} ${entry.title ?? ""}`.toLowerCase(); if (/\b(append|add|insert|create|new)\b/.test(n)) return "added"; @@ -1231,27 +858,35 @@ function AllowedResult({ connectionId: string; }) { const value = outcome.result.result; + const preview = resultPreview(value); return (
- {successHeadline(value, entry, appName)} + {successHeadline(preview.summary, entry, appName)}

Ran as {outcome.agentName} · {seconds(outcome.durationMs)} · {relTime(outcome.ranAt)}

- {!isEmptyResult(value) && ( + {preview.items.some((item) => !isEmptyResult(item.value)) && (

Preview

- +
+ {preview.items.map((item, index) => ( +
+ {item.label &&

{item.label}

} + +
+ ))} +
)} - +

This call is in the{" "} @@ -1265,12 +900,25 @@ function AllowedResult({ ); } -/** Pretty preview: table for row arrays, depth-limited JSON otherwise, plain text for strings. */ +/** Pretty preview: tables for row arrays, labeled fields for objects, plain text for prose. */ function PrettyPreview({ value }: { value: unknown }) { const rows = asRows(value); if (rows) { const columns = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))).slice(0, 6); const shown = rows.slice(0, 6); + const needsCards = columns.length > 4 || shown.some((row) => + columns.some((column) => cellText(row[column]).length > 80) + ); + if (needsCards) { + return ( +

+ {shown.map((row, index) => )} + {rows.length > shown.length && ( +

… {rows.length - shown.length} more rows in the raw response

+ )} +
+ ); + } return (
@@ -1300,6 +948,18 @@ function PrettyPreview({ value }: { value: unknown }) { if (typeof value === "string") { return
{value}
; } + if (value && typeof value === "object" && !Array.isArray(value)) { + return ( +
+ {Object.entries(value as Record).map(([key, field]) => ( +
+
{key}
+
{typeof field === "object" && field !== null ? safeStringify(collapseDeep(field, 2)) : cellText(field)}
+
+ ))} +
+ ); + } return (
       {safeStringify(collapseDeep(value, 2))}
@@ -1307,6 +967,42 @@ function PrettyPreview({ value }: { value: unknown }) {
   );
 }
 
+function PreviewRowCard({ row, index }: { row: Record; index: number }) {
+  const titleKey = ["title", "name", "label"].find((key) => typeof row[key] === "string" && row[key] !== "");
+  const summaryKey = ["highlight", "description", "summary"].find((key) => typeof row[key] === "string" && row[key] !== "");
+  const url = typeof row.url === "string" && /^https?:\/\//i.test(row.url) ? row.url : null;
+  const details = Object.entries(row)
+    .filter(([key]) => key !== titleKey && key !== summaryKey && (key !== "url" || !url))
+    .sort(([left], [right]) => {
+      const order = ["url", "type", "status", "timestamp", "id"];
+      const leftRank = order.indexOf(left);
+      const rightRank = order.indexOf(right);
+      return (leftRank < 0 ? order.length : leftRank) - (rightRank < 0 ? order.length : rightRank);
+    });
+  return (
+    
+
+

+ {titleKey ? cellText(row[titleKey]) : `Result ${index + 1}`} +

+ {url && Open link} +
+ {summaryKey &&

{cellText(row[summaryKey]).replace(/\*\*(.*?)\*\*/g, "$1")}

} + {details.length > 0 && ( +
+ {details.slice(0, 3).map(([key, field]) => ( +
+
{key}
+
{cellText(field)}
+
+ ))} +
+ )} + {details.length > 3 &&

{details.length - 3} more {details.length === 4 ? "field" : "fields"} in the raw response

} +
+ ); +} + function cellText(value: unknown): string { if (value === null || value === undefined) return "—"; if (typeof value === "object") return Array.isArray(value) ? `[${value.length}]` : "{…}"; @@ -1325,8 +1021,8 @@ function collapseDeep(value: unknown, maxDepth: number, depth = 0): unknown { return out; } -function RawResponseDisclosure({ value }: { value: unknown }) { - const [showRaw, setShowRaw] = useState(false); +function RawResponseDisclosure({ value, initiallyOpen = false }: { value: unknown; initiallyOpen?: boolean }) { + const [showRaw, setShowRaw] = useState(initiallyOpen); if (value === undefined || value === null) return null; return (
@@ -1384,6 +1080,7 @@ function ErrorResult({ ))}
+ {outcome.result.result !== undefined && }

{needsReconnect ? "After reconnecting, run this action again." : "Adjust the input above and try again."}

diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx index 033a35f8814..aa08b887c0a 100644 --- a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx +++ b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx @@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { cn } from "@/lib/utils"; import { type InstallState } from "@/lib/tool-installs"; import { QuarantinedActionsReview } from "./SetupPanel"; -import { ActionTestDialog } from "./TestPanel"; +import { ActionTestDialog } from "./ActionTestDialog"; import type { AccessDraft, AppDetailSectionProps } from "./types"; type ActionPermission = "off" | "ask" | "allowed"; diff --git a/ui/src/pages/apps/app-detail/TestPanel.test.tsx b/ui/src/pages/apps/app-detail/TestPanel.test.tsx deleted file mode 100644 index 3b14ea5c5ad..00000000000 --- a/ui/src/pages/apps/app-detail/TestPanel.test.tsx +++ /dev/null @@ -1,575 +0,0 @@ -// @vitest-environment jsdom - -import { flushSync } from "react-dom"; -import type { ReactNode } from "react"; -import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ToolCatalogEntry } from "@paperclipai/shared"; -import { TestPanel, errorHints } from "./TestPanel"; - -const listTestAgentsMock = vi.hoisted(() => vi.fn()); -const getTestAgentAccessMock = vi.hoisted(() => vi.fn()); -const runTestCallMock = vi.hoisted(() => vi.fn()); -const getTestCallStatusMock = vi.hoisted(() => vi.fn()); -const declineActionRequestMock = vi.hoisted(() => vi.fn()); - -vi.mock("@/api/tools", () => ({ - toolsApi: { - listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId), - getTestAgentAccess: (connectionId: string, agentId: string) => - getTestAgentAccessMock(connectionId, agentId), - runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input), - getTestCallStatus: (connectionId: string, actionRequestId: string) => - getTestCallStatusMock(connectionId, actionRequestId), - declineActionRequest: (companyId: string, actionRequestId: string) => - declineActionRequestMock(companyId, actionRequestId), - }, -})); - -vi.mock("@/context/CompanyContext", () => ({ - useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" } }), -})); - -vi.mock("@/lib/router", () => ({ - Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => ( - - {children} - - ), -})); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -async function act(callback: () => void | Promise) { - let result: void | Promise = undefined; - flushSync(() => { - result = callback(); - }); - await result; -} - -async function flushReact() { - for (let i = 0; i < 4; i += 1) { - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); - } -} - -/** The form field input — excludes the "Find an action" search box. */ -function formInput(): HTMLInputElement { - const inputs = [...container.querySelectorAll("input")].filter( - (i) => i.getAttribute("aria-label") !== "Find an action", - ); - return inputs[0] as HTMLInputElement; -} - -async function fillFormField(value: string) { - const input = formInput(); - expect(input).toBeTruthy(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; - await act(async () => { - setter.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); - await flushReact(); -} - -/** Wait past the 200ms minimum-spinner delay so the result panel renders. */ -async function settle() { - await act(async () => { - await new Promise((resolve) => window.setTimeout(resolve, 260)); - }); - await flushReact(); -} - -async function clickByText(text: string) { - const btn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text); - expect(btn).toBeTruthy(); - await act(async () => btn!.click()); - await flushReact(); -} - -function catalogEntry(overrides: Partial = {}): ToolCatalogEntry { - return { - id: "catalog-read", - companyId: "company-1", - applicationId: "app-1", - connectionId: "conn-1", - entryKind: "tool", - toolName: "read_sheet", - title: "Read a sheet", - description: "Get rows and cell values from a sheet.", - inputSchema: { - type: "object", - properties: { spreadsheetId: { type: "string", title: "Spreadsheet" } }, - required: ["spreadsheetId"], - }, - outputSchema: null, - annotations: null, - riskLevel: "read", - isReadOnly: true, - isWrite: false, - isDestructive: false, - status: "active", - addedAt: new Date("2026-01-01T00:00:00Z"), - version: null, - schemaHash: null, - firstSeenAt: new Date("2026-01-01T00:00:00Z"), - lastSeenAt: new Date("2026-01-01T00:00:00Z"), - reviewedAt: null, - reviewedByAgentId: null, - reviewedByUserId: null, - createdAt: new Date("2026-01-01T00:00:00Z"), - updatedAt: new Date("2026-01-01T00:00:00Z"), - ...overrides, - } as ToolCatalogEntry; -} - -function agent(overrides: Record = {}) { - return { - id: "agent-claude", - name: "ClaudeCoder", - role: "engineer", - title: "Engineer", - status: "active", - orgDepth: 1, - effectiveAccess: { - connectionId: "conn-1", - toolCount: 3, - allowedCount: 1, - askFirstCount: 1, - offCount: 1, - lastChangedAt: null, - lastChangedByAgentId: null, - lastChangedByName: null, - tools: [ - { toolName: "read_sheet", gatewayToolName: "gs__read_sheet", displayName: "Read a sheet", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] }, - { toolName: "append_row", gatewayToolName: "gs__append_row", displayName: "Append a row", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] }, - { toolName: "delete_row", gatewayToolName: "gs__delete_row", displayName: "Delete a row", risk: "destructive", decision: "off", reasonCode: null, matchedPolicyIds: [] }, - ], - }, - ...overrides, - }; -} - -const readEntry = catalogEntry(); -const writeAskEntry = catalogEntry({ - id: "catalog-append", - toolName: "append_row", - title: "Append a row", - description: "Add a new row to the bottom of a sheet.", - isReadOnly: false, - isWrite: true, - riskLevel: "write", -}); -const offEntry = catalogEntry({ - id: "catalog-delete", - toolName: "delete_row", - title: "Delete a row", - description: "Remove a row from a sheet permanently.", - isReadOnly: false, - isWrite: true, - isDestructive: true, - riskLevel: "destructive", -}); - -let container: HTMLDivElement; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let root: any; -let client: QueryClient; - -function renderPanel( - active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry], - quarantined: ToolCatalogEntry[] = [], -) { - root.render( - - - , - ); -} - -beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - listTestAgentsMock.mockReset(); - getTestAgentAccessMock.mockReset(); - runTestCallMock.mockReset(); - getTestCallStatusMock.mockReset(); - declineActionRequestMock.mockReset(); - listTestAgentsMock.mockResolvedValue({ agents: [agent()] }); - getTestAgentAccessMock.mockResolvedValue({ access: agent().effectiveAccess }); - // Default ask-first polls report the request still waiting on approval. - getTestCallStatusMock.mockResolvedValue({ - actionRequestId: "req-1", - invocationId: "inv-2", - phase: "waiting", - parameters: { spreadsheetId: "sheet-123" }, - requestedAt: "2026-06-18T00:00:00.000Z", - resolvedAt: null, - durationMs: null, - }); - window.sessionStorage.clear(); -}); - -afterEach(() => { - act(() => root.unmount()); - container.remove(); - window.sessionStorage.clear(); -}); - -describe("TestPanel", () => { - it("shows a lightweight agent-loading state before requesting permissions", async () => { - listTestAgentsMock.mockImplementation(() => new Promise(() => undefined)); - - await act(async () => renderPanel()); - - expect(container.textContent).toContain("Loading agents…"); - expect(container.querySelector(".animate-spin")).toBeTruthy(); - expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0); - expect(getTestAgentAccessMock).not.toHaveBeenCalled(); - }); - - it("loads permissions only for the selected agent and reuses the cached summary", async () => { - await act(async () => renderPanel()); - await flushReact(); - - expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1); - expect(getTestAgentAccessMock).toHaveBeenCalledWith("conn-1", "agent-claude"); - - await act(async () => root.unmount()); - root = createRoot(container); - await act(async () => renderPanel()); - await flushReact(); - - expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1); - }); - - it("renders the test hierarchy and grouped actions with access badges", async () => { - await act(async () => renderPanel()); - await flushReact(); - - expect(container.textContent).toContain("Test an action"); - expect(container.textContent).toContain("Agent"); - expect(container.textContent).toContain("ClaudeCoder"); - expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action"); - expect(container.textContent).toContain("Read (1)"); - expect(container.textContent).toContain("Write (2)"); - // Each action shows its decision badge. - expect(container.textContent).toContain("Allowed"); - expect(container.textContent).toContain("Ask first"); - expect(container.textContent).toContain("Off"); - expect(container.querySelector(".bg-card")).toBeNull(); - }); - - it("defaults to the highest-ranked accessible agent", async () => { - listTestAgentsMock.mockResolvedValue({ - agents: [ - agent({ id: "agent-report", name: "A report", orgDepth: 2 }), - agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }), - agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }), - ], - }); - - await act(async () => renderPanel()); - await flushReact(); - - expect(container.textContent).toContain("Root agent"); - expect(container.textContent).not.toContain("A report"); - }); - - it("shows the empty state when there are no actions", async () => { - await act(async () => renderPanel([])); - await flushReact(); - expect(container.textContent).toContain("Nothing to test yet"); - expect(container.textContent).toContain("Go to Permissions"); - }); - - it("renders an allowed result panel with a row-count headline after a successful run", async () => { - runTestCallMock.mockResolvedValue({ - decision: "allowed", - invocationId: "inv-1", - result: [ - { name: "Acme", stage: "Demo" }, - { name: "Globex", stage: "Trial" }, - ], - }); - await act(async () => renderPanel()); - await flushReact(); - - // Expand the allowed (read) action. - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet")); - expect(trigger).toBeTruthy(); - await act(async () => trigger!.click()); - await flushReact(); - - // Fill the required field then Run. - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - - expect(runTestCallMock).toHaveBeenCalledWith("conn-1", { - agentId: "agent-claude", - toolName: "read_sheet", - parameters: { spreadsheetId: "sheet-123" }, - }); - expect(container.textContent).toContain("Worked. 2 rows came back."); - expect(container.textContent).toContain("Ran as ClaudeCoder"); - expect(container.textContent).toContain("Preview"); - }); - - it.each(["grant_credential_invalid", "oauth_insufficient_scope"])("guides %s failures to reconnect instead of changing action inputs", async (reasonCode) => { - runTestCallMock.mockResolvedValue({ - decision: "allowed", - invocationId: "inv-credential", - error: { message: "The connection must be reconnected.", reasonCode }, - }); - await act(async () => renderPanel()); - await flushReact(); - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet")); - await act(async () => trigger!.click()); - await flushReact(); - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - - expect(container.textContent).toContain("After reconnecting, run this action again."); - expect(container.textContent).not.toContain("Adjust the input above"); - expect(container.textContent).not.toContain("Check the field formats"); - expect(container.textContent).toContain(reasonCode === "grant_credential_invalid" - ? "Ask the connection owner to reconnect" - : "allow the permissions required for this action"); - }); - - it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => { - // The gateway let the call through (decision:"allowed") but the upstream MCP - // tool failed at the tool layer — the envelope carries isError + an error. - runTestCallMock.mockResolvedValue({ - decision: "allowed", - invocationId: "inv-err", - result: { - content: "Invalid arguments: range is required", - data: { content: [{ type: "text", text: "Invalid arguments: range is required" }], isError: true }, - error: "MCP tool returned an error result", - }, - }); - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet")); - await act(async () => trigger!.click()); - await flushReact(); - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - - expect(container.textContent).toContain("It didn't work."); - expect(container.textContent).not.toContain("Worked."); - // The actual MCP message wins over the generic gateway wrapper string. - expect(container.textContent).toContain("Invalid arguments: range is required"); - }); - - it("renders the ask-first card linking to Review", async () => { - runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" }); - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row")); - await act(async () => trigger!.click()); - await flushReact(); - - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - - expect(container.textContent).toContain("Sent for your OK."); - expect(container.textContent).toContain("Cancel this request"); - // "Where" is populated from the polled action-request snapshot. - expect(container.textContent).toContain("spreadsheetId: sheet-123"); - expect(container.textContent).toContain("Waiting ·"); - const reviewLink = [...container.querySelectorAll("a")].find((a) => a.textContent?.includes("Open Review tab")); - expect(reviewLink?.getAttribute("href")).toBe("/apps/conn-1/review"); - }); - - it("restores the ask-first card after leaving and returning to the Test tab", async () => { - runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" }); - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row")); - await act(async () => trigger!.click()); - await flushReact(); - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - expect(container.textContent).toContain("Sent for your OK."); - - await act(async () => root.unmount()); - root = createRoot(container); - await act(async () => renderPanel()); - await flushReact(); - - expect(runTestCallMock).toHaveBeenCalledTimes(1); - expect(container.textContent).toContain("Sent for your OK."); - expect(container.textContent).toContain("spreadsheetId: sheet-123"); - expect(container.textContent).toContain("Cancel this request"); - }); - - it("shows 'Approved · running' while an approved ask-first call is executing", async () => { - runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" }); - getTestCallStatusMock.mockResolvedValue({ - actionRequestId: "req-1", - invocationId: "inv-2", - phase: "running", - parameters: { spreadsheetId: "sheet-123" }, - requestedAt: "2026-06-18T00:00:00.000Z", - resolvedAt: "2026-06-18T00:00:05.000Z", - durationMs: null, - }); - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row")); - await act(async () => trigger!.click()); - await flushReact(); - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - - expect(container.textContent).toContain("Approved · running"); - // While running we don't offer cancel — the call is already on its way. - expect(container.textContent).not.toContain("Cancel this request"); - }); - - it("mutates the ask-first card into the allowed result once approved and done", async () => { - runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" }); - getTestCallStatusMock.mockResolvedValue({ - actionRequestId: "req-1", - invocationId: "inv-2", - phase: "done", - parameters: { spreadsheetId: "sheet-123" }, - result: [{ name: "Acme", stage: "Demo" }], - requestedAt: "2026-06-18T00:00:00.000Z", - resolvedAt: "2026-06-18T00:00:05.000Z", - durationMs: 1200, - }); - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row")); - await act(async () => trigger!.click()); - await flushReact(); - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - - // No longer the pending card — the real response is shown without re-running. - expect(container.textContent).not.toContain("Sent for your OK."); - expect(container.textContent).toContain("Worked."); - expect(container.textContent).toContain("1.2s"); - }); - - it("shows the denied status when an ask-first call is declined in Review", async () => { - runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" }); - getTestCallStatusMock.mockResolvedValue({ - actionRequestId: "req-1", - invocationId: "inv-2", - phase: "denied", - parameters: { spreadsheetId: "sheet-123" }, - requestedAt: "2026-06-18T00:00:00.000Z", - resolvedAt: "2026-06-18T00:00:05.000Z", - durationMs: null, - }); - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row")); - await act(async () => trigger!.click()); - await flushReact(); - await fillFormField("sheet-123"); - await clickByText("Run"); - await settle(); - - expect(container.textContent).toContain("Denied — see Review for why"); - expect(container.textContent).not.toContain("Cancel this request"); - }); - - it("explains an off action and links to Permissions without calling the API", async () => { - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row")); - await act(async () => trigger!.click()); - await flushReact(); - - expect(container.textContent).toContain("Delete a row is off for ClaudeCoder."); - const permLink = [...container.querySelectorAll("a")].find((a) => a.getAttribute("href") === "/apps/conn-1/permissions"); - expect(permLink).toBeTruthy(); - // No Run button is offered for an off action. - expect([...container.querySelectorAll("button")].some((b) => b.textContent?.trim() === "Run")).toBe(false); - expect(runTestCallMock).not.toHaveBeenCalled(); - }); - - it("shows the 'Last changed by' audit hint when the access summary carries one", async () => { - getTestAgentAccessMock.mockResolvedValue({ - access: { - ...agent().effectiveAccess, - lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(), - lastChangedByAgentId: "agent-admin", - lastChangedByName: "Dotta", - }, - }); - await act(async () => renderPanel()); - await flushReact(); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row")); - await act(async () => trigger!.click()); - await flushReact(); - - expect(container.textContent).toContain("Last changed by Dotta"); - }); - - it("surfaces quarantined actions in a 'New' group with the not-yet-on explanation", async () => { - const quarantinedEntry = catalogEntry({ - id: "catalog-new", - toolName: "rename_sheet", - title: "Rename a sheet", - description: "Change a sheet's name.", - isReadOnly: false, - isWrite: true, - riskLevel: "write", - status: "quarantined", - }); - await act(async () => renderPanel([readEntry, writeAskEntry, offEntry], [quarantinedEntry])); - await flushReact(); - - expect(container.textContent).toContain("New (1)"); - - const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Rename a sheet")); - expect(trigger).toBeTruthy(); - await act(async () => trigger!.click()); - await flushReact(); - - expect(container.textContent).toContain("This action is new and hasn't been turned on yet."); - expect(runTestCallMock).not.toHaveBeenCalled(); - }); -}); - -describe("errorHints", () => { - it("maps known reason codes to tailored hints", () => { - expect(errorHints("Requested entity was not found.", "NOT_FOUND").length).toBeGreaterThan(0); - expect(errorHints("permission denied", "PERMISSION_DENIED")[0]).toContain("permission"); - expect(errorHints("bad input", "INVALID_ARGUMENT").length).toBeGreaterThan(0); - expect(errorHints("slow down", "RATE_LIMIT")[0]).toContain("rate-limit"); - }); - - it("returns the locked generic fallback for an unknown error", () => { - expect(errorHints("boom", "tool_execution_failed")).toEqual(["Check the inputs above and try again."]); - }); -}); diff --git a/ui/storybook/stories/action-permissions-test.stories.tsx b/ui/storybook/stories/action-permissions-test.stories.tsx new file mode 100644 index 00000000000..38f2601ca3d --- /dev/null +++ b/ui/storybook/stories/action-permissions-test.stories.tsx @@ -0,0 +1,243 @@ +import { useEffect, useMemo } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ToolCatalogEntry, ToolConnectionTestCallResult } from "@paperclipai/shared"; +import { queryKeys } from "@/lib/queryKeys"; +import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel"; + +const CONNECTION = "conn-asana-storybook"; +const TOOL: ToolCatalogEntry = { + id: "asana-list-projects", + companyId: "company-storybook", + applicationId: "app-asana", + connectionId: CONNECTION, + entryKind: "tool", + toolName: "list_projects", + title: "List projects", + description: "Find projects available in this Asana workspace.", + inputSchema: { type: "object", properties: {} }, + outputSchema: null, + annotations: null, + riskLevel: "read", + isReadOnly: true, + isWrite: false, + isDestructive: false, + status: "active", + addedAt: new Date("2026-09-01T00:00:00Z"), + version: null, + schemaHash: null, + firstSeenAt: new Date("2026-09-01T00:00:00Z"), + lastSeenAt: new Date("2026-10-01T00:00:00Z"), + reviewedAt: null, + reviewedByAgentId: null, + reviewedByUserId: null, + createdAt: new Date("2026-09-01T00:00:00Z"), + updatedAt: new Date("2026-10-01T00:00:00Z"), +} as ToolCatalogEntry; + +const PROJECTS = [ + { gid: "1214644669600482", name: "Q4 launch", resource_subtype: "default_project", team: "Marketing" }, + { gid: "1214644669600483", name: "Customer onboarding", resource_subtype: "default_project", team: "Product" }, + { gid: "1214644669600484", name: "Support operations", resource_subtype: "default_project", team: "Operations" }, +]; + +const NOTION_TOOL = { + ...TOOL, + id: "notion-search", + applicationId: "app-notion", + toolName: "search", + title: "Search Notion", + description: "Search pages in your Notion workspace.", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, +} as ToolCatalogEntry; + +const NOTION_RESULTS = [ + { + id: "11111111-2222-4333-8444-555555555555", + title: "Northstar launch plan", + url: "https://www.notion.so/11111111222243338444555555555555", + type: "page", + highlight: "The team is tracking launch milestones, owners, dependencies, and the decisions needed before the release window.", + timestamp: "2026-09-10T14:30:00.000Z", + }, + { + id: "66666666-7777-4888-8999-aaaaaaaaaaaa", + title: "Launch customer research", + url: "https://www.notion.so/66666666777748888999aaaaaaaaaaaa", + type: "page", + highlight: "Interviews with early customers point to a clearer onboarding flow and better examples for complex team setups.", + timestamp: "2026-09-08T09:15:00.000Z", + }, +]; + +function mcpResult(content: Array>, structuredContent: unknown = null, isError = false) { + return { + content: content.map((block) => block.type === "text" ? block.text : JSON.stringify(block)).join("\n"), + data: { content, structuredContent, isError, transport: "mcp_http", spawnedLocalProcess: false }, + ...(isError ? { error: "MCP tool returned an error result" } : {}), + }; +} + +function seededClient(tool: ToolCatalogEntry) { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } }, + }); + client.setQueryData(queryKeys.tools.testAgents(CONNECTION), { + agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }], + }); + client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, "agent-ceo"), { + access: { + connectionId: CONNECTION, + toolCount: 1, + allowedCount: 1, + askFirstCount: 0, + offCount: 0, + lastChangedAt: null, + lastChangedByAgentId: null, + lastChangedByName: null, + tools: [{ + toolName: tool.toolName, + gatewayToolName: `${tool.applicationId}__${tool.toolName}`, + displayName: tool.title, + risk: "read", + decision: "allowed", + reasonCode: null, + matchedPolicyIds: [], + }], + }, + }); + return client; +} + +function openAndRun(query?: string) { + let step = 0; + const tick = (attempt: number) => { + if (step === 0) { + const button = [...document.querySelectorAll("button")].find((item) => item.textContent?.trim() === "Test"); + if (button) { button.click(); step = 1; } + } else { + const dialog = document.querySelector('[role="dialog"]'); + if (query && step === 1) { + const input = dialog?.querySelector('input[placeholder=""]') as HTMLInputElement | null; + if (input) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, query); + input.dispatchEvent(new Event("input", { bubbles: true })); + step = 2; + } + } + const run = [...(dialog?.querySelectorAll("button") ?? [])].find((item) => item.textContent?.trim() === "Run"); + if (run && (!query || step === 2)) { run.click(); return; } + } + if (attempt < 80) window.setTimeout(() => tick(attempt + 1), 50); + }; + tick(0); +} + +function PermissionsTestStory({ result, tool = TOOL, appName = "Asana", query }: { + result: ToolConnectionTestCallResult; + tool?: ToolCatalogEntry; + appName?: string; + query?: string; +}) { + const client = useMemo(() => seededClient(tool), [tool]); + useEffect(() => { + const original = window.fetch.bind(window); + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes(`/tool-connections/${CONNECTION}/test-calls`)) { + await new Promise((resolve) => window.setTimeout(resolve, 120)); + return Response.json(result); + } + return original(input, init); + }; + const timer = window.setTimeout(() => openAndRun(query), 150); + return () => { window.clearTimeout(timer); window.fetch = original; }; + }, [query, result]); + return ( + +
+ undefined} + onReviewQuarantined={() => undefined} + onRefreshActions={() => undefined} + /> +
+
+ ); +} + +const meta = { + title: "Apps/Action permissions test", + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const StructuredRows: Story = { + name: "Structured rows", + render: () => , +}; + +export const StructuredOnly: Story = { + name: "Structured data without content blocks", + render: () => , +}; + +export const JsonInText: Story = { + name: "JSON inside text", + render: () => , +}; + +export const NotionWideSearch: Story = { + name: "Notion search with long fields", + render: () => , +}; + +export const MixedBlocks: Story = { + name: "Mixed content blocks", + render: () => , +}; + +export const ToolError: Story = { + name: "Tool error with raw response", + render: () => , +}; + +export const MalformedBlock: Story = { + name: "Unexpected block with raw fallback", + render: () => , +}; diff --git a/ui/storybook/stories/test-tab.stories.tsx b/ui/storybook/stories/test-tab.stories.tsx deleted file mode 100644 index e3a7f8391d1..00000000000 --- a/ui/storybook/stories/test-tab.stories.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { - ToolCatalogEntry, - ToolConnectionAccessSummary, - ToolConnectionTestAgent, - ToolConnectionTestCallResult, - ToolConnectionTestDecision, -} from "@paperclipai/shared"; -import { queryKeys } from "@/lib/queryKeys"; -import { TestPanel } from "@/pages/apps/app-detail/TestPanel"; - -const CONNECTION = "conn-sheets"; - -type StoryAgent = ToolConnectionTestAgent & { - effectiveAccess: ToolConnectionAccessSummary; -}; - -// --------------------------------------------------------------------------- -// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes. -// --------------------------------------------------------------------------- - -function tool( - id: string, - toolName: string, - title: string, - description: string, - read: boolean, - inputSchema: Record | null = null, -): ToolCatalogEntry { - return { - id, - companyId: "company-storybook", - applicationId: "app-sheets", - connectionId: CONNECTION, - entryKind: "tool", - name: toolName, - toolName, - title, - description, - inputSchema, - outputSchema: null, - annotations: null, - riskLevel: read ? "read" : "write", - isReadOnly: read, - isWrite: !read, - isDestructive: false, - status: "active", - addedAt: new Date("2026-06-01T00:00:00Z"), - version: null, - versionHash: null, - schemaHash: null, - firstSeenAt: new Date("2026-06-01T00:00:00Z"), - lastSeenAt: new Date("2026-06-01T00:00:00Z"), - reviewedAt: null, - reviewedByAgentId: null, - reviewedByUserId: null, - createdAt: new Date("2026-06-01T00:00:00Z"), - updatedAt: new Date("2026-06-01T00:00:00Z"), - } as ToolCatalogEntry; -} - -const READ_SHEET_SCHEMA = { - type: "object", - properties: { - spreadsheetId: { type: "string", title: "Spreadsheet" }, - sheet: { type: "string", title: "Sheet" }, - range: { type: "string", title: "Range", description: "e.g. A1:F50" }, - }, - required: ["spreadsheetId", "sheet"], -}; - -const APPEND_ROW_SCHEMA = { - type: "object", - properties: { - spreadsheetId: { type: "string", title: "Spreadsheet" }, - sheet: { type: "string", title: "Sheet" }, - values: { type: "string", title: "Values", description: "Comma-separated cell values" }, - }, - required: ["spreadsheetId", "sheet", "values"], -}; - -const DELETE_ROW_SCHEMA = { - type: "object", - properties: { - spreadsheetId: { type: "string", title: "Spreadsheet" }, - rowNumber: { type: "number", title: "Row number" }, - }, - required: ["spreadsheetId", "rowNumber"], -}; - -const CATALOG: ToolCatalogEntry[] = [ - tool("r1", "list_spreadsheets", "List spreadsheets", "See the spreadsheets in this account.", true), - tool("r2", "read_sheet", "Read a sheet", "Get rows and cell values from a sheet.", true, READ_SHEET_SCHEMA), - tool("r3", "search_rows", "Search rows", "Find rows that match a query.", true), - tool("r4", "get_cell", "Get a cell", "Read a single cell value.", true), - tool("r5", "list_sheets", "List sheets", "See the tabs in a spreadsheet.", true), - tool("r6", "get_metadata", "Get spreadsheet info", "Read a spreadsheet's title and tabs.", true), - tool("r7", "describe_columns", "Describe columns", "List the column headers in a sheet.", true), - tool("w1", "append_row", "Append a row", "Add a new row to the bottom of a sheet.", false, APPEND_ROW_SCHEMA), - tool("w2", "update_cell", "Update a cell", "Change a single cell value.", false), - tool("w3", "create_sheet", "Add a sheet", "Create a new tab in a spreadsheet.", false), - tool("w4", "clear_range", "Clear a range", "Empty the cells in a range.", false), - tool("w5", "delete_row", "Delete a row", "Remove a row from a sheet permanently.", false, DELETE_ROW_SCHEMA), -]; - -function decisionTool(entry: ToolCatalogEntry, decision: ToolConnectionTestDecision) { - return { - toolName: entry.toolName, - gatewayToolName: `sheets__${entry.toolName}`, - displayName: entry.title, - risk: (entry.isReadOnly ? "read" : "write") as "read" | "write" | "destructive", - decision, - reasonCode: null, - matchedPolicyIds: [], - }; -} - -// Decisions: 9 allowed (7 read + 2 write), 2 ask first, 1 off. -const DECISIONS: Record = { - list_spreadsheets: "allowed", - read_sheet: "allowed", - search_rows: "allowed", - get_cell: "allowed", - list_sheets: "allowed", - get_metadata: "allowed", - describe_columns: "allowed", - append_row: "ask_first", - update_cell: "allowed", - create_sheet: "allowed", - clear_range: "ask_first", - delete_row: "off", -}; - -function buildAgent( - id: string, - name: string, - decisions: Record, - orgDepth = 1, -): StoryAgent { - const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName])); - return { - id, - name, - role: "engineer", - title: "Engineer", - status: "active", - orgDepth, - effectiveAccess: { - connectionId: CONNECTION, - toolCount: tools.length, - allowedCount: tools.filter((t) => t.decision === "allowed").length, - askFirstCount: tools.filter((t) => t.decision === "ask_first").length, - offCount: tools.filter((t) => t.decision === "off").length, - lastChangedAt: null, - lastChangedByAgentId: null, - lastChangedByName: null, - tools, - }, - }; -} - -const AGENTS: StoryAgent[] = [ - buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0), - buildAgent("agent-codex", "CodexCoder", { - ...DECISIONS, - create_sheet: "ask_first", - clear_range: "off", - }), -]; - -// --------------------------------------------------------------------------- -// DOM driver — expand a row, fill its fields, press Run. -// --------------------------------------------------------------------------- - -type Step = - | { kind: "expand"; title: string } - | { kind: "fill"; values: string[] } - | { kind: "click"; label: string }; - -function setInputValue(input: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; - setter?.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); -} - -function runScript(steps: Step[]) { - let index = 0; - const tick = (attempt: number) => { - if (index >= steps.length) return; - const step = steps[index]; - if (step.kind === "expand") { - const btn = Array.from(document.querySelectorAll("button")).find((b) => - b.textContent?.includes(step.title), - ); - if (btn) { - btn.click(); - index += 1; - window.setTimeout(() => tick(0), 60); - return; - } - } else if (step.kind === "fill") { - const inputs = Array.from(document.querySelectorAll("input")).filter( - (i) => i.getAttribute("aria-label") !== "Find an action" && i.getAttribute("aria-label") !== "Search agents", - ); - if (inputs.length >= step.values.length) { - step.values.forEach((value, i) => setInputValue(inputs[i] as HTMLInputElement, value)); - index += 1; - window.setTimeout(() => tick(0), 60); - return; - } - } else if (step.kind === "click") { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === step.label, - ); - if (btn) { - btn.click(); - index += 1; - window.setTimeout(() => tick(0), 60); - return; - } - } - if (attempt < 40) window.setTimeout(() => tick(attempt + 1), 50); - }; - tick(0); -} - -function seededClient(agents: StoryAgent[]): QueryClient { - const client = new QueryClient({ - defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } }, - }); - client.setQueryData(queryKeys.tools.testAgents(CONNECTION), { - agents: agents.map(({ effectiveAccess: _effectiveAccess, ...agent }) => agent), - }); - for (const agent of agents) { - client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, agent.id), { - access: agent.effectiveAccess, - }); - } - return client; -} - -function TestHost({ - script, - runResult, - runDelayMs = 120, - agents = AGENTS, - quarantined = [], -}: { - script?: Step[]; - runResult?: ToolConnectionTestCallResult; - runDelayMs?: number; - agents?: StoryAgent[]; - quarantined?: ToolCatalogEntry[]; -}) { - const client = useMemo(() => seededClient(agents), [agents]); - const fetchPatched = useRef(false); - - // Stub the test-call POST so the run states render without a backend. - useEffect(() => { - if (fetchPatched.current) return; - fetchPatched.current = true; - const original = window.fetch.bind(window); - window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.includes("/test-calls")) { - await new Promise((resolve) => window.setTimeout(resolve, runDelayMs)); - return Response.json(runResult ?? { decision: "allowed", invocationId: "inv-storybook", result: {} }); - } - return original(input, init); - }; - return () => { - window.fetch = original; - }; - }, [runResult, runDelayMs]); - - useEffect(() => { - if (!script) return; - const timer = window.setTimeout(() => runScript(script), 150); - return () => window.clearTimeout(timer); - }, [script]); - - return ( - -
- -
-
- ); -} - -const meta: Meta = { - title: "Apps/Test tab (PAP-11350)", - parameters: { layout: "fullscreen" }, -}; -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - name: "Default — grouped actions + access badges", - render: () => , -}; - -export const AllowedResult: Story = { - name: "Allowed — result panel", - render: () => ( - - ), -}; - -export const AskFirstResult: Story = { - name: "Ask first — sent for approval", - render: () => ( - - ), -}; - -export const ErrorResult: Story = { - name: "Error — what the app said + what to try", - render: () => ( - - ), -}; - -export const RunningState: Story = { - name: "Running — in-flight card", - render: () => ( - - ), -}; - -export const OffAction: Story = { - name: "Off — explanation + open Permissions", - render: () => , -}; - -// PAP-11404 — Off side panel polish: audit hint + quarantined variant. - -const AGENTS_WITH_AUDIT: StoryAgent[] = AGENTS.map((agent, i) => - i === 0 - ? { - ...agent, - effectiveAccess: { - ...agent.effectiveAccess, - lastChangedAt: new Date("2026-06-17T09:00:00Z").toISOString(), - lastChangedByAgentId: "agent-admin", - lastChangedByName: "Dotta", - }, - } - : agent, -); - -export const OffAuditHint: Story = { - name: "Off — last-changed audit hint (PAP-11404)", - render: () => ( - - ), -}; - -const QUARANTINED: ToolCatalogEntry[] = [ - { ...tool("q1", "rename_sheet", "Rename a sheet", "Change a sheet's name.", false), status: "quarantined" } as ToolCatalogEntry, -]; - -export const QuarantinedAction: Story = { - name: "New — quarantined action not yet on (PAP-11404)", - render: () => ( - - ), -};