diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3551d7f6..00ae3839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md
index b6267c69..36bb5012 100644
--- a/CHANGELOG.zh-CN.md
+++ b/CHANGELOG.zh-CN.md
@@ -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
diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts
index ba170ae2..5ebc6452 100644
--- a/apps/desktop/src/main/controllers/pr.ts
+++ b/apps/desktop/src/main/controllers/pr.ts
@@ -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;
@@ -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;
diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
index 6b69f74b..4e58b7e5 100644
--- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
+++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
@@ -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,
@@ -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: {
diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx
index 3ffcb5d2..858a436e 100644
--- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx
+++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx
@@ -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.
@@ -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}
@@ -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 = (
+
+ );
+
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) →
@@ -358,8 +376,9 @@ export function CommentItem({
{foot}
{reactionChipsEl}
{deleteErrorEl}
- {replyEditor}
{repliesEl}
+ {replyDraftsEl}
+ {replyEditor}
{confirmModalEl}
@@ -391,8 +410,9 @@ export function CommentItem({
{foot}
{reactionChipsEl}
{deleteErrorEl}
- {replyEditor}
{repliesEl}
+ {replyDraftsEl}
+ {replyEditor}
{confirmModalEl}
);
diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx
index 46bd9fda..b3f89c81 100644
--- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx
+++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx
@@ -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';
@@ -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). */
@@ -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,
@@ -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));
@@ -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')}