diff --git a/CHANGELOG.md b/CHANGELOG.md index 7642b774..f3d04a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/). ### ✨ Added +- A comment can now be handed to the agent to investigate: click the reference button on any comment and ask about it, and the agent gets the comment, its author and the code it points at, then checks the claim against the actual code. Its answer can be turned into a draft reply to that comment in one click, editable before it goes out. - The code editor is now Monaco 0.56.0 (from 0.55.1), which also drops the outdated sanitizer copy behind most of the open dependency advisories. - The bundled review engine is now pr-agent 0.45.0 (from 0.39.0). Two long-standing local fixes are no longer needed — a single-line file change is rendered correctly upstream now, and a binary file no longer has to be worked around — and its YAML handling is more tolerant of imperfect model output. - Mentions now render as a pill instead of blending into the surrounding text, so it is obvious at a glance when someone is named — on the activity page, in the inline diff comments, in drafts, and in the PR description alike. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 8f0c9dc8..efc42003 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -9,6 +9,7 @@ ### ✨ 新增 +- 现在可以把评论交给 Agent 调查:在任意评论上点击引用按钮再提问,Agent 会拿到评论原文、作者及其指向的代码,对照真实代码核查该说法是否成立。得到的结论可一键转成针对该评论的回复草稿,发出前仍可编辑。 - 代码编辑器升级至 Monaco 0.56.0(原 0.55.1),同时替换掉了此前多数依赖安全告警所指向的过时清理库副本。 - 内置评审引擎升级至 pr-agent 0.45.0(原 0.39.0)。两处长期存在的本地修补不再需要——单行文件变更在上游已渲染正确,二进制文件也无需再绕开——其 YAML 解析对不规范的模型输出也更宽容。 - @提及 改为胶囊标签展示,不再淹没在正文里,一眼即可看出点到了谁——活动页、内联 diff 评论、草稿与 PR 描述一致生效。 diff --git a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx index df25119c..725ae07e 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx @@ -12,8 +12,15 @@ import type { import { invoke } from '../../../api'; import { ChatIcon, TrashIcon, ConfirmModal, PaneLoading } from '../../common'; import { useChatRunStore } from '../../../stores/chat-run-store'; -import { useDraftsForPr } from '../../../stores/drafts-store'; +import { draftsStore, useDraftsForPr } from '../../../stores/drafts-store'; import { useFindingClosuresForPr } from '../../../stores/finding-closures-store'; +import { + commentReferenceStore, + commentReferenceLabel, + formatCommentReference, + useAnsweringComment, + useCommentReference, +} from '../../../stores/comment-reference-store'; import { formatReferencedContext, selectionStore, @@ -160,6 +167,7 @@ export function ChatPane({ // On PR switch, clear the reference state and reset the detached state to avoid cross-PR residue. useEffect(() => { setRefFinding(null); + commentReferenceStore.reset(); setScopeDetached(false); }, [prLocalId]); // On switching to another commit (or clearing the view scope), reset the detached state: the newly selected commit becomes the implicit scope again. @@ -172,10 +180,23 @@ export function ChatPane({ // Diff selection (belonging to the current PR): used for the input bar's "N lines selected" badge + carrying the selected code as implicit context into a question. const { selection: diffSelection, ignored: selectionIgnored } = useDiffSelection(prLocalId); - // When not ignored, format the selection into a reference string; shared by /ask and natural-language questions. Ignored / no selection → undefined (this message carries no reference). + // Comment referenced from a comment surface (activity panel / inline diff zone), carried as implicit context so the + // question can be about the comment without restating it. + const commentRef = useCommentReference(prLocalId); + // Implicit context for this message: the diff selection and the referenced comment are independent sources and + // compose — asking "is this comment right about the code I selected?" needs both. Each block is self-describing, so + // concatenating them needs no framing. Undefined when neither is present (the message carries no reference). const referencedContext = - diffSelection && !selectionIgnored ? formatReferencedContext(diffSelection) : undefined; + [ + diffSelection && !selectionIgnored ? formatReferencedContext(diffSelection) : null, + commentRef ? formatCommentReference(commentRef) : null, + ] + .filter(Boolean) + .join('\n\n') || undefined; + // The comment this round is answering (set when the question carried a reference). Its presence is what puts the + // "use as reply" action on the answer. + const answeringComment = useAnsweringComment(prLocalId); // The effective scope for this PR's chat commands: follows the commit selected in the Diff view, unless the user has detached (scopeDetached). // Only one scope may be in effect at a time — when a Diff selection exists it takes precedence (finer-grained), the commit scope is suspended and its chip // is hidden too (see commitScopeChip), auto-restored after the selection is cleared. @@ -252,6 +273,45 @@ export function ChatPane({ [timeline, pr?.sourceRef.sha], ); + // Key of the last assistant message in the timeline: the answer the action belongs on. Anchored to the last one + // rather than to a message id because a conversation message has no stable id of its own here. + const lastAnswerKey = useMemo(() => { + for (let i = timeline.length - 1; i >= 0; i -= 1) { + const m = timeline[i]?.message; + if (m && m.role !== 'user') return timeline[i]!.key; + } + return null; + }, [timeline]); + /** Turn the agent's answer into a draft reply to the comment that was asked about, then release the association. */ + const createReplyDraftFromAnswer = (body: string): void => { + if (!answeringComment || !prLocalId) return; + void invoke('drafts:create', { + localId: prLocalId, + draft: { + body, + status: 'pending', + replyTo: { parentCommentId: answeringComment.commentId }, + ...(answeringComment.anchor?.line != null + ? { + anchor: { + path: answeringComment.anchor.path, + startLine: answeringComment.anchor.line, + endLine: answeringComment.anchor.line, + }, + } + : {}), + } as Parameters>[1]['draft'], + }) + .then(() => { + void draftsStore.refresh(prLocalId); + commentReferenceStore.clearAnswering(); + }) + .catch((e: unknown) => { + console.error('create reply draft from answer failed', e); + }); + }; + + // Commit messages for divider tooltips: fetch the PR's commits (main-cached; keyed on head sha so it refreshes when // the head advances) into a sha → message map. Empty until loaded / on failure (the tooltip falls back to the short sha). const [commitMsgBySha, setCommitMsgBySha] = useState>(() => new Map()); @@ -435,7 +495,20 @@ export function ChatPane({ ) : entry.step ? ( ) : entry.message ? ( - + { + createReplyDraftFromAnswer(entry.message!.content); + }, + } + : null + } + /> ) : null, )} {/* Trailing commit divider, for the one case with no entry to precede: the head advanced past the last run's @@ -500,6 +573,13 @@ export function ChatPane({ undefined, tool === 'ask' ? undefined : (effectiveScope ?? undefined), ); + // Hand the referenced comment off to the round being answered: it detaches from the input bar (a reference + // is per-question — leaving it attached would silently prepend the same comment to every later message) + // while staying associated with this answer, so the answer can be turned into a reply to it. Called on + // every send, including unreferenced ones, which is what clears a previous round's association. + // The diff selection is deliberately *not* released — it stays visible in the editor, so keeping it is + // what the user sees and expects. + if (tool === 'ask') commentReferenceStore.handOff(); }} onAgentAsk={(q) => { if (refFinding) { @@ -512,6 +592,7 @@ export function ChatPane({ return; } void actions.handleAgentAsk(q, referencedContext); + commentReferenceStore.handOff(); }} onCancel={hasMyActive || agentRunningHere ? actions.handleStopAll : undefined} onSetReviewStatus={onSetReviewStatus} @@ -537,6 +618,18 @@ export function ChatPane({ } : null } + // Referenced comment: chip showing ": " + clear. Attached from a comment's reference button; + // carried as implicit context with the next question, and released on send (see onSend). + commentChip={ + commentRef + ? { + label: commentReferenceLabel(commentRef), + onClear: () => { + commentReferenceStore.clear(); + }, + } + : null + } // Single-commit scope chip: shown when a commit is selected in the view (the selected state comes from the view); click to toggle enabled/disabled — // when disabled (scopeDetached) commands revert to the full PR and the chip greys out; switching to another commit or switching PR resets it to enabled. // Only one scope at a time: when a Diff selection exists it yields to the selection chip (hides this chip), auto-restored after the selection is cleared. diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx index 06d5979a..839296bb 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx @@ -12,6 +12,7 @@ import { FileTreeIcon, SendIcon, StopIcon, + ShareIcon, } from '../../../common'; import { useChatInput } from '../hooks/useChatInput'; import { useTextareaAutosizeDrag } from '../hooks/useTextareaAutosizeDrag'; @@ -55,6 +56,8 @@ interface ChatInputBarProps { onToggleSelection: () => void; /** Re-review reference chip: shows "re-review " + clear when a finding is referenced; null = not rendered. */ referenceChip?: { label: string; onClear: () => void } | null; + /** Referenced-comment chip: shows ": " + clear when a comment is referenced; null = not rendered. */ + commentChip?: { label: string; onClear: () => void } | null; /** * Single-commit scope chip: follows the commit selected in the Diff view, showing "short SHA · subject". Shown whenever there is * a selection, click to **toggle enable/disable** (disabling does not remove the chip, this session's commands revert to the whole @@ -89,6 +92,7 @@ export function ChatInputBar({ selectionIgnored, onToggleSelection, referenceChip, + commentChip, commitScopeChip, }: ChatInputBarProps) { const { t } = useTranslation(); @@ -287,6 +291,27 @@ export function ChatInputBar({ )} + {/* Referenced comment chip: the comment is carried as implicit context for this question. Its own chip rather + than sharing the re-review one — that reference changes what the run *does* (produces a verdict), while + this only adds context, and the two can be attached at once. */} + {commentChip && ( + <> +