chore: import upstream snapshot with attribution
This commit is contained in:
Generated
+5914
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@github/mcp-server-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP App UIs for github-mcp-server using Primer React",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"dev": "npm run build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@github/markdown-toolbar-element": "^2.2.3",
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.2",
|
||||
"@primer/octicons-react": "^19.0.0",
|
||||
"@primer/react": "^36.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-singlefile": "^2.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Build all UI apps in a single Node process.
|
||||
//
|
||||
// Replaces serial `cross-env APP=<app> vite build` invocations: doing it
|
||||
// in one process avoids paying Vite/plugin startup cost for each app and is
|
||||
// portable without `cross-env`.
|
||||
|
||||
import { build } from "vite";
|
||||
|
||||
const apps = ["get-me", "issue-write", "pr-write", "pr-edit"];
|
||||
|
||||
for (const app of apps) {
|
||||
process.env.APP = app;
|
||||
await build();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { StrictMode, useState } from "react";
|
||||
import type React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Avatar, Box, Text, Link, Heading, Spinner } from "@primer/react";
|
||||
import {
|
||||
OrganizationIcon,
|
||||
LocationIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
PeopleIcon,
|
||||
RepoIcon,
|
||||
PersonIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { AppProvider } from "../../components/AppProvider";
|
||||
import { useMcpApp } from "../../hooks/useMcpApp";
|
||||
|
||||
interface UserData {
|
||||
login: string;
|
||||
avatar_url?: string;
|
||||
details?: {
|
||||
name?: string;
|
||||
company?: string;
|
||||
location?: string;
|
||||
blog?: string;
|
||||
email?: string;
|
||||
twitter_username?: string;
|
||||
public_repos?: number;
|
||||
followers?: number;
|
||||
following?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function AvatarWithFallback({ src, login, size }: { src?: string; login: string; size: number }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
if (!src || imgError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
bg: "accent.subtle",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
mr: 3,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<PersonIcon size={size * 0.6} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={src}
|
||||
size={size}
|
||||
sx={{ mr: 3 }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCard({
|
||||
user,
|
||||
onOpenLink,
|
||||
}: {
|
||||
user: UserData;
|
||||
onOpenLink?: (url: string) => void;
|
||||
}) {
|
||||
const d = user.details || {};
|
||||
const handleClick =
|
||||
onOpenLink &&
|
||||
((url: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenLink(url);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
borderRadius={2}
|
||||
bg="canvas.subtle"
|
||||
p={3}
|
||||
maxWidth={400}
|
||||
>
|
||||
{/* Header with avatar and name */}
|
||||
<Box display="flex" alignItems="center" mb={3} pb={3} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
|
||||
<AvatarWithFallback src={user.avatar_url} login={user.login} size={48} />
|
||||
<Box>
|
||||
<Heading as="h2" sx={{ fontSize: 2, mb: 0 }}>
|
||||
{d.name || user.login}
|
||||
</Heading>
|
||||
<Text sx={{ color: "fg.muted", fontSize: 1 }}>@{user.login}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Info grid */}
|
||||
<Box display="grid" sx={{ gridTemplateColumns: "auto 1fr", gap: 2, fontSize: 1 }}>
|
||||
{d.company && (
|
||||
<>
|
||||
<Box sx={{ color: "fg.muted" }}><OrganizationIcon size={16} /></Box>
|
||||
<Text>{d.company}</Text>
|
||||
</>
|
||||
)}
|
||||
{d.location && (
|
||||
<>
|
||||
<Box sx={{ color: "fg.muted" }}><LocationIcon size={16} /></Box>
|
||||
<Text>{d.location}</Text>
|
||||
</>
|
||||
)}
|
||||
{d.blog && (
|
||||
<>
|
||||
<Box sx={{ color: "fg.muted" }}><LinkIcon size={16} /></Box>
|
||||
<Link
|
||||
href={d.blog}
|
||||
target="_blank"
|
||||
onClick={handleClick?.(d.blog)}
|
||||
>
|
||||
{d.blog}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{d.email && (
|
||||
<>
|
||||
<Box sx={{ color: "fg.muted" }}><MailIcon size={16} /></Box>
|
||||
<Link href={`mailto:${d.email}`}>{d.email}</Link>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box display="flex" justifyContent="space-around" mt={3} pt={3} borderTopWidth={1} borderTopStyle="solid" borderTopColor="border.default">
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
<Text sx={{ fontWeight: "bold", fontSize: 2, display: "block" }}>
|
||||
<RepoIcon size={16} /> {d.public_repos ?? 0}
|
||||
</Text>
|
||||
<Text sx={{ color: "fg.muted", fontSize: 0 }}>Repos</Text>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
<Text sx={{ fontWeight: "bold", fontSize: 2, display: "block" }}>
|
||||
<PeopleIcon size={16} /> {d.followers ?? 0}
|
||||
</Text>
|
||||
<Text sx={{ color: "fg.muted", fontSize: 0 }}>Followers</Text>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
<Text sx={{ fontWeight: "bold", fontSize: 2, display: "block" }}>
|
||||
{d.following ?? 0}
|
||||
</Text>
|
||||
<Text sx={{ color: "fg.muted", fontSize: 0 }}>Following</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function GetMeApp() {
|
||||
const { error, toolResult, hostContext, openLink } = useMcpApp({
|
||||
appName: "github-mcp-server-get-me",
|
||||
});
|
||||
|
||||
const content = (() => {
|
||||
if (error) {
|
||||
return <Text sx={{ color: "danger.fg" }}>Error: {error.message}</Text>;
|
||||
}
|
||||
if (!toolResult) {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Spinner size="small" />
|
||||
<Text sx={{ color: "fg.muted" }}>Loading user data...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text");
|
||||
if (!textContent || !("text" in textContent)) {
|
||||
return <Text sx={{ color: "danger.fg" }}>No user data in response</Text>;
|
||||
}
|
||||
try {
|
||||
const userData = JSON.parse(textContent.text as string) as UserData;
|
||||
return <UserCard user={userData} onOpenLink={(url) => void openLink(url)} />;
|
||||
} catch {
|
||||
return <Text sx={{ color: "danger.fg" }}>Failed to parse user data</Text>;
|
||||
}
|
||||
})();
|
||||
|
||||
return <AppProvider hostContext={hostContext}>{content}</AppProvider>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<GetMeApp />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data:; img-src 'self' data: https://avatars.githubusercontent.com https://*.githubusercontent.com; connect-src *;" />
|
||||
<title>GitHub User Profile</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./App.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create GitHub Issue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./App.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,783 @@
|
||||
import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Flash,
|
||||
Spinner,
|
||||
FormControl,
|
||||
ActionMenu,
|
||||
ActionList,
|
||||
Checkbox,
|
||||
ButtonGroup,
|
||||
CounterLabel,
|
||||
Label,
|
||||
} from "@primer/react";
|
||||
import {
|
||||
GitPullRequestIcon,
|
||||
CheckCircleIcon,
|
||||
GitBranchIcon,
|
||||
LockIcon,
|
||||
PersonIcon,
|
||||
PeopleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { AppProvider } from "../../components/AppProvider";
|
||||
import { useMcpApp } from "../../hooks/useMcpApp";
|
||||
import { completedToolResult } from "../../lib/toolResult";
|
||||
import { MarkdownEditor } from "../../components/MarkdownEditor";
|
||||
|
||||
interface PRResult {
|
||||
ID?: string;
|
||||
number?: number;
|
||||
title?: string;
|
||||
url?: string;
|
||||
html_url?: string;
|
||||
URL?: string;
|
||||
}
|
||||
|
||||
interface BranchItem {
|
||||
name: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string };
|
||||
type PRState = "open" | "closed";
|
||||
|
||||
interface InitialPRState {
|
||||
title: string;
|
||||
body: string;
|
||||
state: PRState;
|
||||
base: string;
|
||||
draft: boolean;
|
||||
maintainerCanModify: boolean;
|
||||
reviewers: string[];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reviewerFromValue(value: string): ReviewerItem {
|
||||
if (value.includes("/")) {
|
||||
const [org, slug] = value.split("/", 2);
|
||||
return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org };
|
||||
}
|
||||
return { kind: "user", id: value, text: value };
|
||||
}
|
||||
|
||||
function reviewerValue(reviewer: ReviewerItem): string {
|
||||
return reviewer.kind === "team" ? reviewer.id : reviewer.text;
|
||||
}
|
||||
|
||||
function sameReviewerValues(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((value, index) => value === sortedB[index]);
|
||||
}
|
||||
|
||||
function parseUserReviewer(value: unknown): ReviewerItem | null {
|
||||
if (typeof value === "string") return reviewerFromValue(value);
|
||||
const record = asRecord(value);
|
||||
const login = asString(record?.login);
|
||||
if (!login) return null;
|
||||
return { kind: "user", id: login, text: login, avatar: asString(record?.avatar_url) };
|
||||
}
|
||||
|
||||
function parseTeamReviewer(value: unknown, fallbackOrg: string): ReviewerItem | null {
|
||||
if (typeof value === "string") {
|
||||
if (value.includes("/")) return reviewerFromValue(value);
|
||||
return { kind: "team", id: `${fallbackOrg}/${value}`, text: `${fallbackOrg}/${value}`, org: fallbackOrg };
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
const slug = asString(record?.slug) || asString(record?.name);
|
||||
if (!slug) return null;
|
||||
|
||||
const organization = asRecord(record?.organization);
|
||||
const org = asString(record?.org) || asString(organization?.login) || fallbackOrg;
|
||||
const id = org ? `${org}/${slug}` : slug;
|
||||
return { kind: "team", id, text: id, org };
|
||||
}
|
||||
|
||||
function reviewersFromValues(values: unknown): ReviewerItem[] | undefined {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
return values
|
||||
.map((value) => (typeof value === "string" ? reviewerFromValue(value) : null))
|
||||
.filter((value): value is ReviewerItem => value !== null);
|
||||
}
|
||||
|
||||
function extractRequestedReviewers(prData: Record<string, unknown>, owner: string): ReviewerItem[] {
|
||||
const requestedReviewers = Array.isArray(prData.requested_reviewers) ? prData.requested_reviewers : [];
|
||||
const requestedTeams = Array.isArray(prData.requested_teams) ? prData.requested_teams : [];
|
||||
return [
|
||||
...requestedReviewers.map(parseUserReviewer).filter((value): value is ReviewerItem => value !== null),
|
||||
...requestedTeams.map((team) => parseTeamReviewer(team, owner)).filter((value): value is ReviewerItem => value !== null),
|
||||
];
|
||||
}
|
||||
|
||||
function parsePRState(value: unknown): PRState {
|
||||
return value === "closed" ? "closed" : "open";
|
||||
}
|
||||
|
||||
function buildInitialState(prData: Record<string, unknown>, owner: string): InitialPRState {
|
||||
const base = asRecord(prData.base);
|
||||
const requestedReviewers = extractRequestedReviewers(prData, owner);
|
||||
return {
|
||||
title: asString(prData.title) || "",
|
||||
body: asString(prData.body) || "",
|
||||
state: parsePRState(prData.state),
|
||||
base: asString(base?.ref) || "",
|
||||
draft: asBoolean(prData.draft) || false,
|
||||
maintainerCanModify: asBoolean(prData.maintainer_can_modify) || false,
|
||||
reviewers: requestedReviewers.map(reviewerValue),
|
||||
};
|
||||
}
|
||||
|
||||
function SuccessView({
|
||||
pr,
|
||||
owner,
|
||||
repo,
|
||||
submittedTitle,
|
||||
openLink,
|
||||
}: {
|
||||
pr: PRResult;
|
||||
owner: string;
|
||||
repo: string;
|
||||
submittedTitle: string;
|
||||
openLink: (url: string) => Promise<void>;
|
||||
}) {
|
||||
const prUrl = pr.html_url || pr.url || pr.URL || "#";
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
borderRadius={2}
|
||||
bg="canvas.subtle"
|
||||
p={3}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
mb={3}
|
||||
pb={3}
|
||||
borderBottomWidth={1}
|
||||
borderBottomStyle="solid"
|
||||
borderBottomColor="border.default"
|
||||
>
|
||||
<Box sx={{ color: "success.fg", flexShrink: 0, mr: 2 }}>
|
||||
<CheckCircleIcon size={16} />
|
||||
</Box>
|
||||
<Text sx={{ fontWeight: "semibold" }}>
|
||||
Pull request updated successfully
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-start"
|
||||
gap={2}
|
||||
p={3}
|
||||
bg="canvas.subtle"
|
||||
borderRadius={2}
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
>
|
||||
<Box sx={{ color: "open.fg", flexShrink: 0, mt: "2px", mr: 1 }}>
|
||||
<GitPullRequestIcon size={16} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<a
|
||||
href={prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (prUrl === "#") return;
|
||||
void openLink(prUrl);
|
||||
}}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "14px",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "var(--fgColor-accent, var(--color-accent-fg))",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{pr.title || submittedTitle}
|
||||
{pr.number && (
|
||||
<Text sx={{ color: "fg.muted", fontWeight: "normal", ml: 1 }}>
|
||||
#{pr.number}
|
||||
</Text>
|
||||
)}
|
||||
</a>
|
||||
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
|
||||
{owner}/{repo}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPRApp() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [prState, setPRState] = useState<PRState>("open");
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
const [maintainerCanModify, setMaintainerCanModify] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successPR, setSuccessPR] = useState<PRResult | null>(null);
|
||||
const [initialValues, setInitialValues] = useState<InitialPRState | null>(null);
|
||||
const [isLoadingPR, setIsLoadingPR] = useState(false);
|
||||
const [submittedTitle, setSubmittedTitle] = useState("");
|
||||
|
||||
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
|
||||
const [baseBranch, setBaseBranch] = useState<string>("");
|
||||
const [branchesLoading, setBranchesLoading] = useState(false);
|
||||
const [baseFilter, setBaseFilter] = useState("");
|
||||
|
||||
const [availableReviewers, setAvailableReviewers] = useState<ReviewerItem[]>([]);
|
||||
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
|
||||
const [reviewersLoading, setReviewersLoading] = useState(false);
|
||||
const [reviewersFilter, setReviewersFilter] = useState("");
|
||||
|
||||
const { app, error: appError, toolInput, toolResult, callTool, hostContext, setModelContext, openLink } = useMcpApp({
|
||||
appName: "github-mcp-server-edit-pull-request",
|
||||
});
|
||||
|
||||
const owner = (toolInput?.owner as string) || "";
|
||||
const repo = (toolInput?.repo as string) || "";
|
||||
const pullNumber = asNumber(toolInput?.pullNumber);
|
||||
|
||||
// When the server updated the PR up-front instead of deferring to this form,
|
||||
// the host still renders this View and delivers the updated PR via
|
||||
// tool-result. Render that completed result as success so we never show an
|
||||
// edit form for changes already applied. The deferral sentinel
|
||||
// (awaiting_user_submission) returns null here, keeping the form for the
|
||||
// normal deferred flow. See github/copilot-mcp-core#1864.
|
||||
const resultPR = useMemo(() => completedToolResult<PRResult>(toolResult), [toolResult]);
|
||||
const shownPR = successPR ?? resultPR;
|
||||
|
||||
useEffect(() => {
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setPRState("open");
|
||||
setIsDraft(false);
|
||||
setMaintainerCanModify(false);
|
||||
setBaseBranch("");
|
||||
setSelectedReviewers([]);
|
||||
setAvailableBranches([]);
|
||||
setAvailableReviewers([]);
|
||||
setBaseFilter("");
|
||||
setReviewersFilter("");
|
||||
setInitialValues(null);
|
||||
setSuccessPR(null);
|
||||
setError(null);
|
||||
setSubmittedTitle("");
|
||||
}, [toolInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!app || !owner || !repo || !pullNumber) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadPullRequest = async () => {
|
||||
setIsLoadingPR(true);
|
||||
try {
|
||||
const result = await callTool("pull_request_read", { method: "get", owner, repo, pullNumber });
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.isError) {
|
||||
const textContent = result.content?.find((c) => c.type === "text");
|
||||
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to load pull request";
|
||||
setError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const textContent = result.content?.find((c) => c.type === "text");
|
||||
if (!textContent || textContent.type !== "text" || !textContent.text) {
|
||||
setError("Pull request details were not returned");
|
||||
return;
|
||||
}
|
||||
|
||||
const prData = JSON.parse(textContent.text) as Record<string, unknown>;
|
||||
const initialState = buildInitialState(prData, owner);
|
||||
const toolInputReviewers = reviewersFromValues(toolInput?.reviewers);
|
||||
|
||||
setInitialValues(initialState);
|
||||
setTitle(asString(toolInput?.title) ?? initialState.title);
|
||||
setBody(asString(toolInput?.body) ?? initialState.body);
|
||||
setPRState(parsePRState(asString(toolInput?.state) ?? initialState.state));
|
||||
setIsDraft(asBoolean(toolInput?.draft) ?? initialState.draft);
|
||||
setBaseBranch(asString(toolInput?.base) ?? initialState.base);
|
||||
setMaintainerCanModify(asBoolean(toolInput?.maintainer_can_modify) ?? initialState.maintainerCanModify);
|
||||
setSelectedReviewers(toolInputReviewers ?? extractRequestedReviewers(prData, owner));
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load pull request");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoadingPR(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPullRequest();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [app, callTool, owner, repo, pullNumber, toolInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!owner || !repo || !app) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadBranches = async () => {
|
||||
setBranchesLoading(true);
|
||||
try {
|
||||
const result = await callTool("ui_get", { method: "branches", owner, repo });
|
||||
if (cancelled) return;
|
||||
if (result && !result.isError && result.content) {
|
||||
const textContent = result.content.find((c: { type: string }) => c.type === "text");
|
||||
if (textContent && "text" in textContent) {
|
||||
const data = JSON.parse(textContent.text as string);
|
||||
const branches = (data.branches || data || []).map(
|
||||
(b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false })
|
||||
);
|
||||
setAvailableBranches(branches);
|
||||
const defaultBranch = branches.find((b: BranchItem) => b.name === "main" || b.name === "master");
|
||||
if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load branches:", e);
|
||||
} finally {
|
||||
if (!cancelled) setBranchesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadReviewers = async () => {
|
||||
setReviewersLoading(true);
|
||||
try {
|
||||
const result = await callTool("ui_get", { method: "reviewers", owner, repo });
|
||||
if (cancelled) return;
|
||||
if (result && !result.isError && result.content) {
|
||||
const textContent = result.content.find((c: { type: string }) => c.type === "text");
|
||||
if (textContent && "text" in textContent) {
|
||||
const data = JSON.parse(textContent.text as string);
|
||||
const users = (data.users || []).map(
|
||||
(u: { login: string; avatar_url?: string }) => ({
|
||||
kind: "user" as const,
|
||||
id: u.login,
|
||||
text: u.login,
|
||||
avatar: u.avatar_url,
|
||||
})
|
||||
);
|
||||
const teams = (data.teams || []).map(
|
||||
(t: { slug: string; name?: string; org: string }) => ({
|
||||
kind: "team" as const,
|
||||
id: `${t.org}/${t.slug}`,
|
||||
text: `${t.org}/${t.slug}`,
|
||||
org: t.org,
|
||||
})
|
||||
);
|
||||
setAvailableReviewers([...users, ...teams]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load reviewers:", e);
|
||||
} finally {
|
||||
if (!cancelled) setReviewersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBranches();
|
||||
loadReviewers();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [owner, repo, app, callTool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (availableReviewers.length === 0) return;
|
||||
setSelectedReviewers((prev) =>
|
||||
prev.map((reviewer) =>
|
||||
availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer
|
||||
)
|
||||
);
|
||||
}, [availableReviewers]);
|
||||
|
||||
const filteredBaseBranches = useMemo(() => {
|
||||
if (!baseFilter.trim()) return availableBranches;
|
||||
return availableBranches.filter((branch) => branch.name.toLowerCase().includes(baseFilter.toLowerCase()));
|
||||
}, [availableBranches, baseFilter]);
|
||||
|
||||
const filteredReviewers = useMemo(() => {
|
||||
if (!reviewersFilter.trim()) return availableReviewers;
|
||||
const lowerFilter = reviewersFilter.toLowerCase();
|
||||
return availableReviewers.filter((reviewer) =>
|
||||
reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter)
|
||||
);
|
||||
}, [availableReviewers, reviewersFilter]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!title.trim()) { setError("Title is required"); return; }
|
||||
if (!owner || !repo || !pullNumber) { setError("Pull request information not available"); return; }
|
||||
if (!baseBranch) { setError("Base branch is required"); return; }
|
||||
if (!initialValues) { setError("Pull request details are still loading"); return; }
|
||||
|
||||
const selectedReviewerValues = selectedReviewers.map(reviewerValue);
|
||||
const params: Record<string, unknown> = { owner, repo, pullNumber, _ui_submitted: true };
|
||||
|
||||
if (title.trim() !== initialValues.title) params.title = title.trim();
|
||||
if (body !== initialValues.body) params.body = body;
|
||||
if (prState !== initialValues.state) params.state = prState;
|
||||
if (baseBranch !== initialValues.base) params.base = baseBranch;
|
||||
if (isDraft !== initialValues.draft) params.draft = isDraft;
|
||||
if (maintainerCanModify !== initialValues.maintainerCanModify) params.maintainer_can_modify = maintainerCanModify;
|
||||
if (!sameReviewerValues(selectedReviewerValues, initialValues.reviewers)) params.reviewers = selectedReviewerValues;
|
||||
|
||||
const hasChanges = Object.keys(params).some((key) => !["owner", "repo", "pullNumber", "_ui_submitted"].includes(key));
|
||||
if (!hasChanges) {
|
||||
setError("No changes to update");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setSubmittedTitle(title);
|
||||
|
||||
try {
|
||||
const result = await callTool("update_pull_request", params);
|
||||
|
||||
if (result.isError) {
|
||||
const textContent = result.content?.find((c) => c.type === "text");
|
||||
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to update pull request";
|
||||
setError(errorMessage);
|
||||
} else {
|
||||
const textContent = result.content?.find((c) => c.type === "text");
|
||||
if (textContent && textContent.type === "text" && textContent.text) {
|
||||
const prData = JSON.parse(textContent.text);
|
||||
setSuccessPR(prData);
|
||||
void setModelContext({
|
||||
structuredContent: prData,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Pull request #${pullNumber} in ${owner}/${repo} was updated by the user via the edit-pull-request view.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, callTool, setModelContext]);
|
||||
|
||||
if (shownPR) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app && !appError) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
|
||||
<Spinner size="medium" />
|
||||
</Box>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (appError) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Flash variant="danger">{appError.message}</Flash>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolInput === null) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
|
||||
<Spinner size="medium" />
|
||||
</Box>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!owner || !repo || !pullNumber) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Flash variant="danger">Pull request owner, repo, and pullNumber are required.</Flash>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Box
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
borderRadius={2}
|
||||
bg="canvas.subtle"
|
||||
p={3}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
mb={3}
|
||||
pb={2}
|
||||
borderBottomWidth={1}
|
||||
borderBottomStyle="solid"
|
||||
borderBottomColor="border.default"
|
||||
sx={{ minWidth: 0, overflow: "hidden" }}
|
||||
>
|
||||
<Box sx={{ color: "open.fg", flexShrink: 0 }}>
|
||||
<GitPullRequestIcon size={16} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Text sx={{ fontWeight: "semibold" }}>#{pullNumber} · {owner}/{repo}</Text>
|
||||
{title && (
|
||||
<Text as="div" sx={{ color: "fg.muted", fontSize: 0, mt: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>}
|
||||
|
||||
{isLoadingPR && !initialValues ? (
|
||||
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
|
||||
<Spinner size="medium" />
|
||||
<Text sx={{ color: "fg.muted", ml: 2 }}>Loading pull request...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<FormControl sx={{ mb: 3 }}>
|
||||
<FormControl.Label sx={{ fontWeight: "semibold" }}>Title</FormControl.Label>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
block
|
||||
contrast
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Text as="label" sx={{ fontWeight: "semibold", fontSize: 1, display: "block", mb: 2 }}>
|
||||
Description
|
||||
</Text>
|
||||
<MarkdownEditor value={body} onChange={setBody} placeholder="Add a description..." />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3, flex: "1 1 240px", minWidth: 0 }}>
|
||||
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
|
||||
{baseBranch || "Select base"}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay width="medium">
|
||||
<ActionList selectionVariant="single">
|
||||
<Box p={2}>
|
||||
<TextInput
|
||||
placeholder="Filter branches..."
|
||||
value={baseFilter}
|
||||
onChange={(e) => setBaseFilter(e.target.value)}
|
||||
size="small"
|
||||
block
|
||||
/>
|
||||
</Box>
|
||||
<ActionList.Divider />
|
||||
{branchesLoading ? (
|
||||
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
|
||||
) : filteredBaseBranches.length === 0 ? (
|
||||
<ActionList.Item disabled>No branches found</ActionList.Item>
|
||||
) : (
|
||||
filteredBaseBranches.map((branch) => (
|
||||
<ActionList.Item
|
||||
key={branch.name}
|
||||
selected={baseBranch === branch.name}
|
||||
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
|
||||
>
|
||||
{branch.name}
|
||||
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
|
||||
</ActionList.Item>
|
||||
))
|
||||
)}
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3} mb={3}>
|
||||
<Box>
|
||||
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>state</Text>
|
||||
<ButtonGroup>
|
||||
<Button size="small" variant={prState === "open" ? "primary" : "default"} onClick={() => setPRState("open")}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="small" variant={prState === "closed" ? "primary" : "default"} onClick={() => setPRState("closed")}>
|
||||
Closed
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mb: "6px" }}>
|
||||
<Checkbox checked={isDraft} onChange={(e) => setIsDraft(e.target.checked)} />
|
||||
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Mark as draft</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
|
||||
{selectedReviewers.length === 0 ? (
|
||||
"No reviewers"
|
||||
) : (
|
||||
<>
|
||||
Reviewers
|
||||
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
|
||||
</>
|
||||
)}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay width="medium">
|
||||
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
|
||||
<TextInput
|
||||
placeholder="Search reviewers"
|
||||
value={reviewersFilter}
|
||||
onChange={(e) => setReviewersFilter(e.target.value)}
|
||||
size="small"
|
||||
block
|
||||
/>
|
||||
</Box>
|
||||
<ActionList selectionVariant="multiple">
|
||||
{reviewersLoading ? (
|
||||
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
|
||||
) : filteredReviewers.length === 0 ? (
|
||||
<ActionList.Item disabled>No reviewers available</ActionList.Item>
|
||||
) : (
|
||||
filteredReviewers.map((reviewer) => (
|
||||
<ActionList.Item
|
||||
key={reviewer.id}
|
||||
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
|
||||
onSelect={() => {
|
||||
setSelectedReviewers((prev) =>
|
||||
prev.some((r) => r.id === reviewer.id)
|
||||
? prev.filter((r) => r.id !== reviewer.id)
|
||||
: [...prev, reviewer]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ActionList.LeadingVisual>
|
||||
{reviewer.kind === "user" ? (
|
||||
reviewer.avatar ? (
|
||||
<img
|
||||
src={reviewer.avatar}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ borderRadius: "50%", display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon />
|
||||
)
|
||||
) : (
|
||||
<PeopleIcon />
|
||||
)}
|
||||
</ActionList.LeadingVisual>
|
||||
{reviewer.text}
|
||||
</ActionList.Item>
|
||||
))
|
||||
)}
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
{selectedReviewers.length > 0 && (
|
||||
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
|
||||
{selectedReviewers.map((reviewer) => (
|
||||
<Label
|
||||
key={reviewer.id}
|
||||
sx={{
|
||||
backgroundColor: "canvas.inset",
|
||||
color: "fg.muted",
|
||||
borderColor: "border.default",
|
||||
}}
|
||||
>
|
||||
{reviewer.text}
|
||||
</Label>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
|
||||
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
|
||||
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />
|
||||
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !initialValues || !title.trim() || !baseBranch}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<><Spinner size="small" sx={{ mr: 1 }} />Updating...</>
|
||||
) : (
|
||||
"Update pull request"
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<EditPRApp />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Edit pull request</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./App.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,823 @@
|
||||
import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Flash,
|
||||
Spinner,
|
||||
FormControl,
|
||||
ActionMenu,
|
||||
ActionList,
|
||||
Checkbox,
|
||||
ButtonGroup,
|
||||
CounterLabel,
|
||||
Label,
|
||||
} from "@primer/react";
|
||||
import {
|
||||
GitPullRequestIcon,
|
||||
CheckCircleIcon,
|
||||
RepoIcon,
|
||||
LockIcon,
|
||||
GitBranchIcon,
|
||||
TriangleDownIcon,
|
||||
PersonIcon,
|
||||
PeopleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { AppProvider } from "../../components/AppProvider";
|
||||
import { useMcpApp } from "../../hooks/useMcpApp";
|
||||
import { completedToolResult } from "../../lib/toolResult";
|
||||
import { MarkdownEditor } from "../../components/MarkdownEditor";
|
||||
|
||||
interface PRResult {
|
||||
ID?: string;
|
||||
number?: number;
|
||||
title?: string;
|
||||
url?: string;
|
||||
html_url?: string;
|
||||
URL?: string;
|
||||
}
|
||||
|
||||
interface RepositoryItem {
|
||||
id: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
interface BranchItem {
|
||||
name: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string };
|
||||
|
||||
function reviewerFromValue(value: string): ReviewerItem {
|
||||
if (value.includes("/")) {
|
||||
const [org, slug] = value.split("/", 2);
|
||||
return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org };
|
||||
}
|
||||
return { kind: "user", id: value, text: value };
|
||||
}
|
||||
|
||||
function reviewerValue(reviewer: ReviewerItem): string {
|
||||
return reviewer.kind === "team" ? reviewer.id : reviewer.text;
|
||||
}
|
||||
|
||||
function SuccessView({
|
||||
pr,
|
||||
owner,
|
||||
repo,
|
||||
submittedTitle,
|
||||
openLink,
|
||||
}: {
|
||||
pr: PRResult;
|
||||
owner: string;
|
||||
repo: string;
|
||||
submittedTitle: string;
|
||||
openLink: (url: string) => Promise<void>;
|
||||
}) {
|
||||
const prUrl = pr.html_url || pr.url || pr.URL || "#";
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
borderRadius={2}
|
||||
bg="canvas.subtle"
|
||||
p={3}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
mb={3}
|
||||
pb={3}
|
||||
borderBottomWidth={1}
|
||||
borderBottomStyle="solid"
|
||||
borderBottomColor="border.default"
|
||||
>
|
||||
<Box sx={{ color: "success.fg", flexShrink: 0, mr: 2 }}>
|
||||
<CheckCircleIcon size={16} />
|
||||
</Box>
|
||||
<Text sx={{ fontWeight: "semibold" }}>
|
||||
Pull request created successfully
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-start"
|
||||
gap={2}
|
||||
p={3}
|
||||
bg="canvas.subtle"
|
||||
borderRadius={2}
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
>
|
||||
<Box sx={{ color: "open.fg", flexShrink: 0, mt: "2px", mr: 1 }}>
|
||||
<GitPullRequestIcon size={16} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<a
|
||||
href={prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
// MCP Apps run in a sandboxed iframe where a plain anchor may be
|
||||
// blocked, so route the click through the host's open-link
|
||||
// capability (falls back to window.open).
|
||||
e.preventDefault();
|
||||
if (prUrl === "#") return;
|
||||
void openLink(prUrl);
|
||||
}}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "14px",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "var(--fgColor-accent, var(--color-accent-fg))",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{pr.title || submittedTitle}
|
||||
{pr.number && (
|
||||
<Text sx={{ color: "fg.muted", fontWeight: "normal", ml: 1 }}>
|
||||
#{pr.number}
|
||||
</Text>
|
||||
)}
|
||||
</a>
|
||||
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
|
||||
{owner}/{repo}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePRApp() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successPR, setSuccessPR] = useState<PRResult | null>(null);
|
||||
|
||||
// Branch state
|
||||
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
|
||||
const [baseBranch, setBaseBranch] = useState<string>("");
|
||||
const [headBranch, setHeadBranch] = useState<string>("");
|
||||
const [branchesLoading, setBranchesLoading] = useState(false);
|
||||
const [baseFilter, setBaseFilter] = useState("");
|
||||
const [headFilter, setHeadFilter] = useState("");
|
||||
|
||||
// Options
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
const [maintainerCanModify, setMaintainerCanModify] = useState(true);
|
||||
const [availableReviewers, setAvailableReviewers] = useState<ReviewerItem[]>([]);
|
||||
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
|
||||
const [reviewersLoading, setReviewersLoading] = useState(false);
|
||||
const [reviewersFilter, setReviewersFilter] = useState("");
|
||||
|
||||
// Repository state
|
||||
const [selectedRepo, setSelectedRepo] = useState<RepositoryItem | null>(null);
|
||||
const [repoSearchResults, setRepoSearchResults] = useState<RepositoryItem[]>([]);
|
||||
const [repoSearchLoading, setRepoSearchLoading] = useState(false);
|
||||
const [repoFilter, setRepoFilter] = useState("");
|
||||
|
||||
const { app, error: appError, toolInput, toolResult, callTool, hostContext, setModelContext, openLink } = useMcpApp({
|
||||
appName: "github-mcp-server-create-pull-request",
|
||||
});
|
||||
|
||||
const owner = selectedRepo?.owner || (toolInput?.owner as string) || "";
|
||||
const repo = selectedRepo?.name || (toolInput?.repo as string) || "";
|
||||
const [submittedTitle, setSubmittedTitle] = useState("");
|
||||
|
||||
// When the server executed up-front instead of deferring to this form (e.g.
|
||||
// the agent passed show_ui=false or parameters the form can't represent), the
|
||||
// host still renders this View and delivers the created PR via tool-result.
|
||||
// Treat that completed result as a success so we never show a "Create pull
|
||||
// request" form for a PR that already exists. The deferral sentinel
|
||||
// (awaiting_user_submission) returns null here, keeping the form for the
|
||||
// normal deferred flow. See github/copilot-mcp-core#1864.
|
||||
const resultPR = useMemo(() => completedToolResult<PRResult>(toolResult), [toolResult]);
|
||||
const shownPR = successPR ?? resultPR;
|
||||
|
||||
// Reset all transient form/result state when toolInput changes (new invocation).
|
||||
// Without this, the SuccessView from a previous submit stays visible and stale
|
||||
// form values bleed through because the prefill effect below only sets when
|
||||
// toolInput has truthy values and never clears. The repo is re-initialized from
|
||||
// the new invocation here (rather than in a separate effect) so it isn't wiped
|
||||
// by this reset.
|
||||
useEffect(() => {
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setHeadBranch("");
|
||||
setBaseBranch("");
|
||||
setIsDraft(false);
|
||||
setMaintainerCanModify(true);
|
||||
setSuccessPR(null);
|
||||
setError(null);
|
||||
setSubmittedTitle("");
|
||||
// Clear branch list and filters so a new invocation doesn't briefly show stale
|
||||
// branches from the previous repo (or allow selecting invalid options) before the
|
||||
// new repo's ui_get branches call resolves.
|
||||
setAvailableBranches([]);
|
||||
setBaseFilter("");
|
||||
setHeadFilter("");
|
||||
setAvailableReviewers([]);
|
||||
setSelectedReviewers([]);
|
||||
setReviewersFilter("");
|
||||
if (toolInput?.owner && toolInput?.repo) {
|
||||
setSelectedRepo({
|
||||
id: `${toolInput.owner}/${toolInput.repo}`,
|
||||
owner: toolInput.owner as string,
|
||||
name: toolInput.repo as string,
|
||||
fullName: `${toolInput.owner}/${toolInput.repo}`,
|
||||
isPrivate: false,
|
||||
});
|
||||
} else {
|
||||
setSelectedRepo(null);
|
||||
}
|
||||
}, [toolInput]);
|
||||
|
||||
// Pre-fill from toolInput
|
||||
useEffect(() => {
|
||||
if (toolInput?.title) setTitle(toolInput.title as string);
|
||||
if (toolInput?.body) setBody(toolInput.body as string);
|
||||
if (toolInput?.head) setHeadBranch(toolInput.head as string);
|
||||
if (toolInput?.base) setBaseBranch(toolInput.base as string);
|
||||
if (toolInput?.draft) setIsDraft(toolInput.draft as boolean);
|
||||
if (toolInput?.maintainer_can_modify !== undefined) {
|
||||
setMaintainerCanModify(toolInput.maintainer_can_modify as boolean);
|
||||
}
|
||||
if (Array.isArray(toolInput?.reviewers)) {
|
||||
setSelectedReviewers((toolInput.reviewers as string[]).map(reviewerFromValue));
|
||||
}
|
||||
}, [toolInput]);
|
||||
|
||||
// Search repositories
|
||||
useEffect(() => {
|
||||
if (!app || !repoFilter.trim()) {
|
||||
setRepoSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchRepos = async () => {
|
||||
setRepoSearchLoading(true);
|
||||
try {
|
||||
const result = await callTool("search_repositories", { query: repoFilter, perPage: 10 });
|
||||
if (result && !result.isError && result.content) {
|
||||
const textContent = result.content.find((c) => c.type === "text");
|
||||
if (textContent && textContent.type === "text" && textContent.text) {
|
||||
const data = JSON.parse(textContent.text);
|
||||
const repos = (data.repositories || data.items || []).map(
|
||||
(r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({
|
||||
id: String(r.id || r.full_name),
|
||||
owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || r.full_name?.split('/')[0] || '',
|
||||
name: r.name || '',
|
||||
fullName: r.full_name || '',
|
||||
isPrivate: r.private || false,
|
||||
})
|
||||
);
|
||||
setRepoSearchResults(repos);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to search repositories:", e);
|
||||
} finally {
|
||||
setRepoSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(searchRepos, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [app, callTool, repoFilter]);
|
||||
|
||||
// Load branches and reviewers when repo is selected
|
||||
useEffect(() => {
|
||||
if (!owner || !repo || !app) return;
|
||||
|
||||
const loadBranches = async () => {
|
||||
setBranchesLoading(true);
|
||||
try {
|
||||
const result = await callTool("ui_get", { method: "branches", owner, repo });
|
||||
if (result && !result.isError && result.content) {
|
||||
const textContent = result.content.find((c: { type: string }) => c.type === "text");
|
||||
if (textContent && "text" in textContent) {
|
||||
const data = JSON.parse(textContent.text as string);
|
||||
const branches = (data.branches || data || []).map(
|
||||
(b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false })
|
||||
);
|
||||
setAvailableBranches(branches);
|
||||
if (branches.length > 0) {
|
||||
const defaultBranch = branches.find((b: BranchItem) => b.name === 'main' || b.name === 'master');
|
||||
// Functional update so a base branch already prefilled from
|
||||
// toolInput.base (or chosen by the user) isn't overwritten by a
|
||||
// stale closure value captured before the request resolved.
|
||||
if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load branches:", e);
|
||||
} finally {
|
||||
setBranchesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadReviewers = async () => {
|
||||
setReviewersLoading(true);
|
||||
try {
|
||||
const result = await callTool("ui_get", { method: "reviewers", owner, repo });
|
||||
if (result && !result.isError && result.content) {
|
||||
const textContent = result.content.find((c: { type: string }) => c.type === "text");
|
||||
if (textContent && "text" in textContent) {
|
||||
const data = JSON.parse(textContent.text as string);
|
||||
const users = (data.users || []).map(
|
||||
(u: { login: string; avatar_url?: string }) => ({
|
||||
kind: "user" as const,
|
||||
id: u.login,
|
||||
text: u.login,
|
||||
avatar: u.avatar_url,
|
||||
})
|
||||
);
|
||||
const teams = (data.teams || []).map(
|
||||
(t: { slug: string; name?: string; org: string }) => ({
|
||||
kind: "team" as const,
|
||||
id: `${t.org}/${t.slug}`,
|
||||
text: `${t.org}/${t.slug}`,
|
||||
org: t.org,
|
||||
})
|
||||
);
|
||||
setAvailableReviewers([...users, ...teams]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load reviewers:", e);
|
||||
} finally {
|
||||
setReviewersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBranches();
|
||||
loadReviewers();
|
||||
}, [owner, repo, app, callTool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (availableReviewers.length === 0) return;
|
||||
setSelectedReviewers((prev) =>
|
||||
prev.map((reviewer) =>
|
||||
availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer
|
||||
)
|
||||
);
|
||||
}, [availableReviewers]);
|
||||
|
||||
// Filters
|
||||
const filteredBaseBranches = useMemo(() => {
|
||||
if (!baseFilter.trim()) return availableBranches;
|
||||
return availableBranches.filter((b) => b.name.toLowerCase().includes(baseFilter.toLowerCase()));
|
||||
}, [availableBranches, baseFilter]);
|
||||
|
||||
const filteredHeadBranches = useMemo(() => {
|
||||
if (!headFilter.trim()) return availableBranches;
|
||||
return availableBranches.filter((b) => b.name.toLowerCase().includes(headFilter.toLowerCase()));
|
||||
}, [availableBranches, headFilter]);
|
||||
|
||||
const filteredReviewers = useMemo(() => {
|
||||
if (!reviewersFilter.trim()) return availableReviewers;
|
||||
const lowerFilter = reviewersFilter.toLowerCase();
|
||||
return availableReviewers.filter((reviewer) =>
|
||||
reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter)
|
||||
);
|
||||
}, [availableReviewers, reviewersFilter]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!title.trim()) { setError("Title is required"); return; }
|
||||
if (!owner || !repo) { setError("Repository information not available"); return; }
|
||||
if (!baseBranch) { setError("Base branch is required"); return; }
|
||||
if (!headBranch) { setError("Head branch is required"); return; }
|
||||
if (baseBranch === headBranch) { setError("Base and head branches cannot be the same"); return; }
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setSubmittedTitle(title);
|
||||
|
||||
try {
|
||||
const result = await callTool("create_pull_request", {
|
||||
...(toolInput as Record<string, unknown> | undefined),
|
||||
owner, repo,
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
head: headBranch,
|
||||
base: baseBranch,
|
||||
draft: isDraft,
|
||||
maintainer_can_modify: maintainerCanModify,
|
||||
reviewers: selectedReviewers.map(reviewerValue),
|
||||
_ui_submitted: true
|
||||
});
|
||||
|
||||
if (result.isError) {
|
||||
const errorText = result.content?.find((c) => c.type === "text");
|
||||
const errorMessage = errorText && errorText.type === "text" ? errorText.text : "Failed to create pull request";
|
||||
setError(errorMessage);
|
||||
} else {
|
||||
const textContent = result.content?.find((c) => c.type === "text");
|
||||
if (textContent && textContent.type === "text" && textContent.text) {
|
||||
const prData = JSON.parse(textContent.text);
|
||||
setSuccessPR(prData);
|
||||
// Push the new PR into the model context so subsequent agent
|
||||
// turns can reference it (MCP Apps 2026-01-26 ui/update-model-context).
|
||||
void setModelContext({
|
||||
structuredContent: prData,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `A new pull request was created in ${owner}/${repo} by the user via the create-pull-request view.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]);
|
||||
|
||||
if (shownPR) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app && !appError) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
|
||||
<Spinner size="medium" />
|
||||
</Box>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (appError) {
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Flash variant="danger">{appError.message}</Flash>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppProvider hostContext={hostContext}>
|
||||
<Box
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
borderRadius={2}
|
||||
bg="canvas.subtle"
|
||||
p={3}
|
||||
>
|
||||
{/* Repository picker */}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
mb={3}
|
||||
pb={2}
|
||||
borderBottomWidth={1}
|
||||
borderBottomStyle="solid"
|
||||
borderBottomColor="border.default"
|
||||
sx={{ minWidth: 0, overflow: "hidden" }}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, maxWidth: "100%" }}>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Button
|
||||
size="small"
|
||||
leadingVisual={selectedRepo?.isPrivate ? LockIcon : RepoIcon}
|
||||
sx={{ maxWidth: "100%", overflow: "hidden", "& > span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}
|
||||
>
|
||||
{selectedRepo ? selectedRepo.fullName : "Select repository"}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay width="medium">
|
||||
<ActionList selectionVariant="single">
|
||||
<Box px={3} py={2}>
|
||||
<TextInput
|
||||
placeholder="Search repositories..."
|
||||
value={repoFilter}
|
||||
onChange={(e) => setRepoFilter(e.target.value)}
|
||||
sx={{ width: "100%" }}
|
||||
size="small"
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
<ActionList.Divider />
|
||||
{repoSearchLoading ? (
|
||||
<Box display="flex" justifyContent="center" p={3}>
|
||||
<Spinner size="small" />
|
||||
</Box>
|
||||
) : repoSearchResults.length > 0 ? (
|
||||
repoSearchResults.map((r) => (
|
||||
<ActionList.Item
|
||||
key={r.id}
|
||||
selected={selectedRepo?.id === r.id}
|
||||
onSelect={() => {
|
||||
setSelectedRepo(r);
|
||||
setRepoFilter("");
|
||||
setAvailableBranches([]);
|
||||
setBaseBranch("");
|
||||
setHeadBranch("");
|
||||
setAvailableReviewers([]);
|
||||
setSelectedReviewers([]);
|
||||
setReviewersFilter("");
|
||||
}}
|
||||
>
|
||||
<ActionList.LeadingVisual>
|
||||
{r.isPrivate ? <LockIcon /> : <RepoIcon />}
|
||||
</ActionList.LeadingVisual>
|
||||
{r.fullName}
|
||||
</ActionList.Item>
|
||||
))
|
||||
) : selectedRepo ? (
|
||||
<ActionList.Item key={selectedRepo.id} selected onSelect={() => setRepoFilter("")}>
|
||||
<ActionList.LeadingVisual>
|
||||
{selectedRepo.isPrivate ? <LockIcon /> : <RepoIcon />}
|
||||
</ActionList.LeadingVisual>
|
||||
{selectedRepo.fullName}
|
||||
</ActionList.Item>
|
||||
) : (
|
||||
<Box px={3} py={2}>
|
||||
<Text sx={{ color: "fg.muted", fontSize: 1 }}>Type to search repositories...</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Branch selectors */}
|
||||
<Box display="flex" gap={2} mb={3} alignItems="flex-end" sx={{ minWidth: 0, flexWrap: "wrap" }}>
|
||||
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
|
||||
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
|
||||
{baseBranch || "Select base"}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay width="medium">
|
||||
<ActionList selectionVariant="single">
|
||||
<Box p={2}>
|
||||
<TextInput
|
||||
placeholder="Filter branches..."
|
||||
value={baseFilter}
|
||||
onChange={(e) => setBaseFilter(e.target.value)}
|
||||
size="small"
|
||||
block
|
||||
/>
|
||||
</Box>
|
||||
<ActionList.Divider />
|
||||
{branchesLoading ? (
|
||||
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
|
||||
) : filteredBaseBranches.length === 0 ? (
|
||||
<ActionList.Item disabled>No branches found</ActionList.Item>
|
||||
) : (
|
||||
filteredBaseBranches.map((branch) => (
|
||||
<ActionList.Item
|
||||
key={branch.name}
|
||||
selected={baseBranch === branch.name}
|
||||
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
|
||||
>
|
||||
{branch.name}
|
||||
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
|
||||
</ActionList.Item>
|
||||
))
|
||||
)}
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</Box>
|
||||
|
||||
<Text sx={{ color: "fg.muted", pb: 1, px: 1, flexShrink: 0 }}>←</Text>
|
||||
|
||||
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
|
||||
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>compare</Text>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
|
||||
{headBranch || "Select head"}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay width="medium">
|
||||
<ActionList selectionVariant="single">
|
||||
<Box p={2}>
|
||||
<TextInput
|
||||
placeholder="Filter branches..."
|
||||
value={headFilter}
|
||||
onChange={(e) => setHeadFilter(e.target.value)}
|
||||
size="small"
|
||||
block
|
||||
/>
|
||||
</Box>
|
||||
<ActionList.Divider />
|
||||
{branchesLoading ? (
|
||||
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
|
||||
) : filteredHeadBranches.length === 0 ? (
|
||||
<ActionList.Item disabled>No branches found</ActionList.Item>
|
||||
) : (
|
||||
filteredHeadBranches.map((branch) => (
|
||||
<ActionList.Item
|
||||
key={branch.name}
|
||||
selected={headBranch === branch.name}
|
||||
onSelect={() => { setHeadBranch(branch.name); setHeadFilter(""); }}
|
||||
>
|
||||
{branch.name}
|
||||
</ActionList.Item>
|
||||
))
|
||||
)}
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>}
|
||||
|
||||
{/* Title */}
|
||||
<FormControl sx={{ mb: 3 }}>
|
||||
<FormControl.Label sx={{ fontWeight: "semibold" }}>Title</FormControl.Label>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
block
|
||||
contrast
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Description */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Text as="label" sx={{ fontWeight: "semibold", fontSize: 1, display: "block", mb: 2 }}>
|
||||
Description
|
||||
</Text>
|
||||
<MarkdownEditor value={body} onChange={setBody} placeholder="Add a description..." />
|
||||
</Box>
|
||||
|
||||
{/* Reviewers */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
|
||||
{selectedReviewers.length === 0 ? (
|
||||
"No reviewers"
|
||||
) : (
|
||||
<>
|
||||
Reviewers
|
||||
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
|
||||
</>
|
||||
)}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay width="medium">
|
||||
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
|
||||
<TextInput
|
||||
placeholder="Search reviewers"
|
||||
value={reviewersFilter}
|
||||
onChange={(e) => setReviewersFilter(e.target.value)}
|
||||
size="small"
|
||||
block
|
||||
/>
|
||||
</Box>
|
||||
<ActionList selectionVariant="multiple">
|
||||
{reviewersLoading ? (
|
||||
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
|
||||
) : filteredReviewers.length === 0 ? (
|
||||
<ActionList.Item disabled>No reviewers available</ActionList.Item>
|
||||
) : (
|
||||
filteredReviewers.map((reviewer) => (
|
||||
<ActionList.Item
|
||||
key={reviewer.id}
|
||||
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
|
||||
onSelect={() => {
|
||||
setSelectedReviewers((prev) =>
|
||||
prev.some((r) => r.id === reviewer.id)
|
||||
? prev.filter((r) => r.id !== reviewer.id)
|
||||
: [...prev, reviewer]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ActionList.LeadingVisual>
|
||||
{reviewer.kind === "user" ? (
|
||||
reviewer.avatar ? (
|
||||
<img
|
||||
src={reviewer.avatar}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ borderRadius: "50%", display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon />
|
||||
)
|
||||
) : (
|
||||
<PeopleIcon />
|
||||
)}
|
||||
</ActionList.LeadingVisual>
|
||||
{reviewer.text}
|
||||
</ActionList.Item>
|
||||
))
|
||||
)}
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
{selectedReviewers.length > 0 && (
|
||||
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
|
||||
{selectedReviewers.map((reviewer) => (
|
||||
<Label
|
||||
key={reviewer.id}
|
||||
sx={{
|
||||
backgroundColor: "canvas.inset",
|
||||
color: "fg.muted",
|
||||
borderColor: "border.default",
|
||||
}}
|
||||
>
|
||||
{reviewer.text}
|
||||
</Label>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Options and Submit */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
|
||||
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
|
||||
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />
|
||||
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>
|
||||
</Box>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<><Spinner size="small" sx={{ mr: 1 }} />Creating...</>
|
||||
) : isDraft ? (
|
||||
"Draft pull request"
|
||||
) : (
|
||||
"Create pull request"
|
||||
)}
|
||||
</Button>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Anchor>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
|
||||
sx={{ px: 2 }}
|
||||
aria-label="Select pull request type"
|
||||
>
|
||||
<TriangleDownIcon />
|
||||
</Button>
|
||||
</ActionMenu.Anchor>
|
||||
<ActionMenu.Overlay width="medium">
|
||||
<ActionList selectionVariant="single">
|
||||
<ActionList.Item selected={!isDraft} onSelect={() => setIsDraft(false)}>
|
||||
<ActionList.LeadingVisual>
|
||||
<GitPullRequestIcon />
|
||||
</ActionList.LeadingVisual>
|
||||
Create pull request
|
||||
<ActionList.Description variant="block">
|
||||
Open a pull request that is ready for review
|
||||
</ActionList.Description>
|
||||
</ActionList.Item>
|
||||
<ActionList.Item selected={isDraft} onSelect={() => setIsDraft(true)}>
|
||||
<ActionList.LeadingVisual>
|
||||
<GitPullRequestIcon />
|
||||
</ActionList.LeadingVisual>
|
||||
Create draft pull request
|
||||
<ActionList.Description variant="block">
|
||||
Cannot be merged until marked ready for review
|
||||
</ActionList.Description>
|
||||
</ActionList.Item>
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<CreatePRApp />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create Pull Request</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./App.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ThemeProvider, BaseStyles, Box } from "@primer/react";
|
||||
import type { ReactNode, CSSProperties } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||
import { FeedbackFooter } from "./FeedbackFooter";
|
||||
|
||||
interface AppProviderProps {
|
||||
children: ReactNode;
|
||||
hostContext?: McpUiHostContext;
|
||||
}
|
||||
|
||||
export function AppProvider({ children, hostContext }: AppProviderProps) {
|
||||
const hostTheme = hostContext?.theme;
|
||||
const hostVariables = hostContext?.styles?.variables;
|
||||
|
||||
useEffect(() => {
|
||||
// Prefer the host-supplied theme; fall back to the OS preference.
|
||||
const colorMode =
|
||||
hostTheme === "light" || hostTheme === "dark"
|
||||
? hostTheme
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.body.setAttribute("data-color-mode", colorMode);
|
||||
document.body.setAttribute("data-light-theme", "light");
|
||||
document.body.setAttribute("data-dark-theme", "dark");
|
||||
}, [hostTheme]);
|
||||
|
||||
// Project the host's standardized CSS variables onto the root so child
|
||||
// components can consume them via `var(--color-...)`. We rely on Primer's
|
||||
// own defaults when the host does not supply variables.
|
||||
const styleVars = useMemo<CSSProperties | undefined>(() => {
|
||||
if (!hostVariables) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(hostVariables)) {
|
||||
if (typeof value === "string") out[key] = value;
|
||||
}
|
||||
return out as CSSProperties;
|
||||
}, [hostVariables]);
|
||||
|
||||
const colorMode =
|
||||
hostTheme === "light" || hostTheme === "dark" ? hostTheme : "auto";
|
||||
|
||||
return (
|
||||
<ThemeProvider colorMode={colorMode}>
|
||||
<BaseStyles>
|
||||
<Box p={3} style={styleVars}>
|
||||
{children}
|
||||
<FeedbackFooter />
|
||||
</Box>
|
||||
</BaseStyles>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Box, Text } from "@primer/react";
|
||||
|
||||
export function FeedbackFooter() {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
mt={2}
|
||||
>
|
||||
<Text sx={{ color: "fg.subtle", fontSize: 0, textAlign: "center" }}>
|
||||
Help us improve MCP Apps support in the GitHub MCP Server
|
||||
<br />
|
||||
github.com/github/github-mcp-server/issues/new?template=insiders-feedback.md
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* MarkdownEditor component using GitHub's official @github/markdown-toolbar-element
|
||||
* with Primer React styling. This provides the same markdown editing experience
|
||||
* used on github.com.
|
||||
*
|
||||
* @see https://github.com/github/markdown-toolbar-element
|
||||
*/
|
||||
import { useId, useRef, useState, useEffect } from "react";
|
||||
import { Box, Text, Button, IconButton, useTheme } from "@primer/react";
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
QuoteIcon,
|
||||
CodeIcon,
|
||||
LinkIcon,
|
||||
ListUnorderedIcon,
|
||||
ListOrderedIcon,
|
||||
TasklistIcon,
|
||||
MarkdownIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Import and register the web component
|
||||
import "@github/markdown-toolbar-element";
|
||||
|
||||
// Declare types for the web component elements
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"markdown-toolbar": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & { for: string },
|
||||
HTMLElement
|
||||
>;
|
||||
"md-bold": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
"md-italic": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
"md-quote": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
"md-code": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
"md-link": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
"md-unordered-list": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
"md-ordered-list": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
"md-task-list": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
export function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Add a description...",
|
||||
minHeight = 150,
|
||||
}: MarkdownEditorProps) {
|
||||
const textareaId = useId();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [viewMode, setViewMode] = useState<"write" | "preview">("write");
|
||||
const { colorScheme } = useTheme();
|
||||
const isDark = colorScheme === "dark" || colorScheme === "dark_dimmed";
|
||||
|
||||
// Sync external value changes to textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && textareaRef.current.value !== value) {
|
||||
textareaRef.current.value = value;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Handle Enter key for list continuation
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== "Enter" || e.shiftKey) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const { selectionStart, value: currentValue } = textarea;
|
||||
|
||||
// Get the current line
|
||||
const beforeCursor = currentValue.substring(0, selectionStart);
|
||||
const lastNewline = beforeCursor.lastIndexOf("\n");
|
||||
const currentLine = beforeCursor.substring(lastNewline + 1);
|
||||
|
||||
// Match different list patterns
|
||||
const unorderedMatch = currentLine.match(/^(\s*)([-*])\s/);
|
||||
const orderedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
|
||||
const taskMatch = currentLine.match(/^(\s*)([-*])\s\[[ x]\]\s/);
|
||||
|
||||
let prefix = "";
|
||||
let isEmpty = false;
|
||||
|
||||
if (taskMatch) {
|
||||
const indent = taskMatch[1];
|
||||
const marker = taskMatch[2];
|
||||
// Check if the line only has the list marker with no content
|
||||
isEmpty = currentLine.trim() === `${marker} [ ]` || currentLine.trim() === `${marker} [x]`;
|
||||
prefix = `${indent}${marker} [ ] `;
|
||||
} else if (orderedMatch) {
|
||||
const indent = orderedMatch[1];
|
||||
const num = parseInt(orderedMatch[2], 10);
|
||||
// Check if the line only has the list marker
|
||||
isEmpty = currentLine.trim() === `${num}.`;
|
||||
prefix = `${indent}${num + 1}. `;
|
||||
} else if (unorderedMatch) {
|
||||
const indent = unorderedMatch[1];
|
||||
const marker = unorderedMatch[2];
|
||||
// Check if the line only has the list marker
|
||||
isEmpty = currentLine.trim() === marker;
|
||||
prefix = `${indent}${marker} `;
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isEmpty) {
|
||||
// If just the list marker, remove it and exit list
|
||||
const newValue = currentValue.substring(0, lastNewline + 1) + currentValue.substring(selectionStart);
|
||||
onChange(newValue);
|
||||
// Set cursor position after React updates
|
||||
requestAnimationFrame(() => {
|
||||
if (textarea) {
|
||||
textarea.selectionStart = textarea.selectionEnd = lastNewline + 1;
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Continue the list on the next line
|
||||
const afterCursor = currentValue.substring(selectionStart);
|
||||
const newValue = beforeCursor + "\n" + prefix + afterCursor;
|
||||
onChange(newValue);
|
||||
// Set cursor position after the prefix
|
||||
const newCursorPos = selectionStart + 1 + prefix.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (textarea) {
|
||||
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderColor="border.default"
|
||||
borderRadius={2}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header with tabs and toolbar */}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
px={2}
|
||||
py={1}
|
||||
bg="canvas.subtle"
|
||||
borderBottomWidth={1}
|
||||
borderBottomStyle="solid"
|
||||
borderBottomColor="border.default"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Write/Preview tabs */}
|
||||
<Box display="flex" flexShrink={0} gap={0}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="invisible"
|
||||
onClick={() => setViewMode("write")}
|
||||
sx={{
|
||||
fontWeight: viewMode === "write" ? "semibold" : "normal",
|
||||
color: viewMode === "write" ? "fg.default" : "fg.muted",
|
||||
bg: viewMode === "write" ? "actionListItem.default.hoverBg" : "transparent",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
color: "fg.default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Write
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="invisible"
|
||||
onClick={() => setViewMode("preview")}
|
||||
sx={{
|
||||
fontWeight: viewMode === "preview" ? "semibold" : "normal",
|
||||
color: viewMode === "preview" ? "fg.default" : "fg.muted",
|
||||
bg: viewMode === "preview" ? "actionListItem.default.hoverBg" : "transparent",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
color: "fg.default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Toolbar - uses GitHub's official markdown-toolbar-element */}
|
||||
{viewMode === "write" && (
|
||||
<markdown-toolbar for={textareaId} style={{ display: "flex", overflow: "hidden", minWidth: 0, flexShrink: 1 }}>
|
||||
<Box display="flex" gap={0} alignItems="center" sx={{ overflowX: "auto" }}>
|
||||
<md-bold>
|
||||
<IconButton
|
||||
icon={BoldIcon}
|
||||
aria-label="Add bold text"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-bold>
|
||||
<md-italic>
|
||||
<IconButton
|
||||
icon={ItalicIcon}
|
||||
aria-label="Add italic text"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-italic>
|
||||
<md-quote>
|
||||
<IconButton
|
||||
icon={QuoteIcon}
|
||||
aria-label="Add a quote"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-quote>
|
||||
<md-code>
|
||||
<IconButton
|
||||
icon={CodeIcon}
|
||||
aria-label="Add code"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-code>
|
||||
<md-link>
|
||||
<IconButton
|
||||
icon={LinkIcon}
|
||||
aria-label="Add a link"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-link>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: "1px",
|
||||
height: 16,
|
||||
bg: "border.default",
|
||||
mx: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<md-unordered-list>
|
||||
<IconButton
|
||||
icon={ListUnorderedIcon}
|
||||
aria-label="Add a bulleted list"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-unordered-list>
|
||||
<md-ordered-list>
|
||||
<IconButton
|
||||
icon={ListOrderedIcon}
|
||||
aria-label="Add a numbered list"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-ordered-list>
|
||||
<md-task-list>
|
||||
<IconButton
|
||||
icon={TasklistIcon}
|
||||
aria-label="Add a task list"
|
||||
size="small"
|
||||
variant="invisible"
|
||||
/>
|
||||
</md-task-list>
|
||||
</Box>
|
||||
</markdown-toolbar>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Content area */}
|
||||
{viewMode === "write" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id={textareaId}
|
||||
defaultValue={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight,
|
||||
padding: "12px",
|
||||
border: "none",
|
||||
resize: "vertical",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
|
||||
fontSize: "14px",
|
||||
lineHeight: 1.5,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
backgroundColor: isDark ? "#0d1117" : "#ffffff",
|
||||
color: isDark ? "#e6edf3" : "#1f2328",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
bg="canvas.default"
|
||||
sx={{
|
||||
padding: "12px",
|
||||
minHeight,
|
||||
fontSize: 1,
|
||||
lineHeight: 1.5,
|
||||
color: "fg.default",
|
||||
// Remove top margin from first element so text aligns with write mode
|
||||
"& > :first-child": { mt: 0 },
|
||||
// GitHub Flavored Markdown styles
|
||||
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
fontWeight: "semibold",
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
"& h1": { fontSize: 4, borderBottom: "1px solid", borderColor: "border.default", pb: 2 },
|
||||
"& h2": { fontSize: 3, borderBottom: "1px solid", borderColor: "border.default", pb: 2 },
|
||||
"& h3": { fontSize: 2 },
|
||||
"& p": { my: 2 },
|
||||
"& ul, & ol": { pl: 4, my: 2 },
|
||||
"& li": { my: 1 },
|
||||
"& code": {
|
||||
bg: "neutral.muted",
|
||||
px: 1,
|
||||
py: "2px",
|
||||
borderRadius: 1,
|
||||
fontFamily: "mono",
|
||||
fontSize: "85%",
|
||||
},
|
||||
"& pre": {
|
||||
bg: "neutral.muted",
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
overflow: "auto",
|
||||
my: 2,
|
||||
},
|
||||
"& pre code": {
|
||||
bg: "transparent",
|
||||
p: 0,
|
||||
},
|
||||
"& blockquote": {
|
||||
borderLeft: "4px solid",
|
||||
borderColor: "border.default",
|
||||
pl: 3,
|
||||
ml: 0,
|
||||
mr: 0,
|
||||
my: 2,
|
||||
color: "fg.muted",
|
||||
bg: "canvas.subtle",
|
||||
},
|
||||
"& a": {
|
||||
color: "accent.fg",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
},
|
||||
"& table": {
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
my: 2,
|
||||
},
|
||||
"& th, & td": {
|
||||
border: "1px solid",
|
||||
borderColor: "border.default",
|
||||
p: 2,
|
||||
},
|
||||
"& th": {
|
||||
bg: "canvas.subtle",
|
||||
fontWeight: "semibold",
|
||||
},
|
||||
"& input[type='checkbox']": {
|
||||
mr: 2,
|
||||
},
|
||||
"& hr": {
|
||||
border: "none",
|
||||
borderTop: "1px solid",
|
||||
borderColor: "border.default",
|
||||
my: 3,
|
||||
},
|
||||
}}>
|
||||
|
||||
{value ? (
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{value}</Markdown>
|
||||
) : (
|
||||
<Text sx={{ color: "fg.muted", fontStyle: "italic" }}>
|
||||
Nothing to preview
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
px={2}
|
||||
py={1}
|
||||
bg="canvas.subtle"
|
||||
borderTopWidth={1}
|
||||
borderTopStyle="solid"
|
||||
borderTopColor="border.default"
|
||||
>
|
||||
<MarkdownIcon size={16} />
|
||||
<Text sx={{ fontSize: 0, color: "fg.muted" }}>
|
||||
Markdown is supported
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react";
|
||||
import type {
|
||||
App,
|
||||
McpUiDisplayMode,
|
||||
McpUiHostContext,
|
||||
McpUiUpdateModelContextRequest,
|
||||
} from "@modelcontextprotocol/ext-apps";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface UseMcpAppOptions {
|
||||
appName: string;
|
||||
appVersion?: string;
|
||||
/**
|
||||
* Display modes this view supports. Per the MCP Apps 2026-01-26 spec, a
|
||||
* view MUST declare every display mode it supports during initialization.
|
||||
* Defaults to ["inline"] which is the only mode the bundled github-mcp-server
|
||||
* views currently render.
|
||||
*/
|
||||
availableDisplayModes?: McpUiDisplayMode[];
|
||||
onToolResult?: (result: CallToolResult) => void;
|
||||
onToolInput?: (input: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface UseMcpAppReturn {
|
||||
app: App | null;
|
||||
error: Error | null;
|
||||
toolResult: CallToolResult | null;
|
||||
toolInput: Record<string, unknown> | null;
|
||||
hostContext: McpUiHostContext | undefined;
|
||||
callTool: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
|
||||
/**
|
||||
* Sends `ui/update-model-context` so the agent's next turn sees the
|
||||
* supplied structured content / blocks. No-op when the app isn't connected.
|
||||
*/
|
||||
setModelContext: (
|
||||
params: McpUiUpdateModelContextRequest["params"]
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* Sends `ui/open-link` so the host opens an external URL in the user's
|
||||
* browser. Falls back to `window.open` when the app isn't connected.
|
||||
*/
|
||||
openLink: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useMcpApp({
|
||||
appName,
|
||||
appVersion = "1.0.0",
|
||||
availableDisplayModes = ["inline"],
|
||||
onToolResult,
|
||||
onToolInput,
|
||||
}: UseMcpAppOptions): UseMcpAppReturn {
|
||||
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
|
||||
const [toolInput, setToolInput] = useState<Record<string, unknown> | null>(null);
|
||||
const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>(undefined);
|
||||
|
||||
// The SDK's autoResize=true installs a ResizeObserver that emits
|
||||
// `ui/notifications/size-changed` automatically; no manual wiring needed.
|
||||
const { app, error } = useExtApp({
|
||||
appInfo: { name: appName, version: appVersion },
|
||||
capabilities: { availableDisplayModes },
|
||||
autoResize: true,
|
||||
strict: import.meta.env.DEV,
|
||||
onAppCreated: (app) => {
|
||||
app.ontoolresult = async (result) => {
|
||||
setToolResult(result);
|
||||
onToolResult?.(result);
|
||||
};
|
||||
app.ontoolinput = async (input) => {
|
||||
const args = (input.arguments ?? {}) as Record<string, unknown>;
|
||||
setToolInput(args);
|
||||
// A tool-input notification marks a new invocation, and the spec
|
||||
// guarantees it is delivered before that invocation's tool-result.
|
||||
// Drop any prior result so a completed result from a previous
|
||||
// invocation can't leak into the new render (e.g. a stale success card
|
||||
// showing over a fresh, still-deferred form). The current invocation's
|
||||
// result, if any, arrives next via ontoolresult.
|
||||
setToolResult(null);
|
||||
onToolInput?.(args);
|
||||
};
|
||||
app.onhostcontextchanged = (params) => {
|
||||
setHostContext((prev) => ({ ...(prev ?? {}), ...params }));
|
||||
};
|
||||
app.onerror = console.error;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!app) return;
|
||||
const initial = app.getHostContext();
|
||||
if (initial) setHostContext(initial);
|
||||
}, [app]);
|
||||
|
||||
const callTool = useCallback(
|
||||
async (name: string, args: Record<string, unknown>) => {
|
||||
if (!app) throw new Error("App not connected");
|
||||
return app.callServerTool({ name, arguments: args });
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
const setModelContext = useCallback<UseMcpAppReturn["setModelContext"]>(
|
||||
async (params) => {
|
||||
if (!app) return;
|
||||
await app.updateModelContext(params);
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
const openLink = useCallback<UseMcpAppReturn["openLink"]>(
|
||||
async (url) => {
|
||||
if (!app) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
const result = await app.openLink({ url });
|
||||
// The host may deny the request (e.g. blocked domain or user cancelled).
|
||||
// Fall back to a direct window.open so the link still works.
|
||||
if (result?.isError) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
return {
|
||||
app,
|
||||
error,
|
||||
toolResult,
|
||||
toolInput,
|
||||
hostContext,
|
||||
callTool,
|
||||
setModelContext,
|
||||
openLink,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
/**
|
||||
* Returns the parsed JSON payload of a *completed* tool result, or `null` when
|
||||
* the result is absent, an error, or the deferral sentinel (structured
|
||||
* `status` of `"awaiting_user_submission"`, set by the server's
|
||||
* `NewToolResultAwaitingFormSubmission`).
|
||||
*
|
||||
* Form-backed write Views (create_pull_request, issue_write,
|
||||
* update_pull_request) use this to tell apart "the server deferred and is
|
||||
* waiting for my form submission" from "the server already executed". Without
|
||||
* it, a View renders its input form off in-app state alone and will show e.g. a
|
||||
* "Create pull request" form for a PR that was already created up-front (when
|
||||
* the agent passed `show_ui=false` or parameters the form can't represent).
|
||||
*
|
||||
* See github/copilot-mcp-core#1864 for the full show/defer state machine.
|
||||
*/
|
||||
export function completedToolResult<T = Record<string, unknown>>(
|
||||
result: CallToolResult | null,
|
||||
): T | null {
|
||||
if (!result || result.isError) return null;
|
||||
|
||||
const status = (result.structuredContent as { status?: string } | undefined)
|
||||
?.status;
|
||||
if (status === "awaiting_user_submission") return null;
|
||||
|
||||
const textContent = result.content?.find((c) => c.type === "text");
|
||||
if (!textContent || textContent.type !== "text" || !textContent.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(textContent.text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
import { existsSync, renameSync, rmSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const app = process.env.APP;
|
||||
|
||||
if (!app) {
|
||||
throw new Error("APP environment variable must be set");
|
||||
}
|
||||
|
||||
const outDir = resolve(__dirname, "../pkg/github/ui_dist");
|
||||
|
||||
// vite-plugin-singlefile inlines all JS/CSS into the HTML, but Vite preserves
|
||||
// the input file's relative path in the output (src/apps/<app>/index.html).
|
||||
// After the bundle is written, hoist that file to <outDir>/<app>.html and
|
||||
// remove the now-empty nested directories. Done in closeBundle (post-write)
|
||||
// because Rolldown disallows mutating the in-memory bundle in generateBundle.
|
||||
function flattenOutput(): Plugin {
|
||||
return {
|
||||
name: "flatten-output",
|
||||
enforce: "post",
|
||||
closeBundle() {
|
||||
const nested = resolve(outDir, `src/apps/${app}/index.html`);
|
||||
const flat = resolve(outDir, `${app}.html`);
|
||||
if (!existsSync(nested)) {
|
||||
throw new Error(
|
||||
`flatten-output: expected built HTML at ${nested} for app "${app}" but it was not emitted`,
|
||||
);
|
||||
}
|
||||
renameSync(nested, flat);
|
||||
rmSync(resolve(outDir, "src"), { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile(), flattenOutput()],
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, `src/apps/${app}/index.html`),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user