-
- Responsible user for automatic events
-
-
- onChange({ ...configuration, responsibleUserId: e.target.value })
- }
- >
- {activeMembers.map((member) => (
-
- {member.user?.name ?? member.user?.email ?? member.principalId}
-
- ))}
-
-
- Accountable for automatic tasks. The PR author and webhook sender
- remain recorded separately.
-
+
+
+
People
+
+
+ Link your account
+
+ {
+ if (accountLink.current)
+ void copyTextToClipboard(accountLink.current.href).then(
+ () => setLinkCopied(true),
+ () =>
+ setError(
+ "Could not copy the link. Open account linking and copy the address.",
+ ),
+ );
+ }}
+ >
+
+ {linkCopied ? "Copied" : "Invite teammate"}
+
+
-
Company member access
+
Who can mention the bot
All linked company members
- Only selected linked members
+ Selected members only
- Members connect their own GitHub account.{" "}
-
- Open account linking
-
- {
- if (accountLink.current)
- void copyTextToClipboard(accountLink.current.href).then(
- () => setLinkCopied(true),
- () =>
- setError(
- "Could not copy the link. Open account linking and copy the address.",
- ),
- );
- }}
- >
- {linkCopied ? "Link copied" : "Copy link for teammates"}
-
- .
+ Automatic events also require the person’s permission below and an
+ enabled event in Settings.
-
-
Linked GitHub accounts
- {links.isError && (
-
- Could not load linked accounts.
-
- )}
- {(links.data ?? [])
- .filter((link) => link.status === "linked")
- .map((link) => (
-
-
+
+ {configuration.people.map((person) => {
+ const identity = linkedAccounts.find(
+ (link) => link.githubUserId === person.githubUserId,
+ );
+ const linked = person.kind === "guest" || Boolean(identity);
+ return (
+
+
+
+
+ @{person.login}
+
+
+ {person.kind === "guest"
+ ? `External contributor · sponsored by ${activeMembers.find((member) => member.principalId === person.sponsorUserId)?.user?.name ?? person.sponsorUserId}`
+ : (identity?.paperclipUserLabel ?? "Company member")}
+
+ {!linked && (
+
+ Link this member’s GitHub account to enable access.
+
+ )}
+
+
+
+
+
+
+
+
+
+ onChange({
+ ...configuration,
+ people: configuration.people.filter(
+ (p) => p.githubUserId !== person.githubUserId,
+ ),
+ })
+ }
+ >
+ {person.kind === "member" &&
+ configuration.memberAccess === "all_linked"
+ ? "Reset individual event settings"
+ : "Remove access"}
+
+ {identity && (
+ void unlink(identity.principalId)}
+ >
+ Unlink GitHub account
+
+ )}
+
+
+
+
+ onChange({
+ ...configuration,
+ people: configuration.people.map((p) =>
+ p.githubUserId === person.githubUserId
+ ? { ...p, automaticReviews: value }
+ : p,
+ ),
+ })
+ }
+ />
+ {person.kind === "guest" && (
+
+ Restricted guest permissions; no company membership or
+ personal credentials.
+
+ )}
+
+ );
+ })}
+ {unlistedAccounts.map((link) => (
+
+
+
@{link.githubLogin ?? link.externalLabel}
+
+ {link.paperclipUserLabel ?? "Linked member"} ·{" "}
+ {configuration.memberAccess === "all_linked"
+ ? "Mentions allowed"
+ : "Access not enabled"}
+
+
+
{
- setBusy(true);
- setError("");
- try {
- await chatEndpointsApi.revokeLink(
- endpointId,
- link.principalId,
- );
- await links.refetch();
- } catch (e) {
- setError(
- e instanceof Error
- ? e.message
- : "Could not unlink this account.",
- );
- } finally {
- setBusy(false);
- }
- }}
+ disabled={!link.githubUserId || !link.paperclipUserId}
+ onClick={() =>
+ onChange({
+ ...configuration,
+ people: [
+ ...configuration.people,
+ {
+ kind: "member",
+ userId: link.paperclipUserId!,
+ githubUserId: link.githubUserId!,
+ login: link.githubLogin ?? link.externalLabel,
+ automaticReviews: false,
+ },
+ ],
+ })
+ }
>
- Unlink account
+ {configuration.memberAccess === "all_linked"
+ ? "Configure events"
+ : "Allow"}
+
+
+
+
+
+
+
+ void unlink(link.principalId)}
+ >
+ Unlink GitHub account
+
+
+
- ))}
+
+ ))}
+ {links.isPending && (
+
+ Loading linked accounts…
+
+ )}
{!links.isPending &&
!links.isError &&
- !(links.data ?? []).some((link) => link.status === "linked") && (
-
- No accounts linked yet. Each teammate confirms their own GitHub
+ configuration.people.length === 0 &&
+ unlistedAccounts.length === 0 && (
+
+ No accounts linked yet. Invite a teammate to confirm their GitHub
identity.
)}
-
- {configuration.people.length === 0 && (
-
- No individual access entries. Unlinked people cannot invoke this
- bot.
-
- )}
- {configuration.people.map((person) => (
-
-
-
-
@{person.login}
-
- {person.kind === "member"
- ? "Linked company member"
- : "External contributor · restricted guest permissions"}
-
-
-
- onChange({
- ...configuration,
- people: configuration.people.filter(
- (p) => p.githubUserId !== person.githubUserId,
- ),
- })
- }
- >
- Remove
-
-
-
- onChange({
- ...configuration,
- people: configuration.people.map((p) =>
- p.githubUserId === person.githubUserId
- ? { ...p, automaticReviews: value }
- : p,
- ),
- })
- }
- />
- {person.kind === "guest" && (
-
- Sponsor:{" "}
- {activeMembers.find(
- (member) => member.principalId === person.sponsorUserId,
- )?.user?.name ?? person.sponsorUserId}
- . No company membership or personal credentials are granted.
-
- )}
-
- ))}
-
-
- setKind("member")}>
- Add linked member
-
- setKind("guest")}>
- Allow external contributor
-
-
- {kind === "member" && (
-
-
- Adding a member switches access to the selected-member list.
- Automatic PR reviews start off.
-
- {(links.data ?? [])
- .filter((link) => link.status === "linked" && link.paperclipUserId)
- .map((link) => (
-
- p.kind === "member" && p.userId === link.paperclipUserId,
- )}
- onClick={() => {
- const id = link.githubUserId;
- if (!id) {
- setError(
- "Refresh linked identities before adding this member.",
- );
- return;
- }
- add({
- kind: "member",
- userId: link.paperclipUserId!,
- githubUserId: id,
- login: link.githubLogin ?? link.externalLabel,
- automaticReviews: false,
- });
- }}
- >
- {link.paperclipUserLabel ?? link.externalLabel}
-
- ))}
-
setKind(null)}>
- Cancel
-
-
- )}
+
setKind("guest")}>
+ Allow external contributor
+
{kind === "guest" && (
@@ -718,12 +787,75 @@ export function GitHubAccessEditor({
)}
- {(error || members.error || links.error) && (
+
+
+ Automatic task ownership
+
+
+ {activeMembers.length === 1 && responsible ? (
+
+ Responsible member:{" "}
+ {responsible.user?.name ??
+ responsible.user?.email ??
+ responsible.principalId}
+
+ ) : (
+ <>
+
Responsible member
+
+ onChange({
+ ...configuration,
+ responsibleUserId: e.target.value,
+ })
+ }
+ >
+ {!responsible && (
+
+ {configuration.responsibleUserId || "Select a member"}
+
+ )}
+ {activeMembers.map((member) => (
+
+ {member.user?.name ??
+ member.user?.email ??
+ member.principalId}
+
+ ))}
+
+ >
+ )}
+
+ Accountable for automatic tasks. GitHub authors remain recorded
+ separately.
+
+
+
+ {error && (
- {error ||
- "Could not load members or linked accounts. Refresh to try again."}
+ {error}
)}
-
+ {(members.error || links.error) && (
+
+
+ Could not load members or linked accounts.
+
+
{
+ void members.refetch();
+ void links.refetch();
+ }}
+ >
+ Try again
+
+
+ )}
+
);
}
diff --git a/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx
new file mode 100644
index 00000000000..a5d161b67ec
--- /dev/null
+++ b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx
@@ -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 }) => (
+
{children}
+ ),
+}));
+vi.mock("@/lib/router", () => ({
+ useParams: () => ({ endpointId: "bot", tab: mocks.tab }),
+ useNavigate: () => vi.fn(),
+ Link: ({
+ children,
+ to,
+ ...props
+ }: React.ComponentProps<"a"> & { to: string }) => (
+
+ {children}
+
+ ),
+ 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(
+
+
+
+
+ ,
+ ),
+ );
+ 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
("#github-policy-repository")!,
+ "100",
+ );
+ await click("Use custom settings for this repository");
+ const areas = [
+ ...container.querySelectorAll(
+ '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(
+ ,
+ ),
+ );
+ await input(
+ container.querySelector(
+ '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( ),
+ );
+ 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");
+ });
+});
diff --git a/ui/src/pages/apps/chat/GitHubBotManagement.tsx b/ui/src/pages/apps/chat/GitHubBotManagement.tsx
index eeaf005edca..ca46ea29c72 100644
--- a/ui/src/pages/apps/chat/GitHubBotManagement.tsx
+++ b/ui/src/pages/apps/chat/GitHubBotManagement.tsx
@@ -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 (
+
+
+
+ Choose where the bot can receive messages and use tools. Repository
+ changes save immediately.
+
+
+ {resources.map((resource) => (
+
+ onChange(resource.id, enabled)}
+ />
+
+ ))}
+ {resources.length === 0 && (
+
+ No repositories available. Add repository access on GitHub, then
+ refresh.
+
+ )}
+
+
+ );
+}
+
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({
);
- if (!record)
+ if (!record || resources.isPending)
return (
- Loading configuration…
+
+ Loading configuration…
+
);
const config = record.configuration;
+ const repositories = (resources.data ?? []).filter(
+ (resource) => resource.type === "repository",
+ );
const override = repository ? config.repositories[repository] : undefined;
return (
-
-
-
- {view === "access"
- ? "Who can start work"
- : "Agent and review behavior"}
-
-
- {endpoint.assignedAgentName} is permanently assigned to this bot.
- GitHub messages create or continue Paperclip tasks; reviews are
- results of those runs.
-
-
- Bot’s GitHub tool connection
-
-
- {view === "access" ? (
-
- ) : (
- <>
- edit({ ...config, toolsEnabled })}
- />
-
-
-
Repository access
-
-
- void act(async () => {
- await githubChatApi.refreshRepositories(endpoint.id);
- await resources.refetch();
- setNotice(
- "Repository access refreshed. New repositories stay disabled.",
- );
- })
- }
- >
-
- Refresh
-
-
-
- Configure on GitHub
-
-
-
-
-
-
- These repositories come from the bot App’s installation. Choose
- where this bot can receive messages and use tools in Paperclip.
-
- {resources.data
- ?.filter((r) => r.type === "repository")
- .map((resource) => (
-
- void act(async () => {
- await chatEndpointsApi.updateResources(endpoint.id, [
- { id: resource.id, enabled },
- ]);
- await resources.refetch();
- })
- }
- />
- ))}
-
-
-
- Review configuration
-
- setRepository(e.target.value)}
- >
- Connection defaults
- {resources.data
- ?.filter(
- (r) =>
- r.type === "repository" &&
- r.enabled &&
- r.metadata?.providerRepositoryId,
- )
- .map((r) => (
-
- {r.label ?? r.providerResourceId}
-
- ))}
-
-
- {repository && (
- {
- const repositories = { ...config.repositories };
- if (enabled) repositories[repository] = { ...config.defaults };
- else delete repositories[repository];
- edit({ ...config, repositories });
- }}
+
+
+ {view === "access" ? (
+ <>
+
+ 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 ? (
+
+
+ Agent tools
+ edit({ ...config, toolsEnabled })}
+ />
+ {endpoint.connectionId && (
+
+ Manage tool permissions
+
+ )}
+
+ >
+ ) : (
+ <>
- edit(
- repository
- ? {
+ policy={config.defaults}
+ onChange={(defaults) => edit({ ...config, defaults })}
+ />
+
+
+ Repository overrides
+ {Object.keys(config.repositories).length > 0
+ ? ` (${Object.keys(config.repositories).length})`
+ : ""}
+
+
+
+ Repository
+
+
setRepository(e.target.value)}
+ >
+ Choose a repository
+ {repositories
+ .filter(
+ (r) => r.enabled && r.metadata?.providerRepositoryId,
+ )
+ .map((r) => (
+
+ {r.label ?? r.providerResourceId}
+
+ ))}
+
+ {repository && (
+
{
+ const overrides = { ...config.repositories };
+ if (enabled)
+ overrides[repository] = { ...config.defaults };
+ else delete overrides[repository];
+ edit({ ...config, repositories: overrides });
+ }}
+ />
+ )}
+ {repository && override && (
+
+ edit({
...config,
repositories: {
...config.repositories,
[repository]: policy,
},
- }
- : { ...config, defaults: policy },
- )
- }
- />
- ) : (
-
- This repository follows the connection defaults.
-
- )}
- >
- )}
+ })
+ }
+ />
+ )}
+ {repository && !override && (
+
+ Uses the settings above.
+
+ )}
+
+
+ >
+ )}
+
{error && (
{error}
@@ -251,40 +322,198 @@ export function GitHubBotManagement({
{notice}
)}
-
-
{
- setDraft(null);
- setError("");
- }}
- >
- Discard changes
-
-
- void act(async () => {
- const saved = await githubChatApi.save(
- endpoint.id,
- record.revision,
- config,
- );
- setDraft(saved);
- await query.refetch();
+ {dirty && (
+
+ {
setDraft(null);
- setNotice("Configuration saved.");
- })
- }
- >
- {pending ? "Saving…" : "Save changes"}
-
-
+ setError("");
+ }}
+ >
+ Discard changes
+
+
+ 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"}
+
+
+ )}
);
}
+export function groupGitHubReviews(reviews: GitHubTaskReview[]) {
+ const groups = new Map();
+ 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 (
+
+
+
+ {completed ? `${review.assessment!.score}/5 · ${conclusion}` : state}
+
+
+ Commit {review.headSha.slice(0, 7)} ·{" "}
+ {formatDateTime(review.updatedAt)}
+
+
+
+ {review.assessment?.summary ?? review.event.title}
+
+
+
+
+ Review details
+
+
+ {review.runId && (
+
+ Agent run
+
+ )}
+ {review.assessment && (
+ <>
+
{review.assessment.rationale}
+
+ {review.assessment.coverage.reviewedPaths.length} files reviewed
+ · {review.assessment.coverage.omittedPaths.length} omitted
+
+ {review.assessment.coverage.limitations.map((limit, index) => (
+
+ {limit}
+
+ ))}
+ >
+ )}
+
+
+
+ );
+}
+
+export function GitHubReviewList({ reviews }: { reviews: GitHubTaskReview[] }) {
+ if (!reviews.length)
+ return (
+
+ );
+ return (
+
+ {groupGitHubReviews(reviews).map(([latest, ...history]) => (
+
+
+
+ {history.length > 0 && (
+
+
+ {history.length} earlier{" "}
+ {history.length === 1 ? "review" : "reviews"}
+
+
+ {history.map((review) => (
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ );
+}
+
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 (
-
-
Reviews
-
- Review activity from the agent’s Paperclip tasks. Open a task for the
- conversation and execution history.
-
-
- {query.isError && (
+
+ Latest review for each pull request. Results apply to the commit shown.
+
+ {query.isError ? (
Reviews could not be loaded.{" "}
void query.refetch()}>
Try again
- )}
- {query.isLoading && (
- Loading reviews…
- )}
- {query.data?.length === 0 && (
-
- No reviews yet. Mention the bot on an enabled repository’s PR, or
- enable automatic review events in Settings.
+ ) : query.isPending ? (
+
+ Loading reviews…
+ ) : (
+
)}
- {query.data?.map((review) => (
-
-
-
- {review.repository} #{review.pullNumber}
-
-
- {review.assessment?.complete
- ? `${review.assessment.score}/5`
- : review.state.replaceAll("_", " ")}{" "}
- ·{" "}
- {review.conclusion?.replaceAll("_", " ") ?? "Awaiting assessment"}
-
-
-
- {review.assessment?.summary ?? review.event.title}
-
-
-
{review.headSha.slice(0, 12)}
-
{formatDateTime(review.updatedAt)}
-
- Paperclip task
-
- {review.runId && (
-
- Run
-
- )}
- {review.summaryUrl && (
-
- Summary
-
- )}
- {review.checkUrl && (
-
- Check
-
- )}
-
- {review.assessment && (
-
-
- Rationale and coverage
-
- {review.assessment.rationale}
-
- {review.assessment.coverage.reviewedPaths.length} files reviewed
- · {review.assessment.coverage.omittedPaths.length} omitted
-
- {review.assessment.coverage.limitations.map((limit, index) => (
-
- {limit}
-
- ))}
-
- )}
-
- ))}
);
}
diff --git a/ui/src/pages/apps/chat/chat-ui-contract.test.ts b/ui/src/pages/apps/chat/chat-ui-contract.test.ts
index 07f701f513c..8418355635c 100644
--- a/ui/src/pages/apps/chat/chat-ui-contract.test.ts
+++ b/ui/src/pages/apps/chat/chat-ui-contract.test.ts
@@ -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");
});
diff --git a/ui/storybook/stories/github-bot-management/fixture-api.tsx b/ui/storybook/stories/github-bot-management/fixture-api.tsx
new file mode 100644
index 00000000000..4ba1e6ede5a
--- /dev/null
+++ b/ui/storybook/stories/github-bot-management/fixture-api.tsx
@@ -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();
+ 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(() => {});
+ 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 ? (
+ {children}
+ ) : null;
+}
diff --git a/ui/storybook/stories/github-bot-management/fixtures.ts b/ui/storybook/stories/github-bot-management/fixtures.ts
new file mode 100644
index 00000000000..4d7dfd08030
--- /dev/null
+++ b/ui/storybook/stories/github-bot-management/fixtures.ts
@@ -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",
+ },
+];
diff --git a/ui/storybook/stories/github-bot-management/management.stories.tsx b/ui/storybook/stories/github-bot-management/management.stories.tsx
new file mode 100644
index 00000000000..6c8486d770c
--- /dev/null
+++ b/ui/storybook/stories/github-bot-management/management.stories.tsx
@@ -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 }) => (
+
+
+
+ }>
+ }
+ />
+
+
+
+
+ ),
+} satisfies Meta<{ state: FixtureState }>;
+export default meta;
+type Story = StoryObj;
+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 ;
+}
+function People() {
+ const [config, setConfig] = useState(configuration);
+ return (
+
+
+
+ );
+}
+function Repositories() {
+ const [rows, setRows] = useState(resources);
+ return (
+ {}}
+ onChange={(id, enabled) =>
+ setRows(rows.map((r) => (r.id === id ? { ...r, enabled } : r)))
+ }
+ />
+ );
+}
+const component = (children: React.ReactNode) => (
+ {children}
+);
+export const InstructionsAndBehavior: Story = {
+ name: "03 Components / Instructions and behavior",
+ render: () => component( ),
+};
+export const PeopleAccess: Story = {
+ name: "03 Components / People access",
+ render: () => component( ),
+};
+export const RepositoryAccess: Story = {
+ name: "03 Components / Repository access",
+ render: () => component( ),
+};
+export const ReviewHistory: Story = {
+ name: "03 Components / Latest review and history",
+ render: () => component( ),
+};
+export const PendingReview: Story = {
+ name: "03 Components / Pending current commit",
+ render: () =>
+ component(
+ ,
+ ),
+};
+export const ConversationRows: Story = {
+ name: "03 Components / Conversation rows",
+ render: () =>
+ component( ),
+};
diff --git a/ui/storybook/stories/github-chat.stories.tsx b/ui/storybook/stories/github-chat.stories.tsx
index 9aec936f5f2..5e6d09b6e25 100644
--- a/ui/storybook/stories/github-chat.stories.tsx
+++ b/ui/storybook/stories/github-chat.stories.tsx
@@ -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",