Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### ✨ 新增

- 现在可以把评论交给 Agent 调查:在任意评论上点击引用按钮再提问,Agent 会拿到评论原文、作者及其指向的代码,对照真实代码核查该说法是否成立。得到的结论可一键转成针对该评论的回复草稿,发出前仍可编辑。
- 代码编辑器升级至 Monaco 0.56.0(原 0.55.1),同时替换掉了此前多数依赖安全告警所指向的过时清理库副本。
- 内置评审引擎升级至 pr-agent 0.45.0(原 0.39.0)。两处长期存在的本地修补不再需要——单行文件变更在上游已渲染正确,二进制文件也无需再绕开——其 YAML 解析对不规范的模型输出也更宽容。
- @提及 改为胶囊标签展示,不再淹没在正文里,一眼即可看出点到了谁——活动页、内联 diff 评论、草稿与 PR 描述一致生效。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<typeof invoke<'drafts:create'>>[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<Map<string, string>>(() => new Map());
Expand Down Expand Up @@ -435,7 +495,20 @@ export function ChatPane({
) : entry.step ? (
<AgentStepRow key={entry.key} step={entry.step} />
) : entry.message ? (
<ConversationMessage key={entry.key} message={entry.message} />
<ConversationMessage
key={entry.key}
message={entry.message}
useAsReply={
answeringComment && entry.key === lastAnswerKey
? {
label: t('chatPane.commentReference.useAsReply'),
onUse: () => {
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
Expand Down Expand Up @@ -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) {
Expand All @@ -512,6 +592,7 @@ export function ChatPane({
return;
}
void actions.handleAgentAsk(q, referencedContext);
commentReferenceStore.handOff();
}}
onCancel={hasMyActive || agentRunningHere ? actions.handleStopAll : undefined}
onSetReviewStatus={onSetReviewStatus}
Expand All @@ -537,6 +618,18 @@ export function ChatPane({
}
: null
}
// Referenced comment: chip showing "<author>: <excerpt>" + 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
FileTreeIcon,
SendIcon,
StopIcon,
ShareIcon,
} from '../../../common';
import { useChatInput } from '../hooks/useChatInput';
import { useTextareaAutosizeDrag } from '../hooks/useTextareaAutosizeDrag';
Expand Down Expand Up @@ -55,6 +56,8 @@ interface ChatInputBarProps {
onToggleSelection: () => void;
/** Re-review reference chip: shows "re-review <file:line>" + clear when a finding is referenced; null = not rendered. */
referenceChip?: { label: string; onClear: () => void } | null;
/** Referenced-comment chip: shows "<author>: <excerpt>" + 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
Expand Down Expand Up @@ -89,6 +92,7 @@ export function ChatInputBar({
selectionIgnored,
onToggleSelection,
referenceChip,
commentChip,
commitScopeChip,
}: ChatInputBarProps) {
const { t } = useTranslation();
Expand Down Expand Up @@ -287,6 +291,27 @@ export function ChatInputBar({
</span>
</>
)}
{/* 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 && (
<>
<span className="chat-cmd-divider" aria-hidden="true" />
<span className="chat-selection-chip chat-reference-chip" title={commentChip.label}>
<ShareIcon size={12} />
<span>{commentChip.label}</span>
<button
type="button"
className="chat-reference-chip-clear"
onClick={commentChip.onClear}
title={t('chatPane.commentReference.clearTitle')}
aria-label={t('chatPane.commentReference.clearTitle')}
>
✕
</button>
</span>
</>
)}
{/* Single-commit scope chip: follows the commit selected in the view, showing "short SHA · subject". Click to toggle enable/disable (without removing) —
when enabled the commands are scoped to that commit (parent..sha), when disabled they revert to the whole PR, greyed out + eye-slash. */}
{commitScopeChip && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { useTranslation } from 'react-i18next';
import type { AgentMessage } from '@meebox/shared';
import { ChatIcon } from '../../../common';
import { ChatIcon, ShareIcon } from '../../../common';
import { VERDICT_LABEL_KEY } from '../constants';
import { Md } from './shared';

/**
* Display of a single multi-turn conversation message: user → right-aligned bubble; assistant review type
* (with recommendation) → "review summary" card + verdict badge; assistant conversation type (no
* recommendation) → left-aligned dedicated conversation reply wrapper.
*
* `useAsReply` turns the answer into a draft reply to the comment the question referenced. Offered only on the answer
* to such a question (the caller decides), because the action needs to know which comment it is replying to.
*/
export function ConversationMessage({ message }: { message: AgentMessage }) {
export function ConversationMessage({
message,
useAsReply,
}: {
message: AgentMessage;
useAsReply?: { label: string; onUse: () => void } | null;
}) {
const { t } = useTranslation();
if (message.role === 'user') {
return (
Expand Down Expand Up @@ -53,6 +62,17 @@ export function ConversationMessage({ message }: { message: AgentMessage }) {
<ChatIcon size={16} />
<div className="markdown chat-agent-reply-body">
<Md>{message.content}</Md>
{/* Turn the answer into a draft reply to the comment that was asked about. A draft rather than a posted
reply: the answer is the agent's, and publishing in the user's name is theirs to decide — the draft lands
in the same pool as every other pending reply, editable and published with the review batch. */}
{useAsReply && (
<div className="chat-agent-reply-actions">
<button type="button" className="btn btn-sm" onClick={useAsReply.onUse}>
<ShareIcon size={12} />
<span>{useAsReply.label}</span>
</button>
</div>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
ConfirmModal,
LazyBoundary,
mermaidComponents,
ShareIcon,
} from '../../../../common';
import {
commentReferenceStore,
useCommentReference,
} from '../../../../../stores/comment-reference-store';
import { CommentEditEditor } from './CommentEditEditor';
import { CommentReplyEditor } from './CommentReplyEditor';
import { ReplyDraftList } from './ReplyDraftList';
Expand Down Expand Up @@ -107,6 +112,10 @@ export function CommentItem({
readOnly,
);

// Whether this comment is the one currently referenced into the Agent conversation (drives the toggle + active styling).
const activeRef = useCommentReference(pr.localId);
const referenced = activeRef?.commentId === comment.remoteId;

// inline comment anchor chip: path:line + side (old=base / new=head), letting the user locate the code position from the comment.
// When onJumpToAnchor is provided (activity view) the chip becomes clickable → jump to the corresponding file/line in the Diff.
const anchor = comment.anchor;
Expand Down Expand Up @@ -205,6 +214,35 @@ export function CommentItem({
{deleting ? t('commentsPanel.deleting') : t('common.delete')}
</button>
)}
{/* Reference this comment into the Agent conversation: attaches it as implicit context, so the question can be
"is this right?" without the user restating the comment or its location. Uses the same ShareIcon as the
finding reference button — one action, one glyph, rather than teaching the same gesture twice. A toggle
rather than a one-way action: clicking the referenced comment again releases it (the input-bar chip can
also clear it). */}
{!replyOpen && (
<button
type="button"
className={`pr-comment-reference-btn${referenced ? ' is-active' : ''}`}
onClick={() =>
referenced
? commentReferenceStore.clear()
: commentReferenceStore.set({
prLocalId: pr.localId,
commentId: comment.remoteId,
author: comment.author.displayName || comment.author.name,
body: comment.body,
...(comment.anchor
? { anchor: { path: comment.anchor.path, line: comment.anchor.line } }
: {}),
})
}
title={referenced ? t('commentsPanel.referenceClearTitle') : t('commentsPanel.referenceTitle')}
aria-label={referenced ? t('commentsPanel.referenceClearTitle') : t('commentsPanel.referenceTitle')}
aria-pressed={referenced}
>
<ShareIcon size={13} />
</button>
)}
{/* The "add reaction" button goes after the action buttons; hidden in reply edit mode to avoid crowding */}
{reactionsMode && !replyOpen && (
<ReactionAddButton
Expand Down
Loading
Loading