feat(desktop): paste a PR review comment as structured composer context

A pasted GitHub PR comment deep link (#discussion_r… / #issuecomment-…)
now lands as a typed review attachment instead of a bare url chip. The
card attaches optimistically and resolves through gh in the background —
author, file:line anchor, body, and the diff hunk — expanding at send
into an anchored fenced block, so "address this" carries exactly what
"this" is. When gh can't answer (offline, unauthenticated, foreign repo,
remote gateway) the card downgrades to the plain url ref and nothing is
lost.
This commit is contained in:
Brooklyn Nicholson 2026-08-12 21:05:56 -05:00 committed by brooklyn!
parent 2960bf37ab
commit 071d27d1c3
17 changed files with 255 additions and 7 deletions

View File

@ -618,6 +618,77 @@ const prPayload = pr => ({
url: String(pr.url || '')
})
// A GitHub review-comment / issue-comment URL, as pasted from the browser.
// Captures owner, repo, PR number, and the comment kind + id. Review threads
// deep-link as `#discussion_r<id>`; conversation-tab comments as
// `#issuecomment-<id>`.
const PR_COMMENT_URL_RE =
/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:\/[^#\s]*)?#(discussion_r|issuecomment-)(\d+)$/
function parsePrCommentUrl(url) {
const match = PR_COMMENT_URL_RE.exec(String(url || '').trim())
if (!match) {
return null
}
const [, owner, repo, prNumber, kind, id] = match
return { id, kind: kind === 'discussion_r' ? 'review' : 'issue', owner, prNumber: Number(prNumber), repo }
}
// Resolve a pasted PR comment URL into the structured context the composer
// attaches: author, body, and — for review comments — the file, line range,
// and the diff hunk the comment anchors to. Reads only; any failure (gh
// missing, unauthenticated, private repo, deleted comment) yields null and the
// paste falls back to being a plain URL.
async function reviewFetchPrComment(repoPath, ghBin, url) {
const parsed = parsePrCommentUrl(url)
if (!parsed) {
return null
}
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review comment fetch' })
} catch {
return null
}
const endpoint =
parsed.kind === 'review'
? `repos/${parsed.owner}/${parsed.repo}/pulls/comments/${parsed.id}`
: `repos/${parsed.owner}/${parsed.repo}/issues/comments/${parsed.id}`
const res = await runGh(['api', endpoint], cwd, ghBin)
if (!res.ok) {
return null
}
try {
const data = JSON.parse(res.stdout)
return {
author: String(data?.user?.login || ''),
body: String(data?.body || ''),
diffHunk: parsed.kind === 'review' ? String(data?.diff_hunk || '') : '',
kind: parsed.kind,
// `line` is the comment's anchor in the current diff; null once the code
// moved on (outdated comment) — `original_line` still says where it was.
line: data?.line ?? data?.original_line ?? null,
path: parsed.kind === 'review' ? String(data?.path || '') : '',
prNumber: parsed.prNumber,
startLine: data?.start_line ?? data?.original_start_line ?? null,
url: String(data?.html_url || url)
}
} catch {
return null
}
}
// The PR for each of the given branches, keyed by branch. Asks GitHub about the
// branches we actually have sessions on rather than listing the repo's newest
// PRs and hoping ours are in the page — on a busy repo they are not. One
@ -820,6 +891,7 @@ export {
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewFetchPrComment,
reviewList,
reviewPrList,
reviewPush,

View File

@ -109,6 +109,7 @@ import {
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewFetchPrComment,
reviewList,
reviewPrList,
reviewPush,
@ -11807,6 +11808,9 @@ ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewS
ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) =>
reviewPrList(repoPath, resolveGhBinary(), branches, numbers)
)
ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) =>
reviewFetchPrComment(repoPath, resolveGhBinary(), url)
)
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)

View File

@ -235,6 +235,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
prList: (repoPath, branches, numbers) =>
ipcRenderer.invoke('hermes:git:review:prList', repoPath, branches, numbers),
fetchPrComment: (repoPath, url) => ipcRenderer.invoke('hermes:git:review:fetchPrComment', repoPath, url),
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
}
},

View File

@ -7,7 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, MessageCode, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
@ -33,14 +33,27 @@ export function AttachmentList({
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const { t } = useI18n()
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const Icon = {
file: FileText,
folder: FolderOpen,
image: ImageIcon,
review: MessageCode,
terminal: Terminal,
url: Link
}[attachment.kind]
// The tile's cwd when this pill lives in a tile composer, not the primary's:
// a relative attachment path has to resolve against its own session's root.
const cwd = useStore(useSessionView().$cwd)
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
// A review card's detail is its resolved-comment JSON, not a previewable
// path — clicking it should do nothing rather than toast a bogus failure.
const canPreview =
attachment.kind !== 'folder' && attachment.kind !== 'terminal' && attachment.kind !== 'review' && !isUploading
const detail =
attachment.kind !== 'review' && attachment.detail && attachment.detail !== attachment.label
? attachment.detail
: undefined
// An attached image already holds its full bytes as a data URL, so it belongs
// in the same lightbox the thread uses. The rail is for files you read or
// edit — not a picture you just want to look at. Images that never resolved a

View File

@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { PR_COMMENT_URL_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
@ -93,6 +94,7 @@ export function ChatBar({
onAddUrl,
onAttachDroppedItems,
onAttachImageBlob,
onAttachPrCommentUrl,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
@ -514,6 +516,17 @@ export function ChatBar({
return
}
// A pasted GitHub PR-comment deep link resolves to a structured review
// attachment (author, body, file:line anchor, diff hunk) instead of a bare
// `@url:` chip. Optimistic card first, resolve via gh in the background —
// if gh can't answer (offline, unauthenticated, foreign repo) the card
// swaps back to the plain URL ref so nothing is lost.
if (PR_COMMENT_URL_RE.test(pastedText) && onAttachPrCommentUrl?.(pastedText)) {
event.preventDefault()
return
}
event.preventDefault()
// Links in the paste land as `@url:` chips rather than a wall of URL text —

View File

@ -46,6 +46,9 @@ export interface ChatBarProps {
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
/** Pasted GitHub PR-comment deep link structured review attachment.
* Returns true when the paste was consumed as an attachment. */
onAttachPrCommentUrl?: (url: string) => boolean
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles?: () => void
onPickFolders?: () => void

View File

@ -6,12 +6,14 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { useI18n } from '@/i18n'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs'
import { desktopGit } from '@/lib/desktop-git'
import { normalize } from '@/lib/text'
import {
addComposerAttachment,
type ComposerAttachment,
removeComposerAttachment,
setComposerTerminalSelection
setComposerTerminalSelection,
updateComposerAttachment
} from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
@ -260,12 +262,14 @@ export function partitionDroppedFiles(candidates: DroppedFile[]): {
interface ComposerActionsScope {
add: (attachment: ComposerAttachment) => void
remove: (id: string) => ComposerAttachment | null
update: (attachment: ComposerAttachment) => boolean
target: string
}
const MAIN_ACTIONS_SCOPE: ComposerActionsScope = {
add: addComposerAttachment,
remove: removeComposerAttachment,
update: updateComposerAttachment,
target: 'main'
}
@ -331,6 +335,49 @@ export function useComposerActions({
[attachToMain]
)
// A pasted GitHub PR-comment deep link → structured `review` attachment.
// Optimistic: the card lands immediately with the URL as its ref, then the
// background gh resolve fills in author/anchor (label + detail). If gh can't
// answer — offline, unauthenticated, foreign repo, remote gateway — the card
// downgrades to a plain `url` attachment so the paste is never lost.
const attachPrCommentUrl = useCallback(
(url: string): boolean => {
const id = attachmentId('review', url)
const refText = `@url:${formatRefValue(url)}`
attachToMain({
id,
kind: 'review',
label: url.replace(/^https:\/\/github\.com\//, '').replace(/#.*$/, ''),
refText,
uploadState: 'uploading'
})
void (async () => {
const comment = currentCwd
? await (desktopGit()?.review.fetchPrComment(currentCwd, url).catch(() => null) ?? null)
: null
if (comment) {
scope.update({
id,
kind: 'review',
label: comment.path
? `${pathLabel(comment.path)}${comment.line ? `:${comment.line}` : ''} — @${comment.author}`
: `PR #${comment.prNumber} — @${comment.author}`,
detail: JSON.stringify(comment),
refText
})
} else {
scope.update({ id, kind: 'url', label: pathLabel(url), refText })
}
})()
return true
},
[attachToMain, currentCwd, scope]
)
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await selectDesktopPaths({
@ -653,6 +700,7 @@ export function useComposerActions({
attachDroppedItems,
attachImageBlob,
attachImagePath,
attachPrCommentUrl,
insertContextPathInlineRef,
pasteClipboardImage,
pickContextPaths,

View File

@ -78,6 +78,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onAttachPrCommentUrl?: (url: string) => boolean
onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles: () => void
onPickFolders: () => void
@ -289,6 +290,7 @@ export const ChatView = memo(function ChatView({
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
onAttachPrCommentUrl,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
@ -616,6 +618,7 @@ export const ChatView = memo(function ChatView({
onAddUrl={onAddUrl}
onAttachDroppedItems={onAttachDroppedItems}
onAttachImageBlob={onAttachImageBlob}
onAttachPrCommentUrl={onAttachPrCommentUrl}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}

View File

@ -149,7 +149,7 @@ function TileChat({
activeSessionId: runtimeId,
currentCwd: cwd,
requestGateway,
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
scope: { add: attachments.add, remove: attachments.remove, target: scope.target, update: attachments.update }
})
// ChatView is memo()d — every callback prop must be referentially stable or
@ -200,6 +200,7 @@ function TileChat({
onAddUrl={onAddUrl}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onAttachPrCommentUrl={composer.attachPrCommentUrl}
onCancel={actions.cancelRun}
onDeleteSelectedSession={noop}
onDismissError={actions.dismissError}

View File

@ -31,6 +31,7 @@ export type ChatActions = Pick<
| 'onAddUrl'
| 'onAttachDroppedItems'
| 'onAttachImageBlob'
| 'onAttachPrCommentUrl'
| 'onBranchInNewChat'
| 'onCancel'
| 'onDeleteSelectedSession'

View File

@ -864,6 +864,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onArchiveSession: sessionId => void archiveSession(sessionId),
onAttachDroppedItems: composer.attachDroppedItems,
onAttachImageBlob: composer.attachImageBlob,
onAttachPrCommentUrl: composer.attachPrCommentUrl,
onBranchInNewChat: messageId => void branchInNewChat(messageId),
onBranchSession: sessionId => void branchStoredSession(sessionId),
onCancel: cancelRun,

View File

@ -272,6 +272,10 @@ declare global {
// number — for badging a list of sessions in one request instead of
// one `pr view` per checkout.
prList: (repoPath: string, branches: string[], numbers?: number[]) => Promise<HermesRepoPullRequests>
// A pasted PR review/issue comment URL resolved to its structured
// context (author, body, file + line anchor, diff hunk). Null when
// gh can't answer — the paste stays a plain URL.
fetchPrComment: (repoPath: string, url: string) => Promise<HermesPrComment | null>
createPr: (repoPath: string) => Promise<{ url: string }>
}
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
@ -978,6 +982,21 @@ export interface HermesRepoPullRequests {
prs: HermesBranchPullRequest[]
}
// A PR review/issue comment resolved from a pasted GitHub URL — the composer's
// review-comment attachment context. `path`/`line`/`diffHunk` are empty for
// conversation-tab (issue) comments; `line` is null when the comment is
// outdated and only `original_line` remained.
export interface HermesPrComment {
author: string
body: string
diffHunk: string
kind: 'issue' | 'review'
line: null | number
path: string
prNumber: number
startLine: null | number
url: string
}
// gh availability/auth + the current branch's PR — drives the review pane's PR
// button (disabled when gh isn't ready, "Open PR" vs "Create PR" otherwise).
export interface HermesReviewShipInfo {

View File

@ -70,6 +70,34 @@ describe('attachmentDisplayText', () => {
it('still resolves a normal file ref', () => {
expect(attachmentDisplayText(attachment({ kind: 'file', refText: '@file:src/a.ts' }))).toBe('@file:src/a.ts')
})
it('expands a review attachment into an anchored fenced block', () => {
const detail = JSON.stringify({
author: 'teknium1',
body: 'this cap looks wrong',
diffHunk: '@@ -1,2 +1,2 @@\n-const CAP = 5\n+const CAP = 50',
kind: 'review',
line: 12,
path: 'src/limits.ts',
prNumber: 123,
startLine: null,
url: 'https://github.com/o/r/pull/123#discussion_r1'
})
const block = attachmentDisplayText(attachment({ kind: 'review', detail, refText: '@url:`https://x`' }))
// The contract: anchor (file:line), author, body, and the hunk all ride.
expect(block).toContain('review-comment src/limits.ts:12')
expect(block).toContain('@teknium1')
expect(block).toContain('this cap looks wrong')
expect(block).toContain('const CAP = 50')
})
it('falls back to the url ref when a review detail is malformed', () => {
expect(attachmentDisplayText(attachment({ kind: 'review', detail: 'not json', refText: '@url:`https://x`' }))).toBe(
'@url:`https://x`'
)
})
})
describe('coerceThinkingText', () => {

View File

@ -172,6 +172,29 @@ export function attachmentId(kind: ComposerAttachment['kind'], value: string): s
return `${kind}:${normalizeAttachmentValue(kind, value)}`
}
/** A GitHub PR review-thread (`#discussion_r<id>`) or conversation
* (`#issuecomment-<id>`) deep link the one paste shape that can resolve to
* a structured review attachment instead of a plain `@url:` chip. */
export const PR_COMMENT_URL_RE =
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+(?:\/[^#\s]*)?#(?:discussion_r|issuecomment-)\d+$/
/** The send-time expansion of a `review` attachment. `detail` holds the
* resolved comment as JSON (HermesPrComment shape); a malformed payload falls
* back to the attachment's URL ref so the send never throws. */
export function reviewCommentBlock(detail: string): null | string {
try {
const c = JSON.parse(detail)
const anchor = c.path
? `${c.path}${c.line ? `:${c.startLine && c.startLine !== c.line ? `${c.startLine}-` : ''}${c.line}` : ''}`
: `PR #${c.prNumber}`
const hunk = c.diffHunk ? `\n--- diff hunk ---\n${String(c.diffHunk).trim()}` : ''
return `\`\`\`review-comment ${anchor}\n@${c.author} on ${c.url}\n\n${String(c.body).trim()}${hunk}\n\`\`\``
} catch {
return null
}
}
export function pathLabel(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || path
}
@ -188,6 +211,18 @@ export function attachmentDisplayText(attachment: ComposerAttachment): string |
return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\``
}
// A resolved PR review comment: expand to a fenced block carrying the
// anchor (file:line), author, body, and — when present — the diff hunk the
// comment sits on, so "address this" needs no re-explaining what "this" is.
// A malformed payload falls through to the refText (the pasted URL).
if (attachment.kind === 'review' && attachment.detail) {
const block = reviewCommentBlock(attachment.detail)
if (block) {
return block
}
}
if (attachment.refText) {
return attachment.refText
}

View File

@ -96,6 +96,10 @@ const remoteGit: GitBridge = {
prList: (repoPath, branches, numbers) =>
gitPost<HermesRepoPullRequests>('review/pr-list', { branches, numbers: numbers ?? [], path: repoPath }),
// Remote gateways have no PR-comment route yet; resolve to null so the
// paste degrades to a plain URL instead of throwing mid-paste.
fetchPrComment: async () => null,
createPr: repoPath => gitPost('review/create-pr', { path: repoPath })
},

View File

@ -69,6 +69,7 @@ import {
IconMail as Mail,
IconMaximize as Maximize,
IconMessageCircle as MessageCircle,
IconMessageCode as MessageCode,
IconMessageQuestion as MessageQuestion,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@ -195,6 +196,7 @@ export {
Mail,
Maximize,
MessageCircle,
MessageCode,
MessageQuestion,
MessageSquareText,
Mic,

View File

@ -5,7 +5,7 @@ import { triggerHaptic } from '@/lib/haptics'
export interface ComposerAttachment {
id: string
kind: 'image' | 'file' | 'folder' | 'terminal' | 'url'
kind: 'file' | 'folder' | 'image' | 'review' | 'terminal' | 'url'
label: string
detail?: string
refText?: string