diff --git a/frontend/src/v2/components/V2GithubPrCard.tsx b/frontend/src/v2/components/V2GithubPrCard.tsx new file mode 100644 index 00000000..f04a421c --- /dev/null +++ b/frontend/src/v2/components/V2GithubPrCard.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState } from 'react'; + +// Inline GitHub PR preview rendered when a message references +// `https://github.com///pull/`. Detection lives in +// V2MessageBubble; this component owns the fetch + render once given the +// (owner, repo, number) tuple. +// +// Fetches via the public GitHub REST API (no auth required for public +// repos). Rate limit is 60/hr per IP unauthenticated — fine for the demo +// surface; a single PR is also memoized at module scope so re-renders and +// repeat references in the same session are free. +// +// Failure mode: if the fetch errors (private repo, rate-limited, network), +// the component renders nothing and the parent's clickable URL remains — +// graceful degradation, never blocks the message bubble. + +interface PrSummary { + title: string; + state: 'open' | 'closed'; + merged: boolean; + number: number; + htmlUrl: string; + user: { login: string; avatarUrl: string }; + createdAt: string; + commits: number; + additions: number; + deletions: number; + changedFiles: number; + contributors: { login: string; avatarUrl: string }[]; +} + +const cache = new Map>(); + +async function fetchPrSummary(owner: string, repo: string, number: number): Promise { + const key = `${owner}/${repo}#${number}`; + if (cache.has(key)) return cache.get(key)!; + const promise = (async () => { + try { + const base = `https://api.github.com/repos/${owner}/${repo}/pulls/${number}`; + const [prRes, commitsRes] = await Promise.all([ + fetch(base, { headers: { Accept: 'application/vnd.github+json' } }), + fetch(`${base}/commits?per_page=100`, { headers: { Accept: 'application/vnd.github+json' } }), + ]); + if (!prRes.ok) return null; + const pr: any = await prRes.json(); + const commits: any[] = commitsRes.ok ? await commitsRes.json() : []; + const contribMap = new Map(); + for (const c of commits) { + const author = c.author || c.commit?.author; + const login = (author?.login as string) || (c.commit?.author?.name as string) || ''; + const avatarUrl = (author?.avatar_url as string) || ''; + if (login && !contribMap.has(login)) { + contribMap.set(login, { login, avatarUrl }); + } + } + return { + title: String(pr.title || ''), + state: pr.state === 'closed' ? 'closed' : 'open', + merged: !!pr.merged, + number: pr.number, + htmlUrl: pr.html_url, + user: { + login: pr.user?.login || '', + avatarUrl: pr.user?.avatar_url || '', + }, + createdAt: pr.created_at, + commits: pr.commits || commits.length || 0, + additions: pr.additions || 0, + deletions: pr.deletions || 0, + changedFiles: pr.changed_files || 0, + contributors: Array.from(contribMap.values()), + }; + } catch (err) { + console.warn('[V2GithubPrCard] fetch failed:', (err as Error).message); + return null; + } + })(); + cache.set(key, promise); + return promise; +} + +interface V2GithubPrCardProps { + owner: string; + repo: string; + number: number; +} + +const V2GithubPrCard: React.FC = ({ owner, repo, number }) => { + const [pr, setPr] = useState('loading'); + + useEffect(() => { + let alive = true; + fetchPrSummary(owner, repo, number).then((data) => { + if (alive) setPr(data); + }); + return () => { alive = false; }; + }, [owner, repo, number]); + + if (pr === 'loading') { + return ( + + PR #{number} + loading… + + ); + } + + if (!pr) { + // Fetch failed — fall back to nothing so the bubble's clickable URL + // is the only artifact (parent has already rendered the URL). + return null; + } + + const stateLabel = pr.merged ? 'Merged' : (pr.state === 'open' ? 'Open' : 'Closed'); + const stateClass = pr.merged ? 'v2-prcard__state--merged' + : pr.state === 'open' ? 'v2-prcard__state--open' + : 'v2-prcard__state--closed'; + + const visibleContributors = pr.contributors.slice(0, 4); + const overflowCount = Math.max(0, pr.contributors.length - visibleContributors.length); + + return ( + +
+ {owner}/{repo} + {stateLabel} +
+
+ #{pr.number} {pr.title} +
+
+ {pr.commits} commit{pr.commits === 1 ? '' : 's'} + {pr.changedFiles} file{pr.changedFiles === 1 ? '' : 's'} + +{pr.additions} + −{pr.deletions} + {pr.contributors.length > 0 && ( + + {visibleContributors.map((c) => ( + + {!c.avatarUrl && c.login.slice(0, 1).toUpperCase()} + + ))} + {overflowCount > 0 && ( + +{overflowCount} + )} + + )} +
+
+ ); +}; + +// Match a github.com PR URL anywhere in a string. Capture (owner, repo, number). +// Anchored to a word boundary on either side so it doesn't match URLs nested +// inside other tokens. +export const GITHUB_PR_URL_RE = /https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?=\b|$)/g; + +export interface ParsedGithubPr { + owner: string; + repo: string; + number: number; + fullUrl: string; +} + +export const parseGithubPrUrls = (content: string): ParsedGithubPr[] => { + const results: ParsedGithubPr[] = []; + const seen = new Set(); + let match: RegExpExecArray | null; + GITHUB_PR_URL_RE.lastIndex = 0; + // eslint-disable-next-line no-cond-assign + while ((match = GITHUB_PR_URL_RE.exec(content)) !== null) { + const key = `${match[1]}/${match[2]}#${match[3]}`; + if (seen.has(key)) continue; + seen.add(key); + results.push({ + owner: match[1], + repo: match[2], + number: parseInt(match[3], 10), + fullUrl: match[0], + }); + } + return results; +}; + +export default V2GithubPrCard; diff --git a/frontend/src/v2/components/V2MessageBubble.tsx b/frontend/src/v2/components/V2MessageBubble.tsx index ed663b21..9e107ee1 100644 --- a/frontend/src/v2/components/V2MessageBubble.tsx +++ b/frontend/src/v2/components/V2MessageBubble.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import V2Avatar from './V2Avatar'; +import V2GithubPrCard, { parseGithubPrUrls } from './V2GithubPrCard'; import { V2Message } from '../hooks/useV2PodDetail'; import { formatRelativeTime } from '../utils/grouping'; import { useAuth } from '../../context/AuthContext'; @@ -289,12 +290,32 @@ const V2MessageBubble: React.FC = ({ message, isLead, agen // files. Order matters — files leave a trimmed body that we then read for // image rendering. const { stripped: noReactions, reactions } = parseReactions(message.content || ''); - const { stripped, files } = parseFiles(noReactions); - const markdownImage = stripped.match(MARKDOWN_IMAGE_RE)?.[1]; - const imageUrl = message.message_type === 'image' || message.messageType === 'image' || IMAGE_URL_RE.test(stripped) - ? stripped + const { stripped: afterFiles, files } = parseFiles(noReactions); + const markdownImage = afterFiles.match(MARKDOWN_IMAGE_RE)?.[1]; + const imageUrl = message.message_type === 'image' || message.messageType === 'image' || IMAGE_URL_RE.test(afterFiles) + ? afterFiles : markdownImage; + // GitHub PR URL detection — if the message body contains a `pull/` URL, + // we render an inline preview card below the text. Card fetch is lazy + + // memoized at module scope; one fetch per (owner, repo, number) per session. + // The bare URL is stripped from the rendered text so we don't double-show + // "URL as text + URL as card". + const prRefs = imageUrl ? [] : parseGithubPrUrls(afterFiles); + let stripped = afterFiles; + if (prRefs.length > 0) { + stripped = afterFiles.replace(/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+(?=\b|$)/g, '').trim(); + } + // Auto-linkify bare http(s) URLs that aren't already inside markdown link + // syntax. Without this, agents posting raw URLs render as plain text and + // the user can't click them. + if (stripped) { + stripped = stripped.replace( + /(?"]+?[^\s<>".,!?;:])(?=[\s.,!?;:]|$)/g, + '[$1]($1)', + ); + } + // Highlight messages that @-mention the current user. Word-boundary so // `@foo` doesn't match `@foobar`. Skip for self-authored messages — no // value highlighting your own outgoing message. @@ -352,6 +373,14 @@ const V2MessageBubble: React.FC = ({ message, isLead, agen {files.map((file, idx) => ( ))} + {prRefs.map((pr) => ( + + ))} {reactions.length > 0 && (
{reactions.map((r, idx) => ( diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index df1feac6..c8ba5d09 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1519,6 +1519,139 @@ background: var(--v2-accent-strong); } +/* GitHub PR preview card. Rendered below a message body when the content + contains a `https://github.com///pull/` URL. Card fetches + from GitHub REST API on mount; failure mode is silent (parent's clickable + URL stays). Visual: borders not shadows, one accent color, no gradients — + matches the v2 design system. */ +.v2-prcard { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + padding: 10px 12px; + background: var(--v2-surface); + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); + text-decoration: none; + color: var(--v2-text-primary); + transition: border-color 120ms ease, background 120ms ease; +} +.v2-prcard:hover { + border-color: var(--v2-border); + background: var(--v2-surface-hover); +} +.v2-prcard--loading { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--v2-text-secondary); + font-size: 12.5px; +} +.v2-prcard__chip { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 12px; + padding: 2px 8px; + border-radius: var(--v2-radius-pill); + background: var(--v2-accent-soft); + color: var(--v2-accent-text); +} +.v2-prcard__loading-text { + font-style: italic; +} +.v2-prcard__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--v2-text-secondary); +} +.v2-prcard__repo { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + letter-spacing: 0.01em; +} +.v2-prcard__state { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: var(--v2-radius-pill); + border: 1px solid transparent; +} +.v2-prcard__state--open { + color: #16a34a; + background: rgba(22, 163, 74, 0.10); + border-color: rgba(22, 163, 74, 0.20); +} +.v2-prcard__state--merged { + color: #6f42c1; + background: rgba(111, 66, 193, 0.10); + border-color: rgba(111, 66, 193, 0.20); +} +.v2-prcard__state--closed { + color: #ef4444; + background: rgba(239, 68, 68, 0.10); + border-color: rgba(239, 68, 68, 0.20); +} +.v2-prcard__title { + font-size: 14px; + font-weight: 600; + line-height: 1.3; + color: var(--v2-text-primary); +} +.v2-prcard__num { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + color: var(--v2-text-secondary); + font-weight: 500; + margin-right: 4px; +} +.v2-prcard__meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + color: var(--v2-text-secondary); +} +.v2-prcard__metric { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 11.5px; +} +.v2-prcard__metric--add { color: #16a34a; } +.v2-prcard__metric--del { color: #ef4444; } +.v2-prcard__contributors { + display: inline-flex; + align-items: center; + margin-left: auto; +} +.v2-prcard__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-left: -6px; + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: var(--v2-accent-soft); + border: 2px solid var(--v2-surface); + font-size: 10px; + font-weight: 600; + color: var(--v2-accent-text); +} +.v2-prcard__avatar:first-child { margin-left: 0; } +.v2-prcard__avatar--overflow { + background-color: var(--v2-border-soft); + color: var(--v2-text-secondary); + font-size: 10px; +} + /* @-mention dropdown anchored to the composer input wrap. Sits above the composer in DOM order but renders above-and-out via absolute positioning. */ .v2-mention-dropdown {