Separate GitHub bot opt-in and add review detail pages

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 16:17:14 -05:00
1 parent 7c1d2503db
commit 7dcdbe7eb6
36 files changed
+647 -204

No files matched your search

+5 -2
View File
@@ -214,6 +214,9 @@ function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: b
<Route path="apps/chat/:endpointId" element={
<ChatConnectorsExperimentalGate><Navigate to="settings" replace /></ChatConnectorsExperimentalGate>
} />
<Route path="apps/chat/:endpointId/reviews/:reviewId" element={
<ChatConnectorsExperimentalGate><ChatEndpointDetail /></ChatConnectorsExperimentalGate>
} />
<Route path="apps/chat/:endpointId/:tab" element={
<ChatConnectorsExperimentalGate><ChatEndpointDetail /></ChatConnectorsExperimentalGate>
} />
@@ -483,8 +486,8 @@ function AppsConnectEntryRoute({
} = {}) {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
return canEnterAppsConnect(searchParams, { chatConnectorsEnabled })
const { enabled: chatConnectorsEnabled, githubEnabled: githubReviewBotsEnabled } = useChatConnectorsEnabled();
return canEnterAppsConnect(searchParams, { chatConnectorsEnabled, githubReviewBotsEnabled })
? <AppsConnect credentialSource={credentialSource} />
: <Navigate to="/apps" replace />;
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function AppsSidebar() {
);
if (pathname.endsWith("/apps/chat/connect")) return <ChatSetupSidebar />;
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|reviews|conversations|activity))?\/?$/);
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|conversations|activity)|\/reviews(?:\/[^/]+)?)?\/?$/);
if (chatDetail) return <ChatDetailSidebar endpointId={chatDetail[1]} NavItem={SidebarNavItem} />;
return (
+1 -1
View File
@@ -27,7 +27,7 @@ export function AppsSidebar() {
});
if (pathname.endsWith("/apps/chat/connect")) return <ChatSetupSidebar />;
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|reviews|conversations|activity))?\/?$/);
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|conversations|activity)|\/reviews(?:\/[^/]+)?)?\/?$/);
if (chatDetail) return <ChatDetailSidebar endpointId={chatDetail[1]} />;
return (
@@ -104,6 +104,31 @@ describe("Chat connectors visibility gate", () => {
await vi.waitFor(() => expect(Boolean(container.querySelector("[data-chat-setup]"))).toBe(provider === "agentmail"));
if (provider !== "agentmail") expect(container.querySelector("[data-redirect]")).not.toBeNull();
});
it.each([true, false])("gates GitHub setup by its own flag, not chat (%s)", async (githubEnabled) => {
api.provider = "github";
api.settings.mockResolvedValue({ enableChatConnectors: !githubEnabled, enableGitHubReviewBots: githubEnabled });
await render();
expect(Boolean(container.querySelector("[data-chat-setup]"))).toBe(githubEnabled);
});
it.each(["github", "slack"])("gates each existing endpoint by provider with only GitHub enabled (%s)", async (provider) => {
api.endpointId = "endpoint-1";
api.get.mockResolvedValue({ provider });
api.settings.mockResolvedValue({ enableChatConnectors: false, enableGitHubReviewBots: true });
await render();
await vi.waitFor(() => expect(api.get).toHaveBeenCalledWith("endpoint-1"));
await vi.waitFor(() => expect(Boolean(container.querySelector("[data-chat-setup]"))).toBe(provider === "github"));
});
it("keeps GitHub agent channels visible while hiding Slack under the independent flag", async () => {
api.settings.mockResolvedValue({ enableChatConnectors: false, enableGitHubReviewBots: true });
api.list.mockResolvedValue([
{ provider: "github", id: "github", assignedAgentId: "agent-1", status: "active" },
{ provider: "slack", id: "slack", assignedAgentId: "agent-1", status: "active" },
]);
flushSync(() => root.render(<QueryClientProvider client={client}><AgentChannelsPanel companyId="company-1" agentId="agent-1" /></QueryClientProvider>));
await flushReact();
expect(container.querySelector('a[href="/apps/chat/github/settings"]')).not.toBeNull();
expect(container.querySelector('a[href="/apps/chat/slack/settings"]')).toBeNull();
});
it("waits without exposing setup, then enables the route after explicit opt-in", async () => {
let resolve!: (value: unknown) => void;
api.settings.mockReturnValue(
@@ -3,22 +3,41 @@ import { useQuery } from "@tanstack/react-query";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import {
useChatConnectorsEnabled,
chatProviderVisible,
} from "@/hooks/useChatConnectorsEnabled";
import { Navigate, useParams, useSearchParams } from "@/lib/router";
function DefaultEmailEndpointGate({ endpointId, children }: { endpointId: string; children: ReactNode }) {
function ProviderEndpointGate({
endpointId,
children,
}: {
endpointId: string;
children: ReactNode;
}) {
const endpoint = useQuery({
queryKey: queryKeys.chatEndpoints.detail(endpointId),
queryFn: () => chatEndpointsApi.get(endpointId),
});
if (endpoint.isError) return (
<div className="space-y-3 p-6">
<p role="alert" className="text-sm text-destructive">{endpoint.error.message}</p>
<Button variant="outline" onClick={() => void endpoint.refetch()}>Retry</Button>
</div>
);
const { enabled, githubEnabled } = useChatConnectorsEnabled();
if (endpoint.isError)
return (
<div className="space-y-3 p-6">
<p role="alert" className="text-sm text-destructive">
{endpoint.error.message}
</p>
<Button variant="outline" onClick={() => void endpoint.refetch()}>
Retry
</Button>
</div>
);
if (!endpoint.data) return null;
return endpoint.data.provider === "agentmail" ? <>{children}</> : <Navigate to="/apps" replace />;
return chatProviderVisible(endpoint.data.provider, enabled, githubEnabled) ? (
<>{children}</>
) : (
<Navigate to="/apps" replace />
);
}
export function ChatConnectorsExperimentalGate({
@@ -26,13 +45,21 @@ export function ChatConnectorsExperimentalGate({
}: {
children: ReactNode;
}) {
const { enabled, loaded } = useChatConnectorsEnabled();
const { enabled, githubEnabled, loaded } = useChatConnectorsEnabled();
const [params] = useSearchParams();
const { endpointId } = useParams<{ endpointId: string }>();
if (!endpointId && params.get("provider") === "agentmail") return <>{children}</>;
if (!endpointId && params.get("provider") === "agentmail")
return <>{children}</>;
if (!loaded) return null;
if (!enabled) return endpointId
? <DefaultEmailEndpointGate endpointId={endpointId}>{children}</DefaultEmailEndpointGate>
: <Navigate to="/apps" replace />;
return <>{children}</>;
if (endpointId)
return (
<ProviderEndpointGate endpointId={endpointId}>
{children}
</ProviderEndpointGate>
);
return chatProviderVisible(params.get("provider"), enabled, githubEnabled) ? (
<>{children}</>
) : (
<Navigate to="/apps" replace />
);
}
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { queryKeys } from "@/lib/queryKeys";
import { Link } from "@/lib/router";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
const providerNames: Record<ChatProvider, string> = {
slack: "Slack",
@@ -24,7 +24,7 @@ export function AgentChannelsPanel({
companyId: string;
agentId: string;
}) {
const { enabled } = useChatConnectorsEnabled();
const { enabled, githubEnabled } = useChatConnectorsEnabled();
const query = useQuery({
queryKey: queryKeys.chatEndpoints.list(companyId),
queryFn: () => chatEndpointsApi.list(companyId),
@@ -32,7 +32,7 @@ export function AgentChannelsPanel({
});
const endpoints = (query.data ?? []).filter(
(endpoint) =>
endpoint.assignedAgentId === agentId && endpoint.status !== "archived" && (endpoint.provider === "agentmail" || enabled),
endpoint.assignedAgentId === agentId && endpoint.status !== "archived" && chatProviderVisible(endpoint.provider, enabled, githubEnabled),
);
return (
<section className="max-w-3xl space-y-5">
@@ -56,7 +56,7 @@ export function AgentChannelsPanel({
<div className="rounded-lg border border-dashed border-border p-5">
<p className="text-sm font-medium">No channels connected</p>
<p className="mt-1 text-sm text-muted-foreground">
{enabled ? "Connect AgentMail, Slack, GitHub Code Review Bot, Discord, Microsoft Teams, or Telegram from Connectors." : "Connect AgentMail from Connectors."}
{enabled || githubEnabled ? "Connect an agent identity from Connectors." : "Connect AgentMail from Connectors."}
</p>
<Button asChild className="mt-3" variant="outline" size="sm">
<Link to="/apps">Open Connectors</Link>
@@ -51,7 +51,6 @@ export function ChatDetailSidebar({
to={`/apps/chat/${endpointId}/reviews`}
label="Reviews"
icon={GitPullRequest}
end
/>
)}
<NavItem
@@ -18,7 +18,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/context/ToastContext";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
import { issuesApi } from "@/api/issues";
import {
boardSendDraftKey,
@@ -109,15 +109,15 @@ const filePhaseLabels: Record<ChatFileTransferPhase, string> = {
};
export function useIssueChatBinding(companyId: string, issueId: string) {
const { enabled } = useChatConnectorsEnabled();
const queryEnabled = enabled && Boolean(companyId && issueId) && !issueId.startsWith("chat:");
const { enabled, githubEnabled } = useChatConnectorsEnabled();
const queryEnabled = (enabled || githubEnabled) && Boolean(companyId && issueId) && !issueId.startsWith("chat:");
const query = useQuery({
queryKey: ["issue-chat-binding", companyId, issueId],
queryFn: () => chatEndpointsApi.getIssueBinding(issueId),
enabled: queryEnabled,
});
return {
binding: queryEnabled ? (query.data ?? null) : null,
binding: queryEnabled && chatProviderVisible(query.data?.provider, enabled, githubEnabled) ? (query.data ?? null) : null,
isLoading: queryEnabled && query.isLoading,
};
}
@@ -58,7 +58,7 @@ import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
import { queryKeys } from "@/lib/queryKeys";
import { RadioCardGroup } from "@/components/ui/radio-card";
import { ApiError } from "@/api/client";
@@ -600,7 +600,7 @@ function StandardConnectionSetupFlow({
}, [host, routeNavigate]);
const routeParams = useParams<{ appKey?: string }>();
const { selectedCompany, selectedCompanyId } = useCompany();
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled();
const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
@@ -985,11 +985,11 @@ function StandardConnectionSetupFlow({
// Generic custom URLs remain usable without selecting a hidden provider.
const visibleGalleryApps = useMemo(
() => (galleryQuery.data?.apps ?? []).filter((app) => memoryConnectorsEnabled || !isMemoryConnectorId(app.slug)).filter((app) =>
app.slug === "agentmail" || chatConnectorsEnabled ||
chatProviderVisible(app.slug, chatConnectorsEnabled, githubEnabled) ||
!app.methods.some((method) => method.transport === "chat_sdk") ||
appSupportsToolCatalogSetup(app),
),
[galleryQuery.data, chatConnectorsEnabled, memoryConnectorsEnabled],
[galleryQuery.data, chatConnectorsEnabled, githubEnabled, memoryConnectorsEnabled],
);
const fullRequestedDefinition = requestedAppKey
? getConnectableAppDefinition(requestedAppKey)
+19 -2
View File
@@ -9,10 +9,11 @@ import { queryKeys } from "@/lib/queryKeys";
let detachedClient: QueryClient | null = null;
/** One default-off visibility gate; this does not pause existing provider delivery. */
/** Independent default-off visibility gates; these do not pause existing provider delivery. */
export function useChatConnectorsEnabled(): {
enabled: boolean;
loaded: boolean;
githubEnabled: boolean;
} {
const contextClient = useContext(QueryClientContext);
const { data, isFetched, isError } = useQuery(
@@ -23,9 +24,25 @@ export function useChatConnectorsEnabled(): {
},
contextClient ?? (detachedClient ??= new QueryClient()),
);
if (!contextClient) return { enabled: false, loaded: true };
if (!contextClient)
return { enabled: false, githubEnabled: false, loaded: true };
return {
enabled: !isError && data?.enableChatConnectors === true,
githubEnabled: !isError && data?.enableGitHubReviewBots === true,
loaded: isFetched,
};
}
/** Provider visibility only; never changes an existing connection's permissions. */
export function chatProviderVisible(
provider: string | null | undefined,
chatEnabled: boolean,
githubEnabled: boolean,
) {
return (
provider === "agentmail" ||
(provider === "github" || provider === "github-code-review-bot"
? githubEnabled
: chatEnabled)
);
}
+2 -1
View File
@@ -769,7 +769,8 @@ export function AgentDetail() {
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
const { enabled: chatConnectorsEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled();
const { enabled: chatEnabled, githubEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled();
const chatConnectorsEnabled = chatEnabled || githubEnabled;
const activeView = urlRunId ? "runs" as AgentDetailView
: urlTab === "channels" && !chatConnectorsEnabled ? "dashboard" : parseAgentDetailView(urlTab ?? null);
const needsDashboardData = activeView === "dashboard";
@@ -87,6 +87,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableMcpAggregators: true,
enablePublicMcp: false,
enableChatConnectors: false,
enableGitHubReviewBots: false,
enableMemoryConnectors: false,
enablePipelines: false,
enableCases: false,
@@ -283,6 +284,18 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
}
});
it("toggles GitHub review bots without enabling chat connectors", async () => {
await renderPage();
const selector = 'button[aria-label="Toggle GitHub review bots experimental setting"]';
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false");
for (const enabled of [true, false]) {
await act(() => container.querySelector<HTMLButtonElement>(selector)!.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableGitHubReviewBots: enabled });
expect(currentExperimentalSettings.enableChatConnectors).toBe(false);
}
});
it("does not render the Conference Room Chat experimental setting for now", async () => {
await renderPage();
+14 -2
View File
@@ -354,8 +354,8 @@ export function InstanceExperimentalSettings() {
<ExperimentalToggleCard
title="Chat connectors"
description="Connect agents to Slack, GitHub, Discord, Microsoft Teams, and Telegram conversations."
footnote="Turning this off hides experimental chat setup and connected-task controls. Existing chat connections keep running. AgentMail, GitHub tools, and other tool connectors stay available."
description="Connect agents to Slack, Discord, Microsoft Teams, and Telegram conversations."
footnote="Turning this off hides experimental chat setup and connected-task controls. Existing chat connections keep running. AgentMail, GitHub review bots, and tool connectors are controlled separately."
checked={enableChatConnectors}
onCheckedChange={(checked) => toggleMutation.mutate({ enableChatConnectors: checked })}
disabled={toggleMutation.isPending}
@@ -457,6 +457,18 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle first task plan proposal experimental setting"
/>
<ExperimentalToggleCard
title="GitHub review bots"
description="Connect agents to GitHub with their own App identity to review pull requests and respond to issues."
footnote="Independent of Chat connectors. Turning this off hides setup and management; existing bots keep running. GitHub tool connections stay available."
checked={experimentalQuery.data?.enableGitHubReviewBots === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableGitHubReviewBots: checked })}
disabled={toggleMutation.isPending}
settingKey="enableGitHubReviewBots"
managed={managedKeys.enableGitHubReviewBots}
ariaLabel="Toggle GitHub review bots experimental setting"
/>
<ExperimentalToggleCard
title="Memory connectors"
description="Connect Mem0, Zep, Supermemory, Cognee, and Honcho for long-term memory and context."
+6 -3
View File
@@ -189,7 +189,7 @@ describe("Connectors landing page", () => {
manageComposioAppAccountMock.mockReset();
listAgentsMock.mockResolvedValue([{ id: "default-agent", name: "Default agent", role: "ceo", reportsTo: null, status: "active", createdAt: new Date(0) }]);
aggregatorCatalogMock.splice(0);
experimentalMock.mockResolvedValue({ enableChatConnectors: true });
experimentalMock.mockResolvedValue({ enableChatConnectors: true, enableGitHubReviewBots: true });
chatListMock.mockResolvedValue([]);
chatSetupMock.mockReset().mockResolvedValue({ status: "archived" });
emailControlMock.mockReset().mockResolvedValue({ status: "archived" });
@@ -993,7 +993,7 @@ describe("Connectors landing page", () => {
expect(container.querySelector('button[aria-label="Add key GitHub"]')).toBeNull();
});
it("separates tools and saved bots and hides only bots when chat connectors are disabled", async () => {
it("separates tools and saved bots and gates bots independently of chat connectors", async () => {
listGalleryMock.mockResolvedValue({ apps: ["github", "github-code-review-bot"].map(getAppStoreDefinition) });
listApplicationsMock.mockResolvedValue({ applications: [
application({ id: "github-tools", name: "GitHub", metadata: { sourceTemplateKey: "github" } }),
@@ -1023,7 +1023,10 @@ describe("Connectors landing page", () => {
const finish = [...bots.querySelectorAll("button")].find((button) => button.textContent === "Finish setup")!;
await act(() => finish.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat&resume=endpoint-2");
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false }); });
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false, enableGitHubReviewBots: true }); });
await flushReact();
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: true, enableGitHubReviewBots: false }); });
await flushReact();
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).toBeNull();
expect(container.textContent).not.toContain("Review agent");
+11 -7
View File
@@ -40,7 +40,7 @@ import {
normalizeConnectionQuery,
} from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
import { appCopyFor } from "@/lib/app-gallery-copy";
import { useCompany } from "@/context/CompanyContext";
@@ -332,7 +332,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
const { selectedCompanyId } = useCompany();
const assistantConnections = useAssistantConnections();
const { userId: viewingUserId, settled: identitySettled } = useAccountIdentity();
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled();
const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled();
const { setBreadcrumbs } = useBreadcrumbs();
const [query, setQuery] = useState("");
@@ -486,7 +486,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
if (!memoryConnectorsEnabled && isMemoryConnectorId(appDefinitionSlug(entry))) return false;
const definition = getAppStoreDefinition(appDefinitionSlug(entry));
return (
appDefinitionSlug(entry) === "agentmail" || chatConnectorsEnabled ||
chatProviderVisible(appDefinitionSlug(entry), chatConnectorsEnabled, githubEnabled) ||
!definition?.methods.some((method) => method.purpose === "channel") ||
appSupportsToolCatalogSetup(definition)
);
@@ -536,7 +536,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
slug,
name: appDefinitionName(entry),
description:
!chatConnectorsEnabled && slug !== "agentmail" && chatProviderForSlug(slug)
!chatProviderVisible(slug, chatConnectorsEnabled, githubEnabled) && chatProviderForSlug(slug)
? appCopyFor(slug).tagline
: appDefinitionDescription(entry),
brandKey: slug,
@@ -581,7 +581,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
"Chat with agents from Telegram direct messages, groups, and topics.",
},
] as const;
for (const item of nativeChatApps.filter(item => item.slug === "agentmail" || chatConnectorsEnabled)) {
for (const item of nativeChatApps.filter(item => chatProviderVisible(item.slug, chatConnectorsEnabled, githubEnabled))) {
if (rowsBySlug.has(item.slug)) continue;
rowsBySlug.set(item.slug, {
key: `native-chat:${item.slug}`,
@@ -678,7 +678,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
});
}
for (const endpoint of (chatEndpointsQuery.data ?? []).filter(endpoint => endpoint.provider === "agentmail" || chatConnectorsEnabled)) {
for (const endpoint of (chatEndpointsQuery.data ?? []).filter(endpoint => chatProviderVisible(endpoint.provider, chatConnectorsEnabled, githubEnabled))) {
if (endpoint.status === "archived") continue;
let target = [...rowsBySlug.values()].find(
(row) => chatProviderForSlug(row.slug) === endpoint.provider,
@@ -754,6 +754,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
applicationsQuery.data,
chatEndpointsQuery.data,
chatConnectorsEnabled,
githubEnabled,
connectionsQuery.data,
gallery,
galleryQuery.data,
@@ -930,6 +931,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
onRequestRemove={target => void requestConnectionRemoval(target)}
preselectedAgentId={preselectedChatAgentId}
chatConnectorsEnabled={chatConnectorsEnabled}
githubReviewBotsEnabled={githubEnabled}
onConnectAggregator={connectAggregator}
onManageAggregator={(app, connectionId) => setAggregatorToManage({ app, connectionId })}
onRefreshAggregator={connectionId => refreshAggregator.mutate(connectionId)}
@@ -1026,6 +1028,7 @@ export function ConnectorCard({
onRequestRemove,
preselectedAgentId,
chatConnectorsEnabled,
githubReviewBotsEnabled = false,
onConnectAggregator,
onManageAggregator,
onRefreshComposio,
@@ -1044,6 +1047,7 @@ export function ConnectorCard({
onRequestRemove: (target: ConnectionRemovalTarget) => void;
preselectedAgentId?: string | null;
chatConnectorsEnabled: boolean;
githubReviewBotsEnabled?: boolean;
onConnectAggregator?: (app: AggregatorAppCatalogEntry) => void;
onManageAggregator?: (app: AggregatorAppCatalogEntry, connectionId?: string) => void;
onRefreshComposio?: (connectionId: string) => void;
@@ -1056,7 +1060,7 @@ export function ConnectorCard({
}) {
const action = connectorAction(
row,
chatConnectorsEnabled,
chatProviderVisible(row.slug, chatConnectorsEnabled, githubReviewBotsEnabled),
preselectedAgentId,
);
const upstreamAccounts = (row.upstreamApps ?? []).flatMap(snapshot => {
+8 -1
View File
@@ -83,7 +83,7 @@ describe("app connect policy", () => {
const href = appSourceConnectHref(app.slug);
const searchParams = new URL(href, "http://paperclip.test").searchParams;
expect(canEnterAppsConnect(searchParams, { chatConnectorsEnabled: true }), app.slug).toBe(true);
expect(canEnterAppsConnect(searchParams, { chatConnectorsEnabled: true, githubReviewBotsEnabled: true }), app.slug).toBe(true);
expect(resolveAppsConnectRouteKey({ sourceSlug: searchParams.get("source") }), app.slug).toBe(app.slug);
}
});
@@ -97,6 +97,13 @@ describe("app connect policy", () => {
expect(resolveAppsConnectRouteKey({})).toBeUndefined();
});
it("admits the GitHub bot independently of chat setup and keeps tools available", () => {
const bot = new URLSearchParams("source=github-code-review-bot");
expect(canEnterAppsConnect(bot, { chatConnectorsEnabled: true })).toBe(false);
expect(canEnterAppsConnect(bot, { githubReviewBotsEnabled: true })).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=discord"), { githubReviewBotsEnabled: true })).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(true);
});
it("retains GitHub tools but denies chat-only deep links while chat connectors are disabled", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=agentmail"))).toBe(true);
+4 -3
View File
@@ -55,7 +55,7 @@ export function resolveAppsConnectRouteKey(input: {
export function canEnterAppsConnect(
searchParams: URLSearchParams,
{ chatConnectorsEnabled = false }: { chatConnectorsEnabled?: boolean } = {},
{ chatConnectorsEnabled = false, githubReviewBotsEnabled = false }: { chatConnectorsEnabled?: boolean; githubReviewBotsEnabled?: boolean } = {},
): boolean {
if (searchParams.get("byo") === "1") {
// The old BYO discovery page has moved to the Connectors list. Keep only
@@ -74,8 +74,9 @@ export function canEnterAppsConnect(
// actionable unavailable state rather than bouncing back to the catalog.
if (/^ai-router-(?:[a-f0-9]{2})+$/.test(source)) return true;
const entry = getAppStoreDefinition(source);
const channelEnabled = source === "github-code-review-bot" ? githubReviewBotsEnabled : chatConnectorsEnabled;
if (
!chatConnectorsEnabled &&
!channelEnabled &&
entry?.methods.some((method) => method.transport === "chat_sdk") &&
!entry.methods.some((method) => (method.purpose ?? "tool") !== "channel" && method.transport !== "chat_sdk")
) return false;
@@ -83,5 +84,5 @@ export function canEnterAppsConnect(
// setup. Admit only known providers here; the setup flow then proves the
// exact reconnect target is visible to the selected company before rendering.
if (getConnectableAppDefinition(source) && searchParams.get("reconnect")?.trim()) return true;
return source === "agentmail" || chatConnectorsEnabled ? appSupportsCatalogSetup(entry) : appSupportsToolCatalogSetup(entry);
return source === "agentmail" || channelEnabled ? appSupportsCatalogSetup(entry) : appSupportsToolCatalogSetup(entry);
}
@@ -227,10 +227,12 @@ export function connectionHealthPresentation(
}
export function ChatEndpointDetail() {
const { endpointId = "", tab = "settings" } = useParams<{
const { endpointId = "", tab: routeTab, reviewId } = useParams<{
endpointId: string;
tab?: string;
reviewId?: string;
}>();
const tab = reviewId ? "reviews" : (routeTab ?? "settings");
const activeTab = tabs.includes(tab as ChatTab) ? (tab as ChatTab) : null;
const navigate = useNavigate();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -260,14 +262,14 @@ export function ChatEndpointDetail() {
label: `${endpoint.assignedAgentName} · ${providerNames[endpoint.provider]}`,
href: `/apps/chat/${endpoint.id}/settings`,
},
{
...(reviewId ? [{ label: "Reviews", href: `/apps/chat/${endpoint.id}/reviews` }, { label: "Review" }] : [{
label:
tabItems.find((item) => item.value === activeTab)?.label ??
"Settings",
},
}]),
]);
return () => setBreadcrumbs([]);
}, [activeTab, endpoint, setBreadcrumbs]);
}, [activeTab, endpoint, reviewId, setBreadcrumbs]);
if (!activeTab)
return <Navigate replace to={`/apps/chat/${endpointId}/settings`} />;
@@ -339,7 +341,7 @@ export function ChatEndpointDetail() {
<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 === "reviews" && endpoint.provider === "github" && <GitHubReviews endpointId={endpoint.id} reviewId={reviewId} />}
{activeTab === "access" && endpoint.provider === "agentmail" && <EmailAccess endpoint={endpoint} />}
{activeTab === "access" && endpoint.provider !== "github" && endpoint.provider !== "agentmail" && (
<Access
@@ -11,7 +11,7 @@ import { accessApi } from "@/api/access";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { githubChatApi } from "@/api/githubChat";
import { Button } from "@/components/ui/button";
import { Copy, MoreHorizontal } from "lucide-react";
import { Copy, HelpCircle, MoreHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,6 +23,11 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Link } from "@/lib/router";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export const githubSelectClass =
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
@@ -34,9 +39,35 @@ const eventLabels = {
mention: "Mention",
comment: "Follow-up comment",
};
function GitHubHelp({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About ${label}`}
className="inline-flex shrink-0 items-center text-muted-foreground hover:text-foreground"
onClick={() => setOpen(true)}
>
<HelpCircle className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{children}</TooltipContent>
</Tooltip>
);
}
export function GitHubToggle({
label,
description,
help,
checked,
onChange,
ariaLabel,
@@ -44,6 +75,7 @@ export function GitHubToggle({
}: {
label: string;
description?: string;
help?: string;
checked: boolean;
onChange: (checked: boolean) => void;
ariaLabel?: string;
@@ -52,7 +84,10 @@ export function GitHubToggle({
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<p className="break-words text-sm font-medium">{label}</p>
<div className="flex items-center gap-2">
<p className="break-words text-sm font-medium">{label}</p>
{help && <GitHubHelp label={label}>{help}</GitHubHelp>}
</div>
{description && (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
)}
@@ -392,12 +427,13 @@ export function GitHubPolicyEditor({
<h3 className="text-sm font-medium">Formal review actions</h3>
<GitHubToggle
label="Allow approvals"
description="The agent must choose this action. A 5/5 score does not approve a PR."
help="Lets the agent submit an Approve review on GitHub after a complete assessment. The agent must explicitly choose it; a 5/5 score or passing check does not approve the PR. Off still allows scores, comments, and checks."
checked={policy.allowApprove}
onChange={(value) => set("allowApprove", value)}
/>
<GitHubToggle
label="Allow request changes"
help="Lets the agent submit a Request changes review on GitHub after a complete assessment. This is a formal review decision and may prevent merging under your repository rules. Off still allows findings, comments, and a failing check."
checked={policy.allowRequestChanges}
onChange={(value) => set("allowRequestChanges", value)}
/>
@@ -597,8 +633,9 @@ export function GitHubAccessEditor({
</DropdownMenu>
</div>
<GitHubToggle
label="Automatic events"
ariaLabel={`Automatic events for @${person.login}`}
label="Run automatically"
ariaLabel={`Run automatically for @${person.login}`}
help="Lets PRs and issues authored by this person start work without mentioning the bot. Only events enabled in Settings run. Turn this off to keep this person’s use mention-only."
checked={person.automaticReviews}
onChange={(value) =>
onChange({
@@ -699,7 +736,7 @@ export function GitHubAccessEditor({
)}
</div>
<Button variant="ghost" size="sm" onClick={() => setKind("guest")}>
Allow external contributor
Add external contributor
</Button>
{kind === "guest" && (
<div className="space-y-4 rounded-lg border border-border p-4">
@@ -782,26 +819,41 @@ export function GitHubAccessEditor({
})
}
>
Allow this account
Add contributor
</Button>
</div>
</div>
)}
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
Automatic task ownership
Automatic task responsibility
</summary>
<div className="mt-4 space-y-2">
{activeMembers.length === 1 && responsible ? (
<p>
Responsible member:{" "}
<span className="inline-flex items-center gap-2">
Responsible member{" "}
<GitHubHelp label="Responsible member">
The Paperclip member accountable for automatically created
tasks. This does not change the GitHub author or grant the bot
access to the member’s personal credentials.
</GitHubHelp>
</span>
:{" "}
{responsible.user?.name ??
responsible.user?.email ??
responsible.principalId}
</p>
) : (
<>
<Label htmlFor="github-responsible">Responsible member</Label>
<div className="flex items-center gap-2">
<Label htmlFor="github-responsible">Responsible member</Label>
<GitHubHelp label="Responsible member">
The Paperclip member accountable for automatically created
tasks. This does not change the GitHub author or grant the bot
access to the member’s personal credentials.
</GitHubHelp>
</div>
<select
id="github-responsible"
className={githubSelectClass}
@@ -829,8 +881,8 @@ export function GitHubAccessEditor({
</>
)}
<p className="text-xs text-muted-foreground">
Accountable for automatic tasks. GitHub authors remain recorded
separately.
Automatically created tasks are attributed to this member in
Paperclip. The GitHub author is recorded separately.
</p>
</div>
</details>
@@ -9,7 +9,7 @@ import {
type GitHubTaskReview,
} from "@paperclipai/shared";
import { ChatEndpointDetail } from "./ChatEndpointDetail";
import { GitHubReviewList, groupGitHubReviews } from "./GitHubBotManagement";
import { GitHubReviewList, orderedGitHubReviews } from "./GitHubBotManagement";
import { GitHubPolicyEditor } from "./GitHubBotConfiguration";
import { conversationDestination } from "./ChatConversationList";
import { queryKeys } from "@/lib/queryKeys";
@@ -17,6 +17,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
const mocks = vi.hoisted(() => ({
tab: "settings",
reviewId: undefined as string | undefined,
get: vi.fn(),
config: vi.fn(),
save: vi.fn(),
@@ -56,7 +57,11 @@ vi.mock("@/components/MarkdownBody", () => ({
),
}));
vi.mock("@/lib/router", () => ({
useParams: () => ({ endpointId: "bot", tab: mocks.tab }),
useParams: () => ({
endpointId: "bot",
tab: mocks.tab,
reviewId: mocks.reviewId,
}),
useNavigate: () => vi.fn(),
Link: ({
children,
@@ -108,6 +113,7 @@ describe("GitHub bot management", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.tab = "settings";
mocks.reviewId = undefined;
mocks.get.mockResolvedValue(endpoint);
mocks.config.mockResolvedValue({
revision: 4,
@@ -216,7 +222,7 @@ describe("GitHub bot management", () => {
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 click("Run automatically for @maya");
await render("reviews");
await render("settings");
expect(container.querySelector("textarea")?.value).toBe(
@@ -312,11 +318,52 @@ describe("GitHub bot management", () => {
expect(mocks.save.mock.calls[0][2].memberAccess).toBe("all_linked");
expect(mocks.save.mock.calls[0][2].people[0].automaticReviews).toBe(false);
});
it("does not show an off switch or silently enable a retained disabled bot", async () => {
mocks.config.mockResolvedValue({
revision: 4,
configuration: { ...base, toolsEnabled: false },
});
await render("access");
expect(
container.querySelector('[aria-label="Use this bot’s GitHub tools"]'),
).toBeNull();
expect(container.textContent).toContain("cannot start work or respond");
await render("settings");
await input(container.querySelector("textarea")!, "Edited instructions");
await click("Save changes");
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
expect(mocks.save.mock.calls[0][2].toolsEnabled).toBe(false);
});
it("loads a direct review URL and keeps unknown reviews within the current connection", async () => {
mocks.reviewId = "review-1";
mocks.reviews.mockResolvedValue([
review("review-1", "repo", 1, "2026-10-07T10:00:00Z"),
]);
await render("reviews");
await vi.waitFor(() =>
expect(container.textContent).toContain(
"No assessment has been submitted yet.",
),
);
expect(
container.querySelector('a[href="/apps/chat/bot/reviews"]')?.textContent,
).toContain("All reviews");
mocks.reviewId = "foreign";
await render("reviews");
await vi.waitFor(() =>
expect(container.textContent).toContain("not found in this connection"),
);
expect(container.textContent).not.toContain(
"No assessment has been submitted yet.",
);
});
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} />,
<TooltipProvider>
<GitHubPolicyEditor policy={base.defaults} onChange={change} />
</TooltipProvider>,
),
);
await input(
@@ -355,7 +402,7 @@ function review(
} 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", () => {
it("keeps every review in creation order, independent of late updates to an older head", () => {
const old = review(
"old",
"repo",
@@ -365,9 +412,10 @@ describe("review history and thread labels", () => {
);
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],
expect(orderedGitHubReviews([old, current, other])).toEqual([
other,
current,
old,
]);
});
it("does not present a previous passing score as the pending current commit’s result", async () => {
@@ -388,18 +436,22 @@ describe("review history and thread labels", () => {
const root = createRoot(container);
try {
await act(async () =>
root.render(<GitHubReviewList reviews={[old, current]} />),
root.render(
<GitHubReviewList endpointId="bot" 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"),
const rows = [...container.querySelectorAll("li")];
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain("Queued");
expect(rows[0].textContent).not.toContain("5/5");
expect(rows[0].querySelector("a")?.getAttribute("href")).toBe(
"/apps/chat/bot/reviews/current",
);
expect(history?.textContent).toContain("5/5 · Passed");
expect(history?.open).toBe(false);
expect(rows[1].textContent).toContain("5/5 · Passed");
expect(rows[1].querySelector("a")?.getAttribute("href")).toBe(
"/apps/chat/bot/reviews/old",
);
expect(container.querySelector("details")).toBeNull();
} finally {
await act(async () => root.unmount());
}
+170 -91
View File
@@ -217,23 +217,28 @@ export function GitHubBotManagement({
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}`}
{!config.toolsEnabled && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
This saved bot has GitHub tools disabled, so it cannot start
work or respond.
</p>
<Button
variant="outline"
onClick={() => edit({ ...config, toolsEnabled: true })}
>
Manage tool permissions
</Link>
)}
</section>
Enable bot tools
</Button>
</div>
)}
{endpoint.connectionId && (
<Link
className="text-xs text-muted-foreground underline underline-offset-4"
to={`/apps/${endpoint.connectionId}`}
>
Manage tool permissions
</Link>
)}
</>
) : (
<>
@@ -360,18 +365,13 @@ export function GitHubBotManagement({
);
}
export function groupGitHubReviews(reviews: GitHubTaskReview[]) {
const groups = new Map<string, GitHubTaskReview[]>();
for (const review of [...reviews].sort(
export function orderedGitHubReviews(reviews: GitHubTaskReview[]) {
return [...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 }) {
export function gitHubReviewResultLabel(review: GitHubTaskReview) {
const completed = review.state === "completed" && review.assessment?.complete;
const conclusion = review.conclusion
? {
@@ -390,20 +390,30 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
superseded: "Superseded",
manual_required: "Action required",
}[review.state];
return completed ? `${review.assessment!.score}/5 · ${conclusion}` : state;
}
function ReviewResult({ review }: { review: GitHubTaskReview }) {
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}
{gitHubReviewResultLabel(review)}
</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>
{review.assessment ? (
<MarkdownBody className="text-sm">
{review.assessment.summary}
</MarkdownBody>
) : (
<p className="text-sm text-muted-foreground">
No assessment has been submitted yet.
</p>
)}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<Link
className="underline underline-offset-4"
@@ -432,11 +442,8 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
</a>
)}
</div>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
Review details
</summary>
<div className="mt-3 space-y-2">
<section className="space-y-4 text-sm" aria-label="Review evidence">
<div className="space-y-3">
{review.runId && (
<Link
className="text-xs underline underline-offset-4"
@@ -447,7 +454,25 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
)}
{review.assessment && (
<>
<h3 className="font-medium">Rationale</h3>
<MarkdownBody>{review.assessment.rationale}</MarkdownBody>
{review.assessment.findings.length > 0 && (
<section className="space-y-3">
<h3 className="font-medium">Findings</h3>
{review.assessment.findings.map((finding) => (
<div
key={finding.key}
className="space-y-2 border-l border-border pl-4"
>
<p className="break-all font-mono text-xs">
{finding.path}:{finding.line} · {finding.severity}
</p>
<MarkdownBody>{finding.body}</MarkdownBody>
</div>
))}
</section>
)}
<h3 className="font-medium">Coverage</h3>
<p className="text-xs text-muted-foreground">
{review.assessment.coverage.reviewedPaths.length} files reviewed
· {review.assessment.coverage.omittedPaths.length} omitted
@@ -460,12 +485,18 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
</>
)}
</div>
</details>
</section>
</div>
);
}
export function GitHubReviewList({ reviews }: { reviews: GitHubTaskReview[] }) {
export function GitHubReviewList({
endpointId,
reviews,
}: {
endpointId: string;
reviews: GitHubTaskReview[];
}) {
if (!reviews.length)
return (
<EmptyState
@@ -475,70 +506,118 @@ export function GitHubReviewList({ reviews }: { reviews: GitHubTaskReview[] }) {
/>
);
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>
<ul
aria-label="Reviews"
className="divide-y divide-border border-y border-border"
>
{orderedGitHubReviews(reviews).map((review) => (
<li key={review.id}>
<Link
to={`/apps/chat/${endpointId}/reviews/${review.id}`}
className="flex flex-wrap items-center gap-x-6 gap-y-2 py-4 text-sm hover:bg-accent/50"
>
<div className="min-w-0 flex-1 basis-48 space-y-1">
<p className="break-words font-medium">
{review.event.title || `Pull request #${review.pullNumber}`}
</p>
<p className="break-all text-xs text-muted-foreground">
{review.repository} #{review.pullNumber} ·{" "}
<code>{review.headSha.slice(0, 7)}</code>
</p>
</div>
<div className="space-y-1 text-right">
<p className="font-medium">{gitHubReviewResultLabel(review)}</p>
<time
className="text-xs text-muted-foreground"
dateTime={review.createdAt}
>
{formatDateTime(review.createdAt)}
</time>
</div>
</Link>
</li>
))}
</div>
</ul>
);
}
export function GitHubReviews({ endpointId }: { endpointId: string }) {
export function GitHubReviewDetail({
endpointId,
review,
}: {
endpointId: string;
review: GitHubTaskReview;
}) {
return (
<article className="max-w-3xl space-y-6">
<Link
className="text-sm text-muted-foreground hover:underline"
to={`/apps/chat/${endpointId}/reviews`}
>
← All reviews
</Link>
<header className="space-y-2">
<h2 className="text-lg font-semibold">
{review.event.title || `Pull request #${review.pullNumber}`}
</h2>
<a
className="break-all text-sm text-muted-foreground hover:underline"
href={`https://github.com/${review.repository}/pull/${review.pullNumber}`}
target="_blank"
rel="noreferrer"
>
{review.repository} #{review.pullNumber}
</a>
</header>
<ReviewResult review={review} />
</article>
);
}
export function GitHubReviews({
endpointId,
reviewId,
}: {
endpointId: string;
reviewId?: string;
}) {
const query = useQuery({
queryKey: ["github-bot-reviews", endpointId],
queryFn: () => githubChatApi.reviews(endpointId),
refetchInterval: 5000,
});
return (
<section className="space-y-4">
<p className="text-sm text-muted-foreground">
Latest review for each pull request. Results apply to the commit shown.
if (query.isError)
return (
<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.isError ? (
<p role="alert" className="text-sm text-destructive">
Reviews could not be loaded.{" "}
<Button variant="link" onClick={() => void query.refetch()}>
Try again
</Button>
);
if (query.isPending)
return (
<p role="status" className="text-sm text-muted-foreground">
Loading reviews…
</p>
);
if (reviewId) {
const review = query.data.find((review) => review.id === reviewId);
return review ? (
<GitHubReviewDetail endpointId={endpointId} review={review} />
) : (
<div className="space-y-3">
<p role="alert" className="text-sm text-muted-foreground">
This review was not found in this connection.
</p>
) : query.isPending ? (
<p role="status" className="text-sm text-muted-foreground">
Loading reviews…
</p>
) : (
<GitHubReviewList reviews={query.data} />
)}
</section>
);
<Link
to={`/apps/chat/${endpointId}/reviews`}
className="text-sm underline"
>
All reviews
</Link>
</div>
);
}
return <GitHubReviewList endpointId={endpointId} reviews={query.data} />;
}
@@ -67,13 +67,6 @@ describe("chat connector UI contract", () => {
expect(detail).toContain("Not observed");
});
it("lists every supported provider in the agent channel empty state", () => {
const panel = source("../../../components/chat/AgentChannelsPanel.tsx");
expect(panel).toContain(
"Connect AgentMail, Slack, GitHub Code Review Bot, Discord, Microsoft Teams, or Telegram from",
);
});
it("keeps provider capabilities automatic and settings focused on plausible reach", () => {
const detail = source("./ChatEndpointDetail.tsx");
const setup = source("./ChatEndpointSetup.tsx");