Improve GitHub bot management hierarchy

Separate behavior and access, retain drafts across tabs, and make current reviews and task conversations easier to scan. Add production Storybook journeys and regression coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 15:39:06 -05:00
1 parent bfc9cc504f
commit 7c1d2503db
12 files changed
+2129 -789

No files matched your search

+21 -2
View File
@@ -33,8 +33,8 @@ required GitHub checks, read
New connections default to authorized mentions, advisory reviews, linked-member
access, guests off, and enabled bot GitHub tools. Existing instructions, explicit
behavior, and narrower saved repository restrictions remain intact. Later
repository additions require enablement in connection settings. Advanced review
rules and prompts also live in settings.
repository additions require enablement in Access. Advanced review rules and
prompts live in Settings.
Local instances receive public callbacks and signed events through an enrolled
Paperclip Cloud connector, using outbound requests instead of a public tunnel.
@@ -57,6 +57,25 @@ Connection setup reports tool/runtime support and isolation separately; an actua
task is still required to prove that the chosen provider can execute in the
selected environment.
## Manage a connected bot
- **Settings:** edit instructions, choose when the bot runs, and set review output.
Event-specific instructions, filters, formal approvals, and repository overrides
are available in disclosures.
- **Access:** choose enabled repositories, allowed people, each person’s automatic
events, and the bot’s GitHub tools. Repository switches save immediately. Other
changes use **Save changes**. A linked account alone does not grant selected-member
access. External contributors still require a sponsor and restricted permissions.
- **Reviews:** see the latest review for each pull request. The result names the
reviewed commit; earlier reviews remain in history. A previous passing result
does not stand in for a pending review of a newer commit.
- **Conversations:** follow the task title to Paperclip or the repository/thread
label to GitHub.
Settings and Access share unsaved edits while you switch connection tabs. Save
before reloading or leaving the connection. No permissions change merely by
opening a tab or configuring an implicit linked member’s individual event settings.
## Who can start work
Linked members may be allowed together or selected individually. Teammates
@@ -253,3 +253,35 @@ Qualification limits: personal-account setup, delayed organization approval and
### Cleanup completed
Closed disposable issues 1, 2 and 4 and PR 3 without merging. Deleted only the test branch. Retained the App, original connection, bot, vault and test-drive data. The public relay/tunnel was stopped. The private test drive remains available on port 3110 for inspection. Fixes and qualification record were pushed to draft PR #15416; current-head CI/review remain outstanding.
### Management page hierarchy qualification
The connected bot now separates instructions, triggers, and review output in
Settings from repository, people, and tool permissions in Access. Each linked
person appears once. Advanced controls are disclosed. Settings and Access share
one versioned draft across all connection tabs. Reviews show the latest assessment
per repository/PR and preserve earlier commit evidence in history. Conversation
titles link directly to tasks; provider labels link directly to their threads.
Walkthrough: Connectors → search GitHub → Animal Bot → Settings → Access → Reviews
→ Conversations. A harmless trailing newline was saved from Access after editing
Settings, persisted after a reload, and then was removed through the UI. Redacted
API snapshots confirm the full configuration returned exactly to its original
value (revision 4 → 6). Repository and people permissions were not changed.
The real instance was inspected at its desktop width, 820px, and 390px with the
actual mobile shell. Review history expanded correctly and the ASCII animal
rendered as a code block. Production Storybook fixtures verified a saved edit,
a pending current commit with its previous passing score hidden, a recoverable
configuration failure, and a mobile empty conversation state. These fixtures do
not qualify any new provider execution. Screenshots and configuration comparisons
are in the existing local E2E evidence directory.
Focused UI checks cover draft retention, revision conflicts, isolated repository
updates, override preservation/unique field IDs, implicit linked-member event
configuration, mentions-only trigger preservation, latest-review ordering, pending
head presentation, and GitHub thread labels. The affected management, setup,
AgentMail, sidebar, and shared UI-contract group has 47 passing tests. Repository
typecheck/build, Storybook build, and token gates pass. The earlier full-suite
failure and outstanding current-head CI/review remain as recorded above.
@@ -0,0 +1,110 @@
import { MessageSquare } from "lucide-react";
import type { ChatConversation, ChatProvider } from "@/api/chatEndpoints";
import { EmptyState } from "@/components/EmptyState";
import { StatusBadge } from "@/components/StatusBadge";
import { Link } from "@/lib/router";
import { formatDateTime } from "@/lib/utils";
import { AppLogo } from "../AppLogo";
export function conversationDestination(
row: ChatConversation,
provider: ChatProvider,
) {
if (provider === "github" && row.externalUrl) {
try {
const url = new URL(row.externalUrl);
const parts = url.pathname.split("/");
if (
url.hostname === "github.com" &&
["issues", "pull"].includes(parts[3]) &&
/^\d+$/.test(parts[4])
)
return `${parts[1]}/${parts[2]} #${parts[4]}`;
} catch {
/* Preserve the provider label when no thread URL is available. */
}
}
return row.externalLabel;
}
export function ChatConversationList({
rows,
provider,
}: {
rows: ChatConversation[];
provider: ChatProvider;
}) {
if (!rows.length)
return (
<EmptyState
icon={MessageSquare}
message="No conversations yet"
description={
provider === "agentmail"
? "Send an email to this agent’s address to start one."
: "Mention the agent in an enabled destination to start one."
}
/>
);
return (
<ul
aria-label="Conversations"
className="divide-y divide-border border-y border-border"
>
{rows.map((row) => (
<li key={row.id} className="flex items-start gap-3 py-4 text-sm">
<AppLogo
name={provider}
brandKey={provider}
compact
className="mt-0.5 size-5! shrink-0 rounded-sm bg-transparent"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="break-words">
{row.issueIdentifier && (
<span className="mr-2 text-xs text-muted-foreground">
{row.issueIdentifier}
</span>
)}
{row.issueId ? (
<Link
className="break-words font-medium hover:underline"
to={`/issues/${row.issueId}`}
>
{row.issueTitle ?? row.issueIdentifier ?? "Paperclip task"}
</Link>
) : (
<span className="font-medium">
{row.issueTitle ?? "Waiting for task"}
</span>
)}
</div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-muted-foreground">
{row.externalUrl ? (
<a
href={row.externalUrl}
target="_blank"
rel="noreferrer"
className="break-words hover:text-foreground hover:underline"
>
{conversationDestination(row, provider)}
</a>
) : (
<span className="break-all">{row.externalLabel}</span>
)}
{row.updatedAt && (
<time dateTime={row.updatedAt}>
{formatDateTime(row.updatedAt)}
</time>
)}
{row.state === "completed" && <span>Completed</span>}
</div>
</div>
{row.state !== "active" && row.state !== "completed" && (
<StatusBadge status={row.state} />
)}
</li>
))}
</ul>
);
}
+19 -41
View File
@@ -5,6 +5,8 @@ import { SlackAvatarSettings } from "./SlackAvatarStep";
import { agentsApi } from "@/api/agents";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { resolveAgentAppearance } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { ChatConversationList } from "./ChatConversationList";
import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement";
import { EmailEndpointSettings } from "./EmailEndpointSetup";
import { EmailConnectionAccess } from "@/components/EmailConnectionAccess";
@@ -243,6 +245,11 @@ export function ChatEndpointDetail() {
: false,
});
const endpoint = endpointQuery.data;
const avatarAgent = useQuery({
queryKey: queryKeys.agents.detail(endpoint?.assignedAgentId ?? ""),
queryFn: () => agentsApi.get(endpoint!.assignedAgentId),
enabled: endpoint?.provider === "github",
});
const [copyStatus, setCopyStatus] = useState<string | null>(null);
useEffect(() => {
@@ -291,12 +298,14 @@ export function ChatEndpointDetail() {
return (
<div className="max-w-5xl space-y-6 pb-12">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex min-w-0 items-center gap-3">
{endpoint.provider === "github" && <AgentAvatar agent={avatarAgent.data ?? { id: endpoint.assignedAgentId, name: endpoint.assignedAgentName }} size={40} />}
<div className="min-w-0">
<h1 className="text-xl font-bold">
{endpoint.assignedAgentName} in {providerNames[endpoint.provider]}
{endpoint.provider === "github" ? <Link to={`/agents/${endpoint.assignedAgentId}`} className="hover:underline">{endpoint.assignedAgentName}</Link> : `${endpoint.assignedAgentName} in ${providerNames[endpoint.provider]}`}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{endpoint.providerAccountLabel ?? (endpoint.provider === "agentmail" ? endpoint.botExternalId ?? "Email connection" : "Chat connection")}
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{endpoint.provider === "github" ? <><AppLogo name="GitHub" brandKey="github" compact className="size-4! rounded-sm bg-transparent" />{endpoint.botLabel ?? endpoint.botUsername ?? "GitHub"}{endpoint.providerAccountLabel && <span>· {endpoint.providerAccountLabel}</span>}</> : endpoint.providerAccountLabel ?? (endpoint.provider === "agentmail" ? endpoint.botExternalId ?? "Email connection" : "Chat connection")}
</p>
{endpoint.provider === "imessage-photon" && endpoint.botExternalId && endpoint.photonAllocation !== "shared" && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
@@ -308,6 +317,7 @@ export function ChatEndpointDetail() {
<span role="status" className="text-muted-foreground">{copyStatus}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
{setupIncomplete ? (
@@ -325,14 +335,11 @@ export function ChatEndpointDetail() {
{endpoint.status !== "active" && <StatusBadge status={endpoint.status} />}
</div>
</header>
{activeTab === "settings" && (
<>
{endpoint.provider === "github" && <GitHubBotManagement endpoint={endpoint} view="settings" />}
{endpoint.provider !== "github" && <Settings endpointId={endpoint.id} endpoint={endpoint} />}
</>
)}
{endpoint.provider === "github" && <div hidden={activeTab !== "settings" && activeTab !== "access"}>
<GitHubBotManagement key={endpoint.id} endpoint={endpoint} view={activeTab === "access" ? "access" : "settings"} />
</div>}
{activeTab === "settings" && endpoint.provider !== "github" && <Settings endpointId={endpoint.id} endpoint={endpoint} />}
{activeTab === "reviews" && endpoint.provider === "github" && <GitHubReviews endpointId={endpoint.id} />}
{activeTab === "access" && endpoint.provider === "github" && <GitHubBotManagement endpoint={endpoint} view="access" />}
{activeTab === "access" && endpoint.provider === "agentmail" && <EmailAccess endpoint={endpoint} />}
{activeTab === "access" && endpoint.provider !== "github" && endpoint.provider !== "agentmail" && (
<Access
@@ -769,43 +776,14 @@ function Conversations({
queryFn: () => chatEndpointsApi.listConversations(endpointId),
...liveChatQueryOptions,
});
const rows = query.data ?? [];
return (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Conversations</h2>
</div>
{query.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading conversations…</p> : query.isError ? (
<div className="space-y-3">
<p role="alert" className="text-sm text-destructive">Conversations could not be loaded.</p>
<Button variant="outline" onClick={() => void query.refetch()}>Try again</Button>
</div>
) : rows.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{provider === "agentmail"
? "No email conversations yet. Send an email to this agent’s address to start one."
: "No conversations yet. Address the agent in an enabled destination to start one."}
</p>
) : (
<ul aria-label="Conversations" className="divide-y divide-border overflow-x-auto border-y border-border">
{rows.map((row) => (
<li key={row.id} className="flex min-w-xl items-center gap-3 px-2 py-3 text-sm transition-colors hover:bg-accent/50">
<AppLogo name={providerNames[provider]} brandKey={provider} compact className="size-5! rounded-sm bg-transparent" />
<div className="flex min-w-0 max-w-56 items-center gap-2">
<span className="truncate font-medium" title={row.externalLabel}>{row.externalLabel}</span>
{row.externalUrl && <a href={row.externalUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">Open {providerNames[provider]}<ExternalLink className="size-3" /></a>}
</div>
<span aria-hidden="true" className="text-muted-foreground">·</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate" title={row.issueTitle ?? undefined}>{row.issueTitle ?? "Waiting for task"}</span>
{row.issueId && <Link to={`/issues/${row.issueId}`} className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">Open task<ExternalLink className="size-3" /></Link>}
</div>
<span className="hidden shrink-0 text-xs text-muted-foreground xl:inline">{row.issueIdentifier}</span>
{row.state !== "active" && <StatusBadge status={row.state} />}
</li>
))}
</ul>
)}
) : <ChatConversationList rows={query.data} provider={provider} />}
</section>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,418 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
defaultGitHubReviewPolicy,
type GitHubChatConfiguration,
type GitHubTaskReview,
} from "@paperclipai/shared";
import { ChatEndpointDetail } from "./ChatEndpointDetail";
import { GitHubReviewList, groupGitHubReviews } from "./GitHubBotManagement";
import { GitHubPolicyEditor } from "./GitHubBotConfiguration";
import { conversationDestination } from "./ChatConversationList";
import { queryKeys } from "@/lib/queryKeys";
import { TooltipProvider } from "@/components/ui/tooltip";
const mocks = vi.hoisted(() => ({
tab: "settings",
get: vi.fn(),
config: vi.fn(),
save: vi.fn(),
resources: vi.fn(),
updateResources: vi.fn(),
reviews: vi.fn(),
members: vi.fn(),
links: vi.fn(),
setBreadcrumbs: vi.fn(),
}));
vi.mock("@/api/githubChat", () => ({
githubChatApi: {
configuration: mocks.config,
save: mocks.save,
reviews: mocks.reviews,
},
}));
vi.mock("@/api/chatEndpoints", () => ({
chatEndpointsApi: {
get: mocks.get,
listResources: mocks.resources,
updateResources: mocks.updateResources,
listPrincipals: mocks.links,
},
}));
vi.mock("@/api/access", () => ({ accessApi: { listMembers: mocks.members } }));
vi.mock("@/api/agents", () => ({ agentsApi: { get: vi.fn() } }));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mocks.setBreadcrumbs }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/lib/router", () => ({
useParams: () => ({ endpointId: "bot", tab: mocks.tab }),
useNavigate: () => vi.fn(),
Link: ({
children,
to,
...props
}: React.ComponentProps<"a"> & { to: string }) => (
<a href={to} {...props}>
{children}
</a>
),
Navigate: () => null,
}));
const endpoint = {
id: "bot",
companyId: "company",
provider: "github",
assignedAgentId: "agent",
assignedAgentName: "Maya",
status: "active",
allowUnlinkedPeople: false,
setup: { step: "complete" },
};
const base: GitHubChatConfiguration = {
version: 1,
toolsEnabled: true,
responsibleUserId: "member",
memberAccess: "selected",
people: [
{
kind: "member",
userId: "member",
githubUserId: "42",
login: "maya",
automaticReviews: true,
},
],
defaults: {
...defaultGitHubReviewPolicy(),
instructions: "Keep existing instructions",
issueOpened: true,
},
repositories: {},
};
describe("GitHub bot management", () => {
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
vi.clearAllMocks();
mocks.tab = "settings";
mocks.get.mockResolvedValue(endpoint);
mocks.config.mockResolvedValue({
revision: 4,
configuration: structuredClone(base),
});
mocks.resources.mockResolvedValue([
{
id: "repo",
type: "repository",
label: "acme/web",
enabled: true,
availability: "available",
metadata: { providerRepositoryId: "100" },
},
]);
mocks.links.mockResolvedValue([
{
id: "link",
principalId: "identity",
status: "linked",
githubUserId: "42",
githubLogin: "maya",
paperclipUserId: "member",
paperclipUserLabel: "Maya",
},
]);
mocks.members.mockResolvedValue({
members: [
{
principalId: "member",
status: "active",
membershipRole: "owner",
user: { name: "Maya" },
},
],
});
mocks.reviews.mockResolvedValue([]);
mocks.save.mockImplementation(async (_id, revision, configuration) => ({
revision: revision + 1,
configuration,
}));
mocks.updateResources.mockResolvedValue([]);
client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(queryKeys.agents.detail("agent"), {
id: "agent",
name: "Maya",
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
client.clear();
container.remove();
});
async function render(tab = mocks.tab) {
mocks.tab = tab;
await act(async () =>
root.render(
<QueryClientProvider client={client}>
<TooltipProvider>
<ChatEndpointDetail />
</TooltipProvider>
</QueryClientProvider>,
),
);
await vi.waitFor(() =>
expect(
container.querySelector(
'textarea[placeholder="What should this agent do on GitHub?"]',
) || container.querySelector("#github-member-access"),
).not.toBeNull(),
);
}
async function click(name: string) {
const target = [...container.querySelectorAll("button")].find(
(b) =>
b.getAttribute("aria-label") === name || b.textContent?.trim() === name,
);
expect(target).toBeDefined();
await act(async () => target!.click());
}
async function input(
element: HTMLTextAreaElement | HTMLSelectElement,
value: string,
) {
await act(async () => {
Object.getOwnPropertyDescriptor(
element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLSelectElement.prototype,
"value",
)!.set!.call(element, value);
element.dispatchEvent(
new Event(element instanceof HTMLSelectElement ? "change" : "input", {
bubbles: true,
}),
);
});
}
it("keeps one draft across Settings, Access, and read-only tabs, then saves with the original revision", async () => {
await render();
await input(container.querySelector("textarea")!, "Edited instructions");
await render("access");
await vi.waitFor(() => expect(container.textContent).toContain("@maya"));
await click("Automatic events for @maya");
await render("reviews");
await render("settings");
expect(container.querySelector("textarea")?.value).toBe(
"Edited instructions",
);
expect(mocks.save).not.toHaveBeenCalled();
await click("Save changes");
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(1));
const [id, revision, saved] = mocks.save.mock.calls[0];
expect(id).toBe("bot");
expect(revision).toBe(4);
expect(saved).toEqual({
...base,
defaults: { ...base.defaults, instructions: "Edited instructions" },
people: [{ ...base.people[0], automaticReviews: false }],
});
await vi.waitFor(() =>
expect(container.textContent).toContain("Changes saved."),
);
expect(
[...container.querySelectorAll("button")].some(
(b) => b.textContent === "Save changes",
),
).toBe(false);
});
it("retains a rejected draft and lets the user discard it", async () => {
mocks.save.mockRejectedValue(
new Error("Configuration changed. Reload before saving."),
);
await render();
await input(container.querySelector("textarea")!, "Unsaved");
await click("Save changes");
await vi.waitFor(() =>
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Configuration changed",
),
);
expect(container.querySelector("textarea")?.value).toBe("Unsaved");
await click("Discard changes");
expect(container.querySelector("textarea")?.value).toBe(
base.defaults.instructions,
);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it("saves repository restrictions separately without touching the behavior draft", async () => {
await render();
await input(container.querySelector("textarea")!, "Unsaved behavior");
await render("access");
await click("acme/web");
await vi.waitFor(() =>
expect(mocks.updateResources).toHaveBeenCalledWith("bot", [
{ id: "repo", enabled: false },
]),
);
expect(mocks.save).not.toHaveBeenCalled();
await render("settings");
expect(container.querySelector("textarea")?.value).toBe("Unsaved behavior");
});
it("creates repository overrides without overwriting defaults or duplicating field IDs", async () => {
await render();
await input(
container.querySelector<HTMLSelectElement>("#github-policy-repository")!,
"100",
);
await click("Use custom settings for this repository");
const areas = [
...container.querySelectorAll<HTMLTextAreaElement>(
'textarea[placeholder="What should this agent do on GitHub?"]',
),
];
expect(areas).toHaveLength(2);
await input(areas[1], "Repository guidance");
const ids = [...container.querySelectorAll("[id]")].map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
await click("Save changes");
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
const saved = mocks.save.mock.calls[0][2];
expect(saved.defaults).toEqual(base.defaults);
expect(saved.repositories["100"].instructions).toBe("Repository guidance");
});
it("configures implicit linked-member events without switching to a narrower selected-member list", async () => {
mocks.config.mockResolvedValue({
revision: 4,
configuration: { ...base, memberAccess: "all_linked", people: [] },
});
await render("access");
await vi.waitFor(() =>
expect(container.textContent).toContain("Configure events"),
);
await click("Configure events");
await click("Save changes");
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
expect(mocks.save.mock.calls[0][2].memberAccess).toBe("all_linked");
expect(mocks.save.mock.calls[0][2].people[0].automaticReviews).toBe(false);
});
it("keeps saved automatic triggers when changing to mentions-only", async () => {
const change = vi.fn();
await act(async () =>
root.render(
<GitHubPolicyEditor policy={base.defaults} onChange={change} />,
),
);
await input(
container.querySelector<HTMLSelectElement>(
'select[id$="github-invocation"]',
)!,
"mentions_only",
);
expect(change.mock.calls[0][0]).toEqual({
...base.defaults,
invocation: "mentions_only",
});
});
});
function review(
id: string,
repositoryId: string,
pullNumber: number,
createdAt: string,
updatedAt = createdAt,
) {
return {
id,
repositoryId,
pullNumber,
createdAt,
updatedAt,
repository: "acme/web",
headSha: id,
issueId: "task",
event: { title: "Review focus handling" },
state: "queued",
assessment: null,
conclusion: null,
} as GitHubTaskReview;
}
describe("review history and thread labels", () => {
it("groups by repository and PR and orders by creation, not late updates to an older head", () => {
const old = review(
"old",
"repo",
1,
"2026-10-07T10:00:00Z",
"2026-10-07T14:00:00Z",
);
const current = review("current", "repo", 1, "2026-10-07T11:00:00Z");
const other = review("other", "another-repo", 1, "2026-10-07T12:00:00Z");
expect(groupGitHubReviews([old, current, other])).toEqual([
[other],
[current, old],
]);
});
it("does not present a previous passing score as the pending current commit’s result", async () => {
const old = review("old", "repo", 1, "2026-10-07T10:00:00Z");
old.state = "completed";
old.conclusion = "success";
old.assessment = {
reviewedCommit: "old",
score: 5,
complete: true,
summary: "Previous result",
rationale: "Complete",
findings: [],
coverage: { reviewedPaths: [], omittedPaths: [], limitations: [] },
};
const current = review("current", "repo", 1, "2026-10-07T11:00:00Z");
const container = document.createElement("div");
const root = createRoot(container);
try {
await act(async () =>
root.render(<GitHubReviewList reviews={[old, current]} />),
);
const article = container.querySelector("article")!;
const latest = article.cloneNode(true) as HTMLElement;
latest.querySelectorAll("details").forEach((details) => details.remove());
expect(latest.textContent).not.toContain("5/5");
expect(latest.textContent).toContain("Queued");
const history = [...article.querySelectorAll("details")].find((e) =>
e.querySelector("summary")?.textContent?.includes("earlier review"),
);
expect(history?.textContent).toContain("5/5 · Passed");
expect(history?.open).toBe(false);
} finally {
await act(async () => root.unmount());
}
});
it("identifies GitHub thread numbers while preserving labels for other providers and invalid URLs", () => {
const row = {
externalLabel: "acme/web",
externalUrl: "https://github.com/acme/web/issues/42#issuecomment-1",
} as any;
expect(conversationDestination(row, "github")).toBe("acme/web #42");
expect(conversationDestination(row, "slack")).toBe("acme/web");
expect(
conversationDestination({ ...row, externalUrl: "invalid" }, "github"),
).toBe("acme/web");
});
});
+432 -289
View File
@@ -1,13 +1,33 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ExternalLink, RefreshCw } from "lucide-react";
import type { GitHubChatConfiguration } from "@paperclipai/shared";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ExternalLink,
GitPullRequest,
MoreHorizontal,
RefreshCw,
} from "lucide-react";
import type {
GitHubChatConfiguration,
GitHubTaskReview,
} from "@paperclipai/shared";
import {
githubChatApi,
type GitHubConfigurationRecord,
} from "@/api/githubChat";
import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints";
import {
chatEndpointsApi,
type ChatEndpoint,
type ChatEndpointResource,
} from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/EmptyState";
import { MarkdownBody } from "@/components/MarkdownBody";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Link } from "@/lib/router";
import { formatDateTime } from "@/lib/utils";
import {
@@ -17,6 +37,81 @@ import {
githubSelectClass,
} from "./GitHubBotConfiguration";
export function GitHubRepositoryAccess({
resources,
managementUrl,
pending,
onRefresh,
onChange,
}: {
resources: ChatEndpointResource[];
managementUrl: string;
pending: boolean;
onRefresh: () => void;
onChange: (id: string, enabled: boolean) => void;
}) {
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold">Repositories</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Manage repositories"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<a href={managementUrl} target="_blank" rel="noreferrer">
Manage on GitHub
<ExternalLink className="size-4" />
</a>
</DropdownMenuItem>
<DropdownMenuItem disabled={pending} onClick={onRefresh}>
<RefreshCw className="size-4" />
Refresh repositories
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="text-xs text-muted-foreground">
Choose where the bot can receive messages and use tools. Repository
changes save immediately.
</p>
<div className="divide-y divide-border border-y border-border">
{resources.map((resource) => (
<div key={resource.id} className="py-3">
<GitHubToggle
label={resource.label ?? resource.providerResourceId}
checked={resource.enabled}
disabled={
pending ||
(resource.availability !== "available" && !resource.enabled)
}
description={
resource.availability === "available"
? undefined
: "Unavailable on GitHub. Update the App’s installation access, then refresh."
}
onChange={(enabled) => onChange(resource.id, enabled)}
/>
</div>
))}
{resources.length === 0 && (
<p className="py-4 text-sm text-muted-foreground">
No repositories available. Add repository access on GitHub, then
refresh.
</p>
)}
</div>
</section>
);
}
export function GitHubBotManagement({
endpoint,
view,
@@ -24,6 +119,7 @@ export function GitHubBotManagement({
endpoint: ChatEndpoint;
view: "settings" | "access";
}) {
const client = useQueryClient();
const query = useQuery({
queryKey: ["github-bot-configuration", endpoint.id],
queryFn: () => githubChatApi.configuration(endpoint.id),
@@ -38,6 +134,11 @@ export function GitHubBotManagement({
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
const record = draft ?? query.data;
const dirty = Boolean(
draft &&
JSON.stringify(draft.configuration) !==
JSON.stringify(query.data?.configuration),
);
const edit = (configuration: GitHubChatConfiguration) => {
if (record) setDraft({ ...record, configuration });
setNotice("");
@@ -68,179 +169,149 @@ export function GitHubBotManagement({
</Button>
</p>
);
if (!record)
if (!record || resources.isPending)
return (
<p className="text-sm text-muted-foreground">Loading configuration…</p>
<p role="status" className="text-sm text-muted-foreground">
Loading configuration…
</p>
);
const config = record.configuration;
const repositories = (resources.data ?? []).filter(
(resource) => resource.type === "repository",
);
const override = repository ? config.repositories[repository] : undefined;
return (
<section className="max-w-3xl space-y-6">
<div className="space-y-2">
<h2 className="text-lg font-semibold">
{view === "access"
? "Who can start work"
: "Agent and review behavior"}
</h2>
<p className="text-sm text-muted-foreground">
{endpoint.assignedAgentName} is permanently assigned to this bot.
GitHub messages create or continue Paperclip tasks; reviews are
results of those runs.
</p>
<Link
className="text-sm underline"
to={`/apps/${endpoint.connectionId}`}
>
Bot’s GitHub tool connection
</Link>
</div>
{view === "access" ? (
<GitHubAccessEditor
endpointId={endpoint.id}
companyId={endpoint.companyId}
configuration={config}
onChange={edit}
/>
) : (
<>
<GitHubToggle
label="Agent can use this bot’s GitHub tools"
description="Uses the same GitHub App, limited to this bot’s enabled repositories and bound tasks. Tool policies still apply."
checked={config.toolsEnabled}
onChange={(toolsEnabled) => edit({ ...config, toolsEnabled })}
/>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-medium">Repository access</h3>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={() =>
void act(async () => {
await githubChatApi.refreshRepositories(endpoint.id);
await resources.refetch();
setNotice(
"Repository access refreshed. New repositories stay disabled.",
);
})
}
>
<RefreshCw className="size-4" />
Refresh
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={
endpoint.setup?.github?.managementUrl ??
endpoint.setup?.github?.installationUrl ??
"https://github.com/settings/installations"
}
target="_blank"
rel="noreferrer"
>
Configure on GitHub
<ExternalLink className="size-4" />
</a>
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
These repositories come from the bot App’s installation. Choose
where this bot can receive messages and use tools in Paperclip.
</p>
{resources.data
?.filter((r) => r.type === "repository")
.map((resource) => (
<GitHubToggle
key={resource.id}
label={resource.label ?? resource.providerResourceId}
description={
resource.availability === "available"
? undefined
: "Installation access is unavailable. Update access on GitHub and refresh."
}
checked={resource.enabled}
onChange={(enabled) =>
void act(async () => {
await chatEndpointsApi.updateResources(endpoint.id, [
{ id: resource.id, enabled },
]);
await resources.refetch();
})
}
/>
))}
</div>
<div className="space-y-2">
<label
htmlFor="github-policy-repository"
className="text-sm font-medium"
>
Review configuration
</label>
<select
id="github-policy-repository"
className={githubSelectClass}
value={repository}
onChange={(e) => setRepository(e.target.value)}
>
<option value="">Connection defaults</option>
{resources.data
?.filter(
(r) =>
r.type === "repository" &&
r.enabled &&
r.metadata?.providerRepositoryId,
)
.map((r) => (
<option
key={r.id}
value={String(r.metadata?.providerRepositoryId)}
>
{r.label ?? r.providerResourceId}
</option>
))}
</select>
</div>
{repository && (
<GitHubToggle
label="Override connection defaults"
description="This repository can have its own prompts, filters, and publication permissions."
checked={!!override}
onChange={(enabled) => {
const repositories = { ...config.repositories };
if (enabled) repositories[repository] = { ...config.defaults };
else delete repositories[repository];
edit({ ...config, repositories });
}}
<section className="max-w-3xl space-y-8">
<fieldset disabled={pending} className="min-w-0 space-y-8">
{view === "access" ? (
<>
<GitHubRepositoryAccess
resources={repositories}
pending={pending}
managementUrl={
endpoint.setup?.github?.managementUrl ??
endpoint.setup?.github?.installationUrl ??
"https://github.com/settings/installations"
}
onRefresh={() =>
void act(async () => {
await githubChatApi.refreshRepositories(endpoint.id);
await resources.refetch();
setNotice(
"Repository access refreshed. New repositories stay disabled.",
);
})
}
onChange={(id, enabled) =>
void act(async () => {
await chatEndpointsApi.updateResources(endpoint.id, [
{ id, enabled },
]);
await resources.refetch();
})
}
/>
)}
{!repository || override ? (
<GitHubAccessEditor
endpointId={endpoint.id}
companyId={endpoint.companyId}
configuration={config}
onChange={edit}
/>
<section className="space-y-3">
<h2 className="text-base font-semibold">Agent tools</h2>
<GitHubToggle
label="Use this bot’s GitHub tools"
description="Limited to enabled repositories and this bot’s tasks. Tool policies still apply."
checked={config.toolsEnabled}
onChange={(toolsEnabled) => edit({ ...config, toolsEnabled })}
/>
{endpoint.connectionId && (
<Link
className="text-xs text-muted-foreground underline underline-offset-4"
to={`/apps/${endpoint.connectionId}`}
>
Manage tool permissions
</Link>
)}
</section>
</>
) : (
<>
<GitHubPolicyEditor
policy={{ ...config.defaults, ...override }}
onChange={(policy) =>
edit(
repository
? {
policy={config.defaults}
onChange={(defaults) => edit({ ...config, defaults })}
/>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
Repository overrides
{Object.keys(config.repositories).length > 0
? ` (${Object.keys(config.repositories).length})`
: ""}
</summary>
<div className="mt-4 space-y-5">
<label
htmlFor="github-policy-repository"
className="block text-sm font-medium"
>
Repository
</label>
<select
id="github-policy-repository"
className={githubSelectClass}
value={repository}
onChange={(e) => setRepository(e.target.value)}
>
<option value="">Choose a repository</option>
{repositories
.filter(
(r) => r.enabled && r.metadata?.providerRepositoryId,
)
.map((r) => (
<option
key={r.id}
value={String(r.metadata?.providerRepositoryId)}
>
{r.label ?? r.providerResourceId}
</option>
))}
</select>
{repository && (
<GitHubToggle
label="Use custom settings for this repository"
checked={!!override}
onChange={(enabled) => {
const overrides = { ...config.repositories };
if (enabled)
overrides[repository] = { ...config.defaults };
else delete overrides[repository];
edit({ ...config, repositories: overrides });
}}
/>
)}
{repository && override && (
<GitHubPolicyEditor
policy={{ ...config.defaults, ...override }}
onChange={(policy) =>
edit({
...config,
repositories: {
...config.repositories,
[repository]: policy,
},
}
: { ...config, defaults: policy },
)
}
/>
) : (
<p className="text-sm text-muted-foreground">
This repository follows the connection defaults.
</p>
)}
</>
)}
})
}
/>
)}
{repository && !override && (
<p className="text-xs text-muted-foreground">
Uses the settings above.
</p>
)}
</div>
</details>
</>
)}
</fieldset>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
@@ -251,40 +322,198 @@ export function GitHubBotManagement({
{notice}
</p>
)}
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<Button
variant="ghost"
disabled={!draft || pending}
onClick={() => {
setDraft(null);
setError("");
}}
>
Discard changes
</Button>
<Button
disabled={!draft || pending}
onClick={() =>
void act(async () => {
const saved = await githubChatApi.save(
endpoint.id,
record.revision,
config,
);
setDraft(saved);
await query.refetch();
{dirty && (
<div className="sticky bottom-(--tc-composer-bottom) z-10 flex items-center justify-between gap-3 border-t border-border bg-background py-4 md:bottom-0">
<Button
variant="ghost"
disabled={pending}
onClick={() => {
setDraft(null);
setNotice("Configuration saved.");
})
}
>
{pending ? "Saving…" : "Save changes"}
</Button>
</div>
setError("");
}}
>
Discard changes
</Button>
<Button
disabled={pending}
onClick={() =>
void act(async () => {
const saved = await githubChatApi.save(
endpoint.id,
record.revision,
config,
);
client.setQueryData(
["github-bot-configuration", endpoint.id],
saved,
);
setDraft(null);
setNotice("Changes saved.");
})
}
>
{pending ? "Saving…" : "Save changes"}
</Button>
</div>
)}
</section>
);
}
export function groupGitHubReviews(reviews: GitHubTaskReview[]) {
const groups = new Map<string, GitHubTaskReview[]>();
for (const review of [...reviews].sort(
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
)) {
const key = `${review.repositoryId}:${review.pullNumber}`;
groups.set(key, [...(groups.get(key) ?? []), review]);
}
return [...groups.values()];
}
function ReviewResult({ review }: { review: GitHubTaskReview }) {
const completed = review.state === "completed" && review.assessment?.complete;
const conclusion = review.conclusion
? {
success: "Passed",
failure: "Below threshold",
neutral: "Report only",
action_required: "Action required",
}[review.conclusion]
: "Awaiting check";
const state = {
queued: "Queued",
running: "Reviewing",
completed: "Incomplete",
incomplete: "Incomplete",
error: "Review failed",
superseded: "Superseded",
manual_required: "Action required",
}[review.state];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm font-medium">
{completed ? `${review.assessment!.score}/5 · ${conclusion}` : state}
</span>
<span className="text-xs text-muted-foreground">
Commit <code>{review.headSha.slice(0, 7)}</code> ·{" "}
{formatDateTime(review.updatedAt)}
</span>
</div>
<MarkdownBody className="text-sm">
{review.assessment?.summary ?? review.event.title}
</MarkdownBody>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<Link
className="underline underline-offset-4"
to={`/issues/${review.issueId}`}
>
Paperclip task
</Link>
{review.summaryUrl && (
<a
className="underline underline-offset-4"
href={review.summaryUrl}
target="_blank"
rel="noreferrer"
>
GitHub comment
</a>
)}
{review.checkUrl && (
<a
className="underline underline-offset-4"
href={review.checkUrl}
target="_blank"
rel="noreferrer"
>
Check
</a>
)}
</div>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
Review details
</summary>
<div className="mt-3 space-y-2">
{review.runId && (
<Link
className="text-xs underline underline-offset-4"
to={`/issues/${review.issueId}?runId=${review.runId}`}
>
Agent run
</Link>
)}
{review.assessment && (
<>
<MarkdownBody>{review.assessment.rationale}</MarkdownBody>
<p className="text-xs text-muted-foreground">
{review.assessment.coverage.reviewedPaths.length} files reviewed
· {review.assessment.coverage.omittedPaths.length} omitted
</p>
{review.assessment.coverage.limitations.map((limit, index) => (
<p key={index} className="text-xs text-muted-foreground">
{limit}
</p>
))}
</>
)}
</div>
</details>
</div>
);
}
export function GitHubReviewList({ reviews }: { reviews: GitHubTaskReview[] }) {
if (!reviews.length)
return (
<EmptyState
icon={GitPullRequest}
message="No reviews yet"
description="Mention the bot on a pull request, or enable automatic events in Settings."
/>
);
return (
<div className="divide-y divide-border border-y border-border">
{groupGitHubReviews(reviews).map(([latest, ...history]) => (
<article
key={`${latest.repositoryId}:${latest.pullNumber}`}
className="space-y-4 py-5"
>
<div className="space-y-1">
<a
className="text-sm font-semibold hover:underline"
href={`https://github.com/${latest.repository}/pull/${latest.pullNumber}`}
target="_blank"
rel="noreferrer"
>
{latest.event.title || `Pull request #${latest.pullNumber}`}
</a>
<p className="break-all text-xs text-muted-foreground">
{latest.repository} #{latest.pullNumber}
</p>
</div>
<ReviewResult review={latest} />
{history.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
{history.length} earlier{" "}
{history.length === 1 ? "review" : "reviews"}
</summary>
<div className="mt-4 space-y-6 border-l border-border pl-4">
{history.map((review) => (
<ReviewResult key={review.id} review={review} />
))}
</div>
</details>
)}
</article>
))}
</div>
);
}
export function GitHubReviews({ endpointId }: { endpointId: string }) {
const query = useQuery({
queryKey: ["github-bot-reviews", endpointId],
@@ -293,109 +522,23 @@ export function GitHubReviews({ endpointId }: { endpointId: string }) {
});
return (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Reviews</h2>
<p className="mt-1 text-sm text-muted-foreground">
Review activity from the agent’s Paperclip tasks. Open a task for the
conversation and execution history.
</p>
</div>
{query.isError && (
<p className="text-sm text-muted-foreground">
Latest review for each pull request. Results apply to the commit shown.
</p>
{query.isError ? (
<p role="alert" className="text-sm text-destructive">
Reviews could not be loaded.{" "}
<Button variant="link" onClick={() => void query.refetch()}>
Try again
</Button>
</p>
)}
{query.isLoading && (
<p className="text-sm text-muted-foreground">Loading reviews…</p>
)}
{query.data?.length === 0 && (
<p className="text-sm text-muted-foreground">
No reviews yet. Mention the bot on an enabled repository’s PR, or
enable automatic review events in Settings.
) : query.isPending ? (
<p role="status" className="text-sm text-muted-foreground">
Loading reviews…
</p>
) : (
<GitHubReviewList reviews={query.data} />
)}
{query.data?.map((review) => (
<article
key={review.id}
className="space-y-3 rounded-lg border border-border p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<a
className="text-sm font-medium underline"
href={`https://github.com/${review.repository}/pull/${review.pullNumber}`}
target="_blank"
rel="noreferrer"
>
{review.repository} #{review.pullNumber}
</a>
<span className="text-sm">
{review.assessment?.complete
? `${review.assessment.score}/5`
: review.state.replaceAll("_", " ")}{" "}
·{" "}
{review.conclusion?.replaceAll("_", " ") ?? "Awaiting assessment"}
</span>
</div>
<p className="text-sm">
{review.assessment?.summary ?? review.event.title}
</p>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<code>{review.headSha.slice(0, 12)}</code>
<span>{formatDateTime(review.updatedAt)}</span>
<Link className="underline" to={`/issues/${review.issueId}`}>
Paperclip task
</Link>
{review.runId && (
<Link
className="underline"
to={`/issues/${review.issueId}?runId=${review.runId}`}
>
Run
</Link>
)}
{review.summaryUrl && (
<a
className="underline"
href={review.summaryUrl}
target="_blank"
rel="noreferrer"
>
Summary
</a>
)}
{review.checkUrl && (
<a
className="underline"
href={review.checkUrl}
target="_blank"
rel="noreferrer"
>
Check
</a>
)}
</div>
{review.assessment && (
<details className="text-sm">
<summary className="cursor-pointer">
Rationale and coverage
</summary>
<p className="mt-2">{review.assessment.rationale}</p>
<p className="mt-2 text-muted-foreground">
{review.assessment.coverage.reviewedPaths.length} files reviewed
· {review.assessment.coverage.omittedPaths.length} omitted
</p>
{review.assessment.coverage.limitations.map((limit, index) => (
<p key={index} className="mt-1 text-muted-foreground">
{limit}
</p>
))}
</details>
)}
</article>
))}
</section>
);
}
@@ -53,8 +53,6 @@ describe("chat connector UI contract", () => {
expect(detail).toContain(`"${tab}"`);
}
expect(detail).not.toContain('"overview"');
expect(detail).toContain("Open {providerNames[provider]}");
expect(detail).toContain("Open task");
expect(detail.toLowerCase()).not.toContain("detach");
});
@@ -0,0 +1,130 @@
import { useLayoutEffect, useState, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import {
endpoint,
agent,
configuration,
resources,
reviews,
conversations,
links,
members,
} from "./fixtures";
export type FixtureState = "populated" | "empty" | "loading" | "error" | "long";
/** Only fixture IDs are intercepted. All shell requests use Storybook's shared API fixtures. */
export function FixtureApi({
state = "populated",
children,
}: {
state?: FixtureState;
children: ReactNode;
}) {
const [client] = useState(() => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(queryKeys.chatEndpoints.detail(endpoint.id), endpoint);
client.setQueryData(queryKeys.agents.detail(agent.id), agent);
client.setQueryData(["github-members", endpoint.companyId], members);
return client;
});
const [ready, setReady] = useState(false);
useLayoutEffect(() => {
const original = window.fetch;
let saved = { revision: 2, configuration: structuredClone(configuration) };
let repos = structuredClone(resources);
let identities = structuredClone(links);
const failedPaths = new Set<string>();
window.fetch = async (input, init) => {
const raw =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const path = new URL(raw, window.location.origin).pathname;
if (!path.includes(`/chat-endpoints/${endpoint.id}`))
return original(input, init);
if (state === "loading") return new Promise<Response>(() => {});
if (
state === "error" &&
(path.endsWith("/configuration") || path.endsWith("/resources")) &&
!failedPaths.has(path)
) {
failedPaths.add(path);
return Response.json(
{ error: "Could not reach GitHub. Try again." },
{ status: 503 },
);
}
const body = init?.body ? JSON.parse(String(init.body)) : {};
if (path.endsWith("/configuration")) {
if (init?.method === "PUT")
saved = {
revision: saved.revision + 1,
configuration: body.configuration,
};
return Response.json(saved);
}
if (path.endsWith("/repositories/refresh")) return Response.json(repos);
if (path.endsWith("/resources")) {
if (init?.method === "PUT")
repos = repos.map((r) => ({
...r,
enabled:
body.resources?.find(
(change: { id: string; enabled: boolean }) =>
change.id === r.id,
)?.enabled ?? r.enabled,
}));
const rows =
state === "empty"
? []
: state === "long"
? repos.map((r) => ({
...r,
label: `acme/platform-services-production-web-${r.id}-accessibility-improvements`,
}))
: repos;
return Response.json({ items: rows });
}
if (path.endsWith("/reviews"))
return Response.json(state === "empty" ? [] : reviews);
if (path.endsWith("/activity"))
return Response.json({ items: [], nextCursor: null });
if (path.endsWith("/conversations"))
return Response.json({
items:
state === "empty"
? []
: state === "long"
? conversations.map((r) => ({
...r,
issueTitle: `${r.issueTitle} for every keyboard, screen reader, and narrow screen entry point`,
}))
: conversations,
});
if (path.endsWith("/principals"))
return Response.json({ items: state === "empty" ? [] : identities });
if (path.endsWith("/link") && init?.method === "DELETE") {
identities = identities.filter(
(link) => !path.includes(link.principalId),
);
return Response.json({ success: true });
}
if (path.endsWith("/people/lookup"))
return Response.json({ githubUserId: "44", login: body.login });
return Response.json(endpoint);
};
setReady(true);
return () => {
window.fetch = original;
client.clear();
};
}, [client, state]);
return ready ? (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
) : null;
}
@@ -0,0 +1,200 @@
import {
defaultGitHubReviewPolicy,
type GitHubChatConfiguration,
type GitHubTaskReview,
} from "@paperclipai/shared";
import type {
ChatEndpoint,
ChatEndpointResource,
ChatConversation,
ChatIdentityLink,
} from "@/api/chatEndpoints";
import { STORYBOOK_COMPANY_ID } from "../../fixtures/onboardingDraft";
import { storybookAgents } from "../../fixtures/paperclipData";
export const agent = { ...storybookAgents[0], name: "Maya" };
export const endpoint: ChatEndpoint = {
id: "story-github-bot",
companyId: STORYBOOK_COMPANY_ID,
provider: "github",
status: "active",
assignedAgentId: agent.id,
assignedAgentName: agent.name,
connectionId: "story-github-tools",
botLabel: "Maya Reviews",
botUsername: "maya-reviews[bot]",
providerAccountLabel: "acme",
allowUnlinkedPeople: false,
setup: { step: "complete" },
};
export const configuration: GitHubChatConfiguration = {
version: 1,
toolsEnabled: true,
responsibleUserId: "user-board",
memberAccess: "selected",
people: [
{
kind: "member",
userId: "user-board",
githubUserId: "42",
login: "mayacoder",
automaticReviews: true,
},
],
defaults: {
...defaultGitHubReviewPolicy(),
invocation: "allowed_authors",
events: ["opened", "synchronize"],
issueOpened: true,
instructions:
"Review for correctness and security. Explain important findings clearly, and suggest a focused fix. For issues, help the author understand the next step.",
},
repositories: {},
};
export const resources: ChatEndpointResource[] = [
{
id: "web",
type: "repository",
providerResourceId: "acme/web",
label: "acme/web",
availability: "available",
enabled: true,
metadata: { providerRepositoryId: "100" },
},
{
id: "api",
type: "repository",
providerResourceId: "acme/api",
label: "acme/api",
availability: "available",
enabled: false,
metadata: { providerRepositoryId: "101" },
},
];
export const links: ChatIdentityLink[] = [
{
id: "maya",
principalId: "maya",
githubUserId: "42",
githubLogin: "mayacoder",
externalLabel: "mayacoder",
paperclipUserId: "user-board",
paperclipUserLabel: "Maya Chen",
status: "linked",
},
{
id: "vin",
principalId: "vin",
githubUserId: "43",
githubLogin: "vin-dev",
externalLabel: "vin-dev",
paperclipUserId: "vin-member",
paperclipUserLabel: "Vin Patel",
status: "linked",
},
];
export const members = {
members: [
{
principalId: "user-board",
status: "active",
membershipRole: "owner",
user: { name: "Maya Chen" },
},
{
principalId: "vin-member",
status: "active",
membershipRole: "operator",
user: { name: "Vin Patel" },
},
],
};
export function review(
id: string,
headSha: string,
createdAt: string,
): GitHubTaskReview {
return {
id,
companyId: endpoint.companyId,
endpointId: endpoint.id,
issueId: `task-${id}`,
runId: null,
repositoryId: "100",
repository: "acme/web",
pullNumber: 24,
headSha,
configurationRevision: 2,
event: {
event: "opened",
deliveryId: `delivery-${id}`,
repositoryId: "100",
repository: "acme/web",
pullNumber: 24,
title: "Fix keyboard focus in the account picker",
body: "",
baseSha: "0000000",
headSha,
baseBranch: "main",
author: { id: "42", login: "mayacoder", isBot: false },
sender: { id: "42", login: "mayacoder" },
draft: false,
labels: [],
},
state: "completed",
assessment: {
reviewedCommit: headSha,
score: 5,
complete: true,
summary:
"**5/5** — Focus returns to the trigger after dismissal. Keyboard navigation and the loading state are covered.\n\n```text\n /\\_/\\\n( o.o )\n > ^ <\n```",
rationale:
"The change preserves the picker’s existing access rules and handles dismissal without losing focus.",
coverage: {
reviewedPaths: ["ui/AccountPicker.tsx", "ui/AccountPicker.test.tsx"],
omittedPaths: [],
limitations: [],
},
findings: [],
},
conclusion: "success",
checkUrl: "https://github.com/acme/web/pull/24/checks",
summaryUrl: "https://github.com/acme/web/pull/24#issuecomment-123",
createdAt,
updatedAt: createdAt,
};
}
export const reviews = [
review(
"latest",
"b046e7e18bdda6208515088540aee438091ab120",
"2026-10-07T12:43:00Z",
),
review(
"previous",
"c1525d7a904142590babb421971e7590d0a03b26",
"2026-10-07T12:33:00Z",
),
];
export const conversations: ChatConversation[] = [
{
id: "pr",
externalLabel: "acme/web",
externalUrl: "https://github.com/acme/web/pull/24",
issueId: "task-latest",
issueIdentifier: "PAP-24",
issueTitle: "Fix keyboard focus in the account picker",
state: "completed",
updatedAt: "2026-10-07T12:43:00Z",
},
{
id: "issue",
externalLabel: "acme/web",
externalUrl: "https://github.com/acme/web/issues/25",
issueId: "task-issue",
issueIdentifier: "PAP-25",
issueTitle: "Investigate the sign-in error after an invitation",
state: "active",
updatedAt: "2026-10-07T12:50:00Z",
},
];
@@ -0,0 +1,180 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { Layout } from "@/components/Layout";
import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail";
import {
GitHubPolicyEditor,
GitHubAccessEditor,
} from "@/pages/apps/chat/GitHubBotConfiguration";
import {
GitHubRepositoryAccess,
GitHubReviewList,
} from "@/pages/apps/chat/GitHubBotManagement";
import { ChatConversationList } from "@/pages/apps/chat/ChatConversationList";
import {
endpoint,
configuration,
resources,
reviews,
conversations,
} from "./fixtures";
import { FixtureApi, type FixtureState } from "./fixture-api";
const meta = {
title: "Connectors/GitHub bot management",
parameters: {
layout: "fullscreen",
initialEntries: [`/PAP/apps/chat/${endpoint.id}/settings`],
docs: {
description: {
component:
"Production pages and components. Start with **01 Journey / Settings**, then use the actual contextual sidebar to visit Access, Reviews, and Conversations. Try editing instructions, switch tabs, and save or discard the shared draft. **02 States** covers empty, loading, recoverable error, long content, and mobile. **03 Components** isolates the policy editor, people access, repository access, latest review/history, pending current commit, and conversations. Provider operations use isolated fixture APIs; these stories are not live GitHub evidence.",
},
},
},
args: { state: "populated" as FixtureState },
argTypes: {
state: {
control: "select",
options: ["populated", "empty", "loading", "error", "long"],
},
},
render: ({ state }) => (
<FixtureApi key={state} state={state}>
<PluginLauncherProvider>
<Routes>
<Route path="/:companyPrefix" element={<Layout />}>
<Route
path="apps/chat/:endpointId/:tab"
element={<ChatEndpointDetail />}
/>
</Route>
</Routes>
</PluginLauncherProvider>
</FixtureApi>
),
} satisfies Meta<{ state: FixtureState }>;
export default meta;
type Story = StoryObj<typeof meta>;
const route = (tab: string) => ({
initialEntries: [`/PAP/apps/chat/${endpoint.id}/${tab}`],
});
export const SettingsJourney: Story = { name: "01 Journey / Settings" };
export const AccessJourney: Story = {
name: "01 Journey / Access",
parameters: route("access"),
};
export const ReviewsJourney: Story = {
name: "01 Journey / Reviews",
parameters: route("reviews"),
};
export const ConversationsJourney: Story = {
name: "01 Journey / Conversations",
parameters: route("conversations"),
};
export const FirstUse: Story = {
name: "02 States / No conversations",
args: { state: "empty" },
parameters: route("conversations"),
};
export const NoReviews: Story = {
name: "02 States / No reviews",
args: { state: "empty" },
parameters: route("reviews"),
};
export const Loading: Story = {
name: "02 States / Loading settings",
args: { state: "loading" },
};
export const Error: Story = {
name: "02 States / Settings failure",
args: { state: "error" },
};
export const LongAccess: Story = {
name: "02 States / Long repository names",
args: { state: "long" },
parameters: route("access"),
};
export const Mobile: Story = {
name: "02 States / Mobile conversations",
args: { state: "long" },
parameters: {
...route("conversations"),
viewport: { defaultViewport: "mobile1" },
},
globals: { viewport: { value: "mobile1", isRotated: false } },
};
function Policy() {
const [policy, setPolicy] = useState(configuration.defaults);
return <GitHubPolicyEditor policy={policy} onChange={setPolicy} />;
}
function People() {
const [config, setConfig] = useState(configuration);
return (
<FixtureApi>
<GitHubAccessEditor
endpointId={endpoint.id}
companyId={endpoint.companyId}
configuration={config}
onChange={setConfig}
/>
</FixtureApi>
);
}
function Repositories() {
const [rows, setRows] = useState(resources);
return (
<GitHubRepositoryAccess
resources={rows}
managementUrl="https://github.com/settings/installations"
pending={false}
onRefresh={() => {}}
onChange={(id, enabled) =>
setRows(rows.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
/>
);
}
const component = (children: React.ReactNode) => (
<div className="max-w-3xl p-6">{children}</div>
);
export const InstructionsAndBehavior: Story = {
name: "03 Components / Instructions and behavior",
render: () => component(<Policy />),
};
export const PeopleAccess: Story = {
name: "03 Components / People access",
render: () => component(<People />),
};
export const RepositoryAccess: Story = {
name: "03 Components / Repository access",
render: () => component(<Repositories />),
};
export const ReviewHistory: Story = {
name: "03 Components / Latest review and history",
render: () => component(<GitHubReviewList reviews={reviews} />),
};
export const PendingReview: Story = {
name: "03 Components / Pending current commit",
render: () =>
component(
<GitHubReviewList
reviews={[
{
...reviews[0],
state: "running",
assessment: null,
conclusion: null,
},
reviews[1],
]}
/>,
),
};
export const ConversationRows: Story = {
name: "03 Components / Conversation rows",
render: () =>
component(<ChatConversationList rows={conversations} provider="github" />),
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, userEvent, within } from "storybook/test";
import { GitHubChatPreview } from "../prototypes/github-chat/GitHubChatPreview";
const meta = {
title: "Apps/GitHub chat & reviews",
title: "Historical/GitHub chat prototypes",
component: GitHubChatPreview,
parameters: {
layout: "fullscreen",