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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ and the versioning follows [Semantic Versioning](https://semver.org/).
- Binary files in the diff (images, office documents, PDFs, …) now show their Git LFS status — a "Git LFS · <size>" tag for LFS-managed files, or a "⚠ Not LFS" tag for files stored inline in git.
- Comments can now be anchored to a whole file (not only a single line) where the platform supports it (Bitbucket / GitHub): a "comment on file" entry in the diff, and existing file-level comments now display correctly instead of being shown as generic PR comments.
- Reviews now pick up the reviewed repository's own guidance file (`AGENTS.md`) as project context, so the review, description, and suggestions follow the project's stated conventions.
- Replying to a comment now creates a **reply draft** instead of posting immediately — consistent with adding a new inline comment. The reply is persisted (so an unsubmitted reply survives switching PRs/files/tabs), shown below its parent comment on every surface (activity timeline + inline diff), and published together with your other drafts via "Publish comments".

### 🔧 Fixed

- An in-progress inline comment reply or edit in the diff is no longer discarded when the comment list refreshes (e.g. the poller pulls a new remote comment while you're typing) — the open editor keeps its text.

## [0.11.0] - 2026-07-07

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
- diff 中的二进制文件(图片、Office 文档、PDF 等)现会显示其 Git LFS 状态——受 LFS 管理的文件显示「Git LFS · <大小>」标记,直接内联存入 git 的文件显示「⚠ 非 LFS」标记。
- 现可对整个文件(而不仅是某一行)添加评论(平台支持时,Bitbucket / GitHub):diff 中新增「对文件评论」入口,且已存在的文件级评论现能正确归属显示,不再被当作 PR 通用评论。
- 评审现会读取被审仓库自身的规范文件(`AGENTS.md`)作为项目上下文,使评审、描述与建议遵循该项目既定的约定。
- 回复评论现会生成一条**回复草稿**,而非立即提交——与新增内联评论的行为一致。回复被持久化(未提交的回复可跨 PR / 文件 / 标签页切换保留),在每个界面(活动时间线 + 内联 diff)都显示在其父评论下方,并随其他草稿一起通过「发布评论」批量发布。

### 🔧 修复

- 在 diff 中正在编写的内联评论回复 / 编辑,不再因评论列表刷新(如轮询在你输入时拉到新的远端评论)而被丢弃——打开的编辑器会保留已输入的内容。

## [0.11.0] - 2026-07-07

Expand Down
60 changes: 46 additions & 14 deletions apps/desktop/src/main/controllers/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,18 @@ export const addDraft: IpcController<'drafts:create'> = async (_event, req) => {
if (draft.origin === 'manual' && draft.source) {
throw new Error('drafts:create: origin=manual must not pass source');
}
// Kind constraints: a reply must target a parent comment and is always a manually-authored draft (no source);
// a comment (default) must anchor to a line. Enforced here so malformed drafts never reach disk.
if (draft.kind === 'reply') {
if (!draft.replyTo?.parentCommentId) {
throw new Error('drafts:create: kind=reply requires replyTo.parentCommentId');
}
if (draft.origin !== 'manual') {
throw new Error('drafts:create: kind=reply must be origin=manual');
}
} else if (!draft.anchor) {
throw new Error('drafts:create: a comment draft requires an anchor');
}
const created = await createDraft(await ctx.pr.storeForPr(localId), localId, draft);
ctx.broadcast('drafts:changed', { localId });
return created;
Expand Down Expand Up @@ -674,20 +686,40 @@ export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (_e
continue;
}
try {
// ReviewDraftAnchor → PrCommentAnchor: side maps conservatively new→added / old→removed;
// multi-line lands on endLine (the comment appears below the annotated range, not interrupting top-down reading). Hitting a context line
// makes Bitbucket return 400; the error is collected into results for the user to see.
const posted = await adapter.comments.publishInlineComment(
{ projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug },
pr.remoteId,
{
path: draft.anchor.path,
line: draft.anchor.endLine,
side: draft.anchor.side,
lineType: draft.anchor.side === 'old' ? 'removed' : 'added',
},
draft.body,
);
let posted: { remoteId: string };
if (draft.kind === 'reply') {
// Reply-draft: publish against the parent comment via the reply API (not a fresh inline comment). The parent
// already exists remotely, so ordering within the batch doesn't matter.
if (!draft.replyTo?.parentCommentId) {
results.push({ draftId, ok: false, error: 'reply draft missing replyTo.parentCommentId' });
continue;
}
posted = await adapter.comments.replyToComment(
{ projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug },
pr.remoteId,
draft.replyTo.parentCommentId,
draft.body,
);
} else {
if (!draft.anchor) {
results.push({ draftId, ok: false, error: 'comment draft missing anchor' });
continue;
}
// ReviewDraftAnchor → PrCommentAnchor: side maps conservatively new→added / old→removed;
// multi-line lands on endLine (the comment appears below the annotated range, not interrupting top-down reading). Hitting a context line
// makes Bitbucket return 400; the error is collected into results for the user to see.
posted = await adapter.comments.publishInlineComment(
{ projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug },
pr.remoteId,
{
path: draft.anchor.path,
line: draft.anchor.endLine,
side: draft.anchor.side,
lineType: draft.anchor.side === 'old' ? 'removed' : 'added',
},
draft.body,
);
}
// Successful publish = the local draft's mission is done, delete it directly (the remote comment is pulled back and takes over display via the force-refresh below).
await deleteDraft(store, req.localId, draftId);
anyPublished = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ export function PrPanel({
readOnly={readOnly}
onJumpToAnchor={(draftId) => {
const d = (drafts ?? []).find((x) => x.id === draftId);
if (!d) return;
// Reply-drafts with no anchor (reply to a summary comment) aren't line-navigable.
if (!d?.anchor) return;
onRequestDiffNav?.({
anchor: {
path: d.anchor.path,
Expand All @@ -272,7 +273,8 @@ export function PrPanel({
// Click anchor → close modal + turn into pendingDiffNav bubbled up to App. runId/findingId omitted →
// DiffView only navigates, doesn't enter edit (the user wants to see the code context, not necessarily edit the draft).
const d = (drafts ?? []).find((x) => x.id === draftId);
if (!d) return;
// Reply-drafts with no anchor (reply to a summary comment) aren't line-navigable.
if (!d?.anchor) return;
setPublishModalOpen(false);
onRequestDiffNav?.({
anchor: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
} from '../../../../common';
import { CommentEditEditor } from './CommentEditEditor';
import { CommentReplyEditor } from './CommentReplyEditor';
import { ReplyDraftList } from './ReplyDraftList';
import { CommentMarkdown } from '../shared/CommentMarkdown';
import { toReplyDraftAnchor } from '../shared/replyDraftAnchor';
import { ReactionAddButton, ReactionChips, useReactions } from '../shared/ReactionBar';
import { useCommentThread } from '../shared/useCommentThread';
// Inline code context uses Monaco; lazy-loaded and pulled on demand with the same Monaco chunk as DiffView, not in the entry bundle.
Expand Down Expand Up @@ -278,6 +280,7 @@ export function CommentItem({
prLocalId={pr.localId}
// Reply target abstraction (threadId): GitLab=discussion id (required for reply); Bitbucket empty / GitHub=remoteId → fall back to remoteId.
parentCommentId={comment.threadId ?? comment.remoteId}
parentAnchor={toReplyDraftAnchor(comment.anchor)}
mentionCandidates={mentionCandidates}
platform={pr.platform}
attachmentsEnabled={attachmentsEnabled}
Expand All @@ -287,6 +290,21 @@ export function CommentItem({
/>
) : null;

// Pending reply-drafts for this comment (self-fetching from the drafts store; renders null when there are none),
// shown at the tail of the thread as editable draft cards — the same deferred-draft model as a new inline comment.
const replyDraftsEl = (
<ReplyDraftList
prLocalId={pr.localId}
parentCommentId={comment.threadId ?? comment.remoteId}
hardBreaks={hardBreaks}
mentionCandidates={mentionCandidates}
platform={pr.platform}
attachmentsEnabled={attachmentsEnabled}
userSearchEnabled={userSearchEnabled}
readOnly={readOnly}
/>
);

const repliesEl =
comment.replies.length > 0 ? (
// Past MAX_REPLY_DEPTH levels, use pr-comments-flat instead of pr-comments-replies (no more indent / left border) →
Expand Down Expand Up @@ -358,8 +376,9 @@ export function CommentItem({
{foot}
{reactionChipsEl}
{deleteErrorEl}
{replyEditor}
{repliesEl}
{replyDraftsEl}
{replyEditor}
</div>
{confirmModalEl}
</li>
Expand Down Expand Up @@ -391,8 +410,9 @@ export function CommentItem({
{foot}
{reactionChipsEl}
{deleteErrorEl}
{replyEditor}
{repliesEl}
{replyDraftsEl}
{replyEditor}
{confirmModalEl}
</li>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PlatformKind, PlatformUser } from '@meebox/shared';
import type { PlatformKind, PlatformUser, ReviewDraftAnchor } from '@meebox/shared';
import { invoke } from '../../../../../api';
import { MentionTextarea } from '../shared/MentionTextarea';
import { searchMentionUsers } from '../shared/mentionSearch';
Expand All @@ -9,6 +9,11 @@ import { uploadCommentImage } from '../shared/uploadCommentImage';
interface CommentReplyEditorProps {
prLocalId: string;
parentCommentId: string;
/**
* Parent comment's anchor snapshot (present only for an inline/line comment; absent for a summary comment). Stored on
* the reply-draft so it can render as a diff zone at the parent's line where applicable; a summary-comment reply has none.
*/
parentAnchor?: ReviewDraftAnchor;
/** `@mention` autocomplete candidates (PR participants + comment authors). */
mentionCandidates?: PlatformUser[];
/** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */
Expand All @@ -18,17 +23,21 @@ interface CommentReplyEditorProps {
/** Whether the platform supports remote user search (capabilities.userSearch); enables the mention editor's remote fallback when true. */
userSearchEnabled?: boolean;
onCancel: () => void;
/** Called after a reply is created successfully (UI collapses the editor; the comment list auto-refreshes via the comments:changed event) */
/** Called after the reply draft is saved (UI collapses the editor; the pending reply-draft then renders below the comment). */
onPosted: () => void;
}

/**
* Manual reply editor for an existing comment: textarea + save/cancel. Expands below the comment being replied to.
* Cmd/Ctrl+Enter to save, Esc to cancel; empty body disables save
* Reply editor for an existing comment: textarea + save/cancel. Expands below the comment being replied to.
* "Save" creates a **reply draft** (deferred, published later via the review batch) rather than posting immediately —
* consistent with a new inline comment, so an unsubmitted reply survives switching PRs/files/tabs. Shared by every
* comment surface (activity page + inline diff), so the deferred behavior is identical everywhere.
* Cmd/Ctrl+Enter to save, Esc to cancel; empty body disables save.
*/
export function CommentReplyEditor({
prLocalId,
parentCommentId,
parentAnchor,
mentionCandidates = [],
platform,
attachmentsEnabled = false,
Expand All @@ -48,7 +57,19 @@ export function CommentReplyEditor({
setPosting(true);
setError(null);
try {
await invoke('comments:reply', { localId: prLocalId, parentCommentId, body });
// Create a pending reply-draft instead of posting immediately: it joins the draft pool, renders below the parent
// comment, and publishes via the reply API in the "Publish comments" batch.
await invoke('drafts:create', {
localId: prLocalId,
draft: {
kind: 'reply',
replyTo: { parentCommentId },
...(parentAnchor ? { anchor: parentAnchor } : {}),
body,
origin: 'manual',
status: 'pending',
},
});
onPosted();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
Expand Down Expand Up @@ -93,9 +114,11 @@ export function CommentReplyEditor({
className="comment-reply-btn comment-reply-btn-primary"
onClick={() => void handleSave()}
disabled={!canSave}
title={canSave ? t('commentReplyEditor.sendTitle') : t('commentReplyEditor.emptyTitle')}
title={
canSave ? t('commentReplyEditor.saveDraftTitle') : t('commentReplyEditor.emptyTitle')
}
>
{posting ? t('commentReplyEditor.sending') : t('commentReplyEditor.send')}
{posting ? t('commentReplyEditor.savingDraft') : t('commentReplyEditor.saveDraft')}
</button>
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { PlatformKind, PlatformUser } from '@meebox/shared';
import { invoke } from '../../../../../api';
import { formatBackendError } from '../../../../../errors';
import { useDraftsForPr } from '../../../../../stores/drafts-store';
import { DraftZone } from '../drafts/DraftZone';

/**
* Pending reply-drafts for one parent comment, rendered below it as editable draft cards (reusing {@link DraftZone}).
* Shared by every comment surface — the activity timeline's `CommentItem` and the inline diff `CommentNode` — so a
* reply behaves the same everywhere: it is a deferred draft (persisted, survives switching) that publishes via the
* reply API in the "Publish comments" batch, mirroring how a new inline comment is a draft.
*
* Reads the shared drafts store and filters to this parent's reply-drafts; `rejected` / `posted` are not shown
* (rejected = the user dropped it; posted = the remote reply already took over and is fetched as a normal comment).
*/
export function ReplyDraftList({
prLocalId,
parentCommentId,
hardBreaks,
mentionCandidates,
platform,
attachmentsEnabled = false,
userSearchEnabled = false,
readOnly = false,
}: {
prLocalId: string;
/** Reply target id — the same value used as the reply's parent (comment.threadId ?? comment.remoteId). */
parentCommentId: string;
hardBreaks: boolean;
mentionCandidates?: PlatformUser[];
platform?: PlatformKind;
attachmentsEnabled?: boolean;
userSearchEnabled?: boolean;
/** Content read-only (declined / non-participable archived PR): don't render draft editors. */
readOnly?: boolean;
}) {
const drafts = useDraftsForPr(prLocalId);
if (readOnly) return null;
const replyDrafts = (drafts ?? []).filter(
(d) =>
d.kind === 'reply' &&
d.replyTo?.parentCommentId === parentCommentId &&
d.status !== 'rejected' &&
d.status !== 'posted',
);
if (replyDrafts.length === 0) return null;

const onSave = async (draftId: string, body: string): Promise<void> => {
await invoke('drafts:update', { localId: prLocalId, draftId, patch: { body } });
};
const onDelete = async (draftId: string): Promise<void> => {
await invoke('drafts:delete', { localId: prLocalId, draftId });
};
// Single publish reuses drafts:publishBatch with one id (same main-side path as the batch modal / DraftZoneList),
// so the reply-publish branch stays the single source of truth.
const onPublish = async (draftId: string): Promise<{ ok: boolean; error?: string }> => {
const resp = await invoke('drafts:publishBatch', { localId: prLocalId, draftIds: [draftId] });
const r = resp.results[0];
if (!r) return { ok: false, error: 'no result' };
return { ok: r.ok, error: r.error ? formatBackendError(r.error).title : undefined };
};

return (
<div className="reply-draft-list">
{replyDrafts.map((d) => (
<DraftZone
key={d.id}
draft={d}
prLocalId={prLocalId}
attachmentsEnabled={attachmentsEnabled}
hardBreaks={hardBreaks}
mentionCandidates={mentionCandidates}
platform={platform}
userSearchEnabled={userSearchEnabled}
onSave={(body) => onSave(d.id, body)}
onDelete={() => onDelete(d.id)}
onPublish={() => onPublish(d.id)}
/>
))}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export function DiffView({
const publishable = drafts.filter((d) => d.status === 'pending' || d.status === 'edited');
for (const f of files) {
const n = publishable.filter(
(d) => d.anchor.path === f.path || (f.oldPath && d.anchor.path === f.oldPath),
(d) => d.anchor && (d.anchor.path === f.path || (f.oldPath && d.anchor.path === f.oldPath)),
).length;
if (n > 0) m.set(f.path, n);
}
Expand Down
Loading
Loading