diff --git a/doc/grok-bot-agent.md b/doc/grok-bot-agent.md index dc842362b26..ee04b8a6f3c 100644 --- a/doc/grok-bot-agent.md +++ b/doc/grok-bot-agent.md @@ -22,7 +22,9 @@ Use **Save & exit** to preserve unfinished setup. See the official Manage the Bot from its agent's **Runtime** settings. This shows connection health, routine configuration, and verification. Model selection, local paths, and runtime tests are managed outside Paperclip; use **Verify connection** to -check both directions. +check both directions. Use **Reconnect agent access** to restore the same agent's +connection. Select **Copy code** beside the masked one-use pairing code, then +paste it into Paperclip's authorization page. Keep it out of Bot chat. Assign ordinary tasks to the agent. Read its progress, answer its question cards, and review its documents and files in the task thread. The Bot can initiate a diff --git a/ui/src/components/GrokBotConnection.tsx b/ui/src/components/GrokBotConnection.tsx index 0aa3fd32060..0e7e7bc2c7c 100644 --- a/ui/src/components/GrokBotConnection.tsx +++ b/ui/src/components/GrokBotConnection.tsx @@ -1,15 +1,17 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { DotPairing } from "@paperclipai/shared"; import { grokBotApi } from "@/api/grokBot"; import { buildGrokAccessPrompt, buildGrokRoutinePrompt } from "@/lib/grok-bot-setup-prompt"; import { AgentSetupPrompt } from "./AgentSetupPrompt"; -import { Button } from "./ui/button"; +import { CopyText } from "./CopyText"; +import { Button, buttonVariants } from "./ui/button"; import { Input } from "./ui/input"; import { queryKeys } from "@/lib/queryKeys"; /** Configuration writes update the authoritative binding, never an unsaved agent form. */ export function GrokBotConnection({ companyId, agentId }: { companyId?: string; agentId?: string }) { + const pairingCodeId = useId(); const cache = useQueryClient(); const key = ["grok-binding", companyId, agentId]; const state = useQuery({ queryKey: key, enabled: !!companyId && !!agentId, @@ -75,7 +77,14 @@ export function GrokBotConnection({ companyId, agentId }: { companyId?: string; {pairing && state.data?.resourceUrl && <> - +
+ +
+ e.target.select()} /> + Copy code +
+

Copy this code into Paperclip’s authorization page. Keep it out of Bot chat.

+
} } {binding?.assignment &&

{binding.assignment.status === "offered" ? "Awaiting acknowledgement" : "Working in a Paperclip work session"}

Revokes this session’s Paperclip authority and stops future dispatch. Remote execution may continue. Open Grok Bot to inspect it.

} diff --git a/ui/src/components/new-agent/GrokBotSetup.render.test.tsx b/ui/src/components/new-agent/GrokBotSetup.render.test.tsx index 88228bf05ca..3a2c873eb89 100644 --- a/ui/src/components/new-agent/GrokBotSetup.render.test.tsx +++ b/ui/src/components/new-agent/GrokBotSetup.render.test.tsx @@ -35,7 +35,7 @@ it("restores the same saved agent when a failed initial resume succeeds on retry expect(retry).toBeTruthy(); flushSync(() => retry!.click()); await vi.waitFor(() => expect([...document.querySelectorAll("label")].some(label => label.textContent === "Webhook key")).toBe(true)); - expect(api.connection).toHaveBeenCalledWith(companyId, agentId, expect.any(AbortSignal)); + await vi.waitFor(() => expect(api.connection).toHaveBeenCalledWith(companyId, agentId, expect.any(AbortSignal))); expect(api.create).not.toHaveBeenCalled(); expect(api.pair).not.toHaveBeenCalled(); }); @@ -82,3 +82,44 @@ it("copies the masked pairing code and offers a retry after a clipboard failure" expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, pairingCode); expect(document.body.textContent).not.toContain(pairingCode); }); + +it("copies a masked reconnect code without adding it to Bot instructions and retries clipboard failure", async () => { + const pairingCode = "synthetic_reconnect_code_12345"; + api.connection.mockResolvedValue({ enabled: true, canConfigureConnection: true, + resourceUrl: "https://paperclip.example/mcp/runner", binding: null }); + api.pair.mockResolvedValue({ bindingId: "binding-fixture", pairingCode, + expiresAt: new Date(Date.now() + 60_000).toISOString() }); + vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable")).mockResolvedValue(); + cache = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); + flushSync(() => root.render()); + const reconnect = await vi.waitFor(() => { + const button = [...host.querySelectorAll("button")].find(element => element.textContent === "Reconnect agent access")!; + expect(button.disabled).toBe(false); + return button; + }); + flushSync(() => reconnect.click()); + const copy = await vi.waitFor(() => { + const button = host.querySelector('button[aria-label="Copy pairing code"]'); + expect(button).toBeTruthy(); + return button!; + }); + const label = [...host.querySelectorAll("label")].find(element => element.textContent === "One-use pairing code")!; + const input = document.getElementById(label.htmlFor) as HTMLInputElement; + expect(input.type).toBe("password"); + expect(input.readOnly).toBe(true); + expect(input.value).toBe(pairingCode); + expect(host.textContent).not.toContain(pairingCode); + flushSync(() => copy.click()); + await vi.waitFor(() => expect(host.textContent).toContain("Copy failed")); + flushSync(() => copy.click()); + await vi.waitFor(() => expect(host.textContent).toContain("Copied!")); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(1, pairingCode); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, pairingCode); + const instructions = host.querySelector('button[aria-label="Copy connection instructions"]')!; + flushSync(() => instructions.click()); + await vi.waitFor(() => expect(copyTextToClipboard).toHaveBeenCalledTimes(3)); + expect(vi.mocked(copyTextToClipboard).mock.calls[2]![0]).toContain("https://paperclip.example/mcp/runner"); + expect(vi.mocked(copyTextToClipboard).mock.calls[2]![0]).not.toContain(pairingCode); + expect(api.pair).toHaveBeenCalledExactlyOnceWith("company-fixture", "agent-fixture", undefined); +}); diff --git a/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx index 70370e74e60..886c24cfe55 100644 --- a/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx +++ b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import type { AgentDetail, GrokConnection } from "@paperclipai/shared"; import { ConfigurationTab } from "@/pages/AgentDetail"; import { storybookAgents } from "../../../fixtures/paperclipData"; @@ -36,6 +36,8 @@ function Configuration({ disconnected = false }: { disconnected?: boolean }) { const fixture: typeof fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); if (url.pathname === `/api/companies/${agent.companyId}/agents/${agent.id}/grok-binding`) { + if (disconnected && init?.method === "POST") return Response.json({ bindingId, + pairingCode: "synthetic-reconnect-code", expiresAt: new Date(Date.now() + 900000).toISOString() }); if ((init?.method ?? "GET") !== "GET") return Response.json({ error: "This preview does not authorize connections." }, { status: 405 }); return Response.json(disconnected ? { ...connection, binding: null } : connection); } @@ -71,6 +73,12 @@ export const ConnectedBot: Story = { play: async ({ canvasElement }) => { await expect(page.queryByText("AI connection", { exact: true })).not.toBeInTheDocument(); } }; export const DisconnectedBot: Story = { args: { disconnected: true }, play: async ({ canvasElement }) => { - await expect(await within(canvasElement).findByRole("button", { name: "Reconnect agent access" })).toBeVisible(); + const page = within(canvasElement); + const reconnect = await page.findByRole("button", { name: "Reconnect agent access" }); + await waitFor(() => expect(reconnect).toBeEnabled()); + await userEvent.click(reconnect); + await expect(await page.findByRole("button", { name: "Copy pairing code" })).toBeVisible(); + await expect(page.getByLabelText("One-use pairing code")).toHaveAttribute("type", "password"); + await expect(page.queryByText("synthetic-reconnect-code")).not.toBeInTheDocument(); } }; export const Mobile: Story = { ...ConnectedBot, globals: { viewport: { value: "mobile1", isRotated: false } } };