From cab79b257099598faad4e01785a64fcdf2ed3545 Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Mon, 4 May 2026 09:30:18 -0700 Subject: [PATCH] feat(v2-chat): GitHub PR preview cards + auto-linkify bare URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for messages that reference work artifacts. 1. Bare URLs are now clickable ------------------------------- Previously, agents/users posting raw URLs (e.g. "https://github.com/Team-Commonly/commonly/pull/297") rendered as plain text — no anchor, no click. ReactMarkdown's default mode doesn't autolink bare URLs and remark-gfm isn't installed. Quick fix: pre-process message content to wrap bare http(s) URLs in markdown link syntax `[url](url)` before passing to ReactMarkdown. Skips URLs already inside markdown link / autolink syntax via negative lookbehind on `(`, `<`, `[`. Existing messageMarkdownComponents.a sets target="_blank" + rel. 2. GitHub PR URLs render as inline preview cards ------------------------------------------------- New V2GithubPrCard component fetches PR meta from the public GitHub REST API on mount and renders: - Repo (owner/name) - State pill (Open / Merged / Closed) with color - PR number + title - Commit count, file count, +/- line stats - Contributor avatars (up to 4, with overflow chip) Click target is the github.com PR URL (target=_blank). Detection regex matches `pull/` URLs anywhere in the message body. Detected URLs are stripped from the rendered text so we don't double-show "URL as text + URL as card". Fetch is memoized at module scope — one fetch per (owner, repo, number) per session. Failure mode (private repo, rate limited, network error) renders nothing; the parent's clickable URL is the fallback. Why this exists --------------- Agent collaboration in Commonly centers on shared artifacts. Pull requests are the canonical "this is what the agents shipped" moment, and showing them as plain text URLs sells the workflow short. The card visualization makes the multi-vendor contributor list visible at a glance — exactly the moat moment for any multi-agent demo. Net diff: - New: V2GithubPrCard.tsx (~190 lines incl. fetch + memo + types) - V2MessageBubble.tsx: +21 lines (parse, strip, linkify, render) - v2.css: +110 lines (.v2-prcard chrome — borders not shadows, one accent, no gradients per the v2 design system) No new dependencies. No backend changes. Public GitHub API only; private-repo PRs render the URL only. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/v2/components/V2GithubPrCard.tsx | 201 ++++++++++++++++++ .../src/v2/components/V2MessageBubble.tsx | 37 +++- frontend/src/v2/v2.css | 133 ++++++++++++ 3 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v2/components/V2GithubPrCard.tsx 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 {