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')} + {/* Reply-drafts carry a small "reply" tag; when anchored to an inline comment they still show the + parent's file:line (jump lands on the parent's line), otherwise (reply to a summary comment) just the tag. */} + {isReply && ( + {t('draftsPanel.replyTag')} + )} + {anchor ? ( + onJumpToAnchor ? ( + + ) : ( + + {anchor.path}:{lineLabel} + · {sideLabel} + + ) ) : ( - - {d.anchor.path}:{lineLabel} - · {sideLabel} - + + {t('draftsPanel.replyToComment')} + )} {t(STATUS_LABEL_KEY[d.status])} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx index 2fab76ba..0fccd999 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx @@ -153,14 +153,18 @@ export function PublishReviewModal({