diff --git a/apps/desktop/electron/git-review-ops.ts b/apps/desktop/electron/git-review-ops.ts index 393ede5f85a43..95cdfba7ca39b 100644 --- a/apps/desktop/electron/git-review-ops.ts +++ b/apps/desktop/electron/git-review-ops.ts @@ -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`; conversation-tab comments as +// `#issuecomment-`. +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, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9c40a64739afd..d908d5fa18538 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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()) ) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f2b16d1760576..0231ecf33e6c0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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) } }, diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index cb42dc9f1c99c..5d5028466a9c2 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -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 diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 56e0bf9c2f7b8..5d490eb61c412 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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 — diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 24abcf3736971..da6a463cd3866 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -46,6 +46,9 @@ export interface ChatBarProps { onAddUrl?: (url: string) => void onAttachImageBlob?: (blob: Blob) => Promise | boolean | void onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise | 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 | void onPickFiles?: () => void onPickFolders?: () => void diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 3c31f4067c7fe..0c560b3450cc0 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -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, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 1c2636b4f66e8..16f622bee9bcf 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -78,6 +78,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void + onAttachPrCommentUrl?: (url: string) => boolean onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise | 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} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 27d2cebf68ecf..76ecc3b24cd9e 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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} diff --git a/apps/desktop/src/app/contrib/types.ts b/apps/desktop/src/app/contrib/types.ts index 44c29f468532b..bc9142c8e4e91 100644 --- a/apps/desktop/src/app/contrib/types.ts +++ b/apps/desktop/src/app/contrib/types.ts @@ -31,6 +31,7 @@ export type ChatActions = Pick< | 'onAddUrl' | 'onAttachDroppedItems' | 'onAttachImageBlob' + | 'onAttachPrCommentUrl' | 'onBranchInNewChat' | 'onCancel' | 'onDeleteSelectedSession' diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 11583b635cde4..e0a7216abcc2e 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 7c52ed5c17917..f2470634c2fac 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 + // 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 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 { diff --git a/apps/desktop/src/lib/chat-runtime.test.ts b/apps/desktop/src/lib/chat-runtime.test.ts index 7ec2aa4cc4d8e..621286e584ab8 100644 --- a/apps/desktop/src/lib/chat-runtime.test.ts +++ b/apps/desktop/src/lib/chat-runtime.test.ts @@ -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', () => { diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 4f48a141a9488..0e703857258a2 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -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`) or conversation + * (`#issuecomment-`) 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 } diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts index 189046e0e72c9..98f41fbf87e0b 100644 --- a/apps/desktop/src/lib/desktop-git.ts +++ b/apps/desktop/src/lib/desktop-git.ts @@ -96,6 +96,10 @@ const remoteGit: GitBridge = { prList: (repoPath, branches, numbers) => gitPost('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 }) }, diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 550c2a4bc88fc..6b3d5f80cc7ba 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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, diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 9f2d541f9b9aa..1b6847a537b15 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -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