From 6be1c603226bc1c7c0c609665fd3b97ff942502c Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 14 Jul 2026 10:25:14 +0800 Subject: [PATCH 1/2] fix(diff): preserve in-progress inline comment across comment refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline comment view zones were fully torn down and rebuilt on every comments:changed (a reply/edit/delete anywhere, or the poller pulling a new remote comment). Each rebuild unmounted the zones' React roots, so an open inline reply/edit lost its half-typed text — unlike the activity timeline, which survives via keyed reconciliation. Give the inline zones the same "reconcile in place" behaviour: refactor mountInlineZones into a persistent controller (createInlineZones → { update, dispose }) that diffs zones by a stable (side, line) key — unchanged keys re-render their existing root (CommentZone keys children by remoteId, so an open editor keeps its state), only added/removed lines mount/unmount. useCommentZones splits into a structural effect (owns the controller, recreated only on editor/file/view change) and a content effect (calls controller.update on comments/props change + rebuilds the stateless glyph decorations). The one-shot mountInlineZones wrapper is kept byte-compatible, so the draft zones (useDraftZones) are unchanged. The activity timeline already handled this and needs no change. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + CHANGELOG.zh-CN.md | 4 + .../pr/tabs/diff/hooks/useCommentZones.tsx | 96 ++-- .../pr/tabs/diff/zones/mountInlineZones.ts | 490 ++++++++++++------ 4 files changed, 387 insertions(+), 207 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3551d7f6..1a8cf1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and the versioning follows [Semantic Versioning](https://semver.org/). - 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. +### 🔧 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 > Highlights of this release: diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index b6267c69..0483fda9 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -13,6 +13,10 @@ - 现可对整个文件(而不仅是某一行)添加评论(平台支持时,Bitbucket / GitHub):diff 中新增「对文件评论」入口,且已存在的文件级评论现能正确归属显示,不再被当作 PR 通用评论。 - 评审现会读取被审仓库自身的规范文件(`AGENTS.md`)作为项目上下文,使评审、描述与建议遵循该项目既定的约定。 +### 🔧 修复 + +- 在 diff 中正在编写的内联评论回复 / 编辑,不再因评论列表刷新(如轮询在你输入时拉到新的远端评论)而被丢弃——打开的编辑器会保留已输入的内容。 + ## [0.11.0] - 2026-07-07 > 本次发布要点: diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx index bf3cfbad..d191c8fa 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { editor as MonacoEditorNs, type editor as MonacoEditor } from 'monaco-editor'; import type { PlatformKind, PlatformUser, PrComment } from '@meebox/shared'; import type { DiffChangedFile } from '@meebox/ipc'; @@ -7,13 +7,19 @@ import { estimateZoneHeight, renderHoverMd, } from '../inline-comments/InlineCommentZone'; -import { mountInlineZones } from '../zones/mountInlineZones'; +import { createInlineZones, type InlineZonesController } from '../zones/mountInlineZones'; import type { LoadedContent } from '../diff-types'; /** * Inline comment markers: a blue dot in the glyph margin on the comment's anchored line (hover shows a - * markdown summary) + a view zone inserted below the line rendering the comment content. Zone mount / - * cleanup goes through the shared mountInlineZones; glyph decorations are managed by this hook itself. + * markdown summary) + a view zone inserted below the line rendering the comment content. + * + * Split into two effects deliberately. A **structural** effect owns the zone controller's lifecycle (recreated only + * when the editor / file / view orientation changes); a **content** effect calls `controller.update(...)` whenever + * the comments or passthrough props change, which **reconciles** zones in place rather than tearing them down. This + * is what lets an in-progress inline reply / edit survive a comments refresh (e.g. the poller pulling a new remote + * comment mid-typing): the anchored line's zone keeps its React root, so the open editor's text isn't discarded. + * Glyph decorations are stateless and simply recreated by the content effect. */ export function useCommentZones(opts: { diffEditor: MonacoEditor.IStandaloneDiffEditor | null; @@ -58,8 +64,33 @@ export function useCommentZones(opts: { readOnly = false, } = opts; + // Structural lifecycle: (re)create the zone controller only when the editor / file / view orientation changes. + // A comments refresh does NOT touch these deps, so the controller (and its live zones) survives — the content + // effect below then reconciles into it instead of tearing everything down. + const controllerRef = useRef | null>(null); useEffect(() => { if (!diffEditor || !content || !selected) return; + const controller = createInlineZones({ + diffEditor, + renderSideBySide, + zoneClassName: 'monaco-comment-zone', + innerClassName: 'monaco-comment-zone-inner', + // Don't intercept wheel — comment zones auto-size with no inner scroll, so the wheel must bubble to Monaco to + // scroll the editor, otherwise the whole diff can't scroll while hovering a comment (stopPropagation would eat the scroll). + stopEvents: ['mousedown', 'mouseup', 'click', 'dblclick'], + }); + controllerRef.current = controller; + return () => { + controller.dispose(); + controllerRef.current = null; + }; + }, [diffEditor, content, selected, renderSideBySide]); + + // Content sync: reconcile zones + rebuild glyph decorations whenever comments / passthrough props change. Declared + // after the structural effect so on mount the controller exists before this runs (React runs setup in order). + useEffect(() => { + const controller = controllerRef.current; + if (!controller || !diffEditor || !content || !selected) return; const fileComments = comments.filter( (c) => c.anchor && @@ -80,6 +111,32 @@ export function useCommentZones(opts: { target.set(line, arr); } + // Reconcile the zones: unchanged anchored lines re-render in place (an open reply/edit editor keeps its text), + // only genuinely added/removed lines mount/unmount. + controller.update({ + oldByLine, + newByLine, + initialHeight: (cs, lineHeight) => + Math.max(estimateZoneHeight(cs) * lineHeight, lineHeight * 3), + render: (cs) => ( + + ), + }); + + // Glyph-margin dots + overview-ruler ticks are stateless → just recreate them to match the current comments. const buildDecorations = ( byLine: Map, ): MonacoEditor.IModelDeltaDecoration[] => @@ -108,36 +165,6 @@ export function useCommentZones(opts: { buildDecorations(newByLine), ); - const cleanupZones = mountInlineZones({ - diffEditor, - renderSideBySide, - oldByLine, - newByLine, - zoneClassName: 'monaco-comment-zone', - innerClassName: 'monaco-comment-zone-inner', - // Don't intercept wheel — comment zones auto-size with no inner scroll, so the wheel must bubble to Monaco to - // scroll the editor, otherwise the whole diff can't scroll while hovering a comment (stopPropagation would eat the scroll). - stopEvents: ['mousedown', 'mouseup', 'click', 'dblclick'], - initialHeight: (cs, lineHeight) => - Math.max(estimateZoneHeight(cs) * lineHeight, lineHeight * 3), - render: (cs) => ( - - ), - }); - return () => { try { originalDecorations.clear(); @@ -145,7 +172,6 @@ export function useCommentZones(opts: { } catch { // editor already disposed } - cleanupZones(); }; }, [ diffEditor, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts index e2173d49..717ee8c9 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts @@ -10,8 +10,15 @@ import { remapOldByLineToModified } from './line-mapping'; * `removeZone` / `unmount` cleanup. The differences (which events to intercept, initial height estimation, what component * to render) are injected via options. * - * Returns a cleanup function (called in the effect's teardown). The comment zone's extra glyph decorations are not managed - * here (the caller useCommentZones creates / clears them itself). + * Two entry points sharing the same internals: + * - {@link createInlineZones} returns a persistent controller whose `update(content)` **reconciles** zones by + * `(side, line)` key: an unchanged key re-renders its existing React root in place (preserving the zone's React + * state, e.g. an in-progress inline reply/edit that must survive a comments refresh / poll), a new key mounts a + * fresh zone, a vanished key is removed. Use this when the zone content changes independently of the editor/file. + * - {@link mountInlineZones} is the original one-shot form (create + populate once, teardown on cleanup), kept for + * callers that rebuild their whole zone set per effect run (the draft zones). + * + * The comment zone's extra glyph decorations are not managed here (the caller useCommentZones creates / clears them itself). */ export interface MountInlineZonesOptions { diffEditor: MonacoEditor.IStandaloneDiffEditor; @@ -32,206 +39,321 @@ export interface MountInlineZonesOptions { render: (items: T[]) => ReactNode; } +/** Structural options for {@link createInlineZones}: everything that identifies where/how zones mount, minus the content. */ +export interface CreateInlineZonesOptions { + diffEditor: MonacoEditor.IStandaloneDiffEditor; + renderSideBySide: boolean; + zoneClassName: string; + innerClassName: string; + stopEvents: readonly string[]; +} + +/** Per-`update` content: the line buckets + how to size and render each zone. */ +export interface InlineZonesContent { + oldByLine: Map; + newByLine: Map; + initialHeight: (items: T[], lineHeight: number) => number; + render: (items: T[]) => ReactNode; +} + +export interface InlineZonesController { + /** Reconcile the mounted zones to match `content`, preserving the React root (and its state) of any unchanged key. */ + update(content: InlineZonesContent): void; + /** Remove every zone and unmount its root. */ + dispose(): void; +} + interface ZoneRef { + /** Stable reconciliation key: `new:` or `old:` (side-by-side old line, or the remapped modified line in unified). */ + key: string; editor: MonacoEditor.ICodeEditor; zoneId: string; + /** The afterLineNumber this zone currently sits at; a change means the anchor moved → remove + re-add rather than re-render in place. */ + afterLine: number; root: Root; disposers: Array<() => void>; } -export function mountInlineZones(opts: MountInlineZonesOptions): () => void { - const { - diffEditor, - renderSideBySide, - oldByLine, - newByLine, - zoneClassName, - innerClassName, - stopEvents, - initialHeight, - render, - } = opts; - +export function createInlineZones(opts: CreateInlineZonesOptions): InlineZonesController { + const { diffEditor, renderSideBySide, zoneClassName, innerClassName, stopEvents } = opts; const originalEditor = diffEditor.getOriginalEditor(); const modifiedEditor = diffEditor.getModifiedEditor(); - const zoneRefs: ZoneRef[] = []; + // Live registry keyed by the stable `(side, line)` key; persists across update() calls so unchanged zones keep their root. + const zones = new Map(); - const addZonesFor = (editorInst: MonacoEditor.ICodeEditor, byLine: Map): void => { + // Create ONE zone. **Must be called inside editorInst.changeViewZones(accessor => ...)**, so the caller batches + // adds/removes. Registers the resulting ZoneRef into `zones` under `key`. + const createZone = ( + accessor: MonacoEditor.IViewZoneChangeAccessor, + editorInst: MonacoEditor.ICodeEditor, + key: string, + afterLine: number, + items: T[], + content: InlineZonesContent, + ): void => { + const { render, initialHeight } = content; const lineHeight = editorInst.getOption(MonacoEditorNs.EditorOption.lineHeight); - editorInst.changeViewZones((accessor) => { - for (const [line, items] of byLine) { - // Two-layer structure: dom is the monaco wrapper (monaco writes height inline directly onto it), - // inner is the real visual container not controlled by monaco → inner.offsetHeight is the true content height. - const dom = document.createElement('div'); - dom.className = zoneClassName; - - // Classic Monaco view zone pitfall: the editor's built-in mousedown listener treats the whole zone area as - // an "editor mouse target" and swallows events bubbling to the DOM → the zone's textarea gets no focus, buttons - // don't respond to clicks. stopPropagation a set of key events on the dom container so monaco no longer takes over - // user input within the zone. **Must be the bubble phase** (third arg omitted / false): intercepting in the capture - // phase would block before the event reaches button/textarea, so React onClick / onKeyDown never fire. The bubble - // phase lets the target's React handler fire first, then stops the bubble to the editor. - const stopAll = (e: Event): void => e.stopPropagation(); - for (const evt of stopEvents) { - dom.addEventListener(evt, stopAll); - } + // Two-layer structure: dom is the monaco wrapper (monaco writes height inline directly onto it), + // inner is the real visual container not controlled by monaco → inner.offsetHeight is the true content height. + const dom = document.createElement('div'); + dom.className = zoneClassName; + + // Classic Monaco view zone pitfall: the editor's built-in mousedown listener treats the whole zone area as + // an "editor mouse target" and swallows events bubbling to the DOM → the zone's textarea gets no focus, buttons + // don't respond to clicks. stopPropagation a set of key events on the dom container so monaco no longer takes over + // user input within the zone. **Must be the bubble phase** (third arg omitted / false): intercepting in the capture + // phase would block before the event reaches button/textarea, so React onClick / onKeyDown never fire. The bubble + // phase lets the target's React handler fire first, then stops the bubble to the editor. + const stopAll = (e: Event): void => e.stopPropagation(); + for (const evt of stopEvents) { + dom.addEventListener(evt, stopAll); + } - const inner = document.createElement('div'); - inner.className = innerClassName; - dom.appendChild(inner); - - const root = createRoot(inner); - root.render(render(items)); - - const initialPx = initialHeight(items, lineHeight); - const zoneObj: MonacoEditor.IViewZone = { - afterLineNumber: line, - heightInPx: initialPx, - domNode: dom, - }; - const zoneId = accessor.addZone(zoneObj); - - // Height sync: directly mutate zoneObj.heightInPx + layoutZone(id). removeZone+addZone called every frame - // during textarea drag-resize causes zone-rebuild jitter. layoutZone is a lightweight operation; mutate - // heightInPx first then layoutZone to let monaco recompute the viewModel whitespace. - // Measure with inner.offsetHeight (dom's height is hard-set by monaco, so offsetHeight would self-loop). - const syncHeight = (): void => { - const next = inner.offsetHeight; - if (next <= 0) return; - if (Math.abs(next - (zoneObj.heightInPx ?? 0)) < 1) return; - zoneObj.heightInPx = next; - try { - editorInst.changeViewZones((acc) => { - acc.layoutZone(zoneId); - }); - } catch { - /* editor disposed */ - } - }; - // ResizeObserver tracks inner height changes (read↔edit switch, textarea resize, async loading of embedded images, - // nested comment expansion). requestAnimationFrame avoids the "sync layout in the callback re-triggers RO" loop. - const ro = new ResizeObserver(() => { - requestAnimationFrame(syncHeight); + const inner = document.createElement('div'); + inner.className = innerClassName; + dom.appendChild(inner); + + const root = createRoot(inner); + root.render(render(items)); + + const initialPx = initialHeight(items, lineHeight); + const zoneObj: MonacoEditor.IViewZone = { + afterLineNumber: afterLine, + heightInPx: initialPx, + domNode: dom, + }; + const zoneId = accessor.addZone(zoneObj); + + // Height sync: directly mutate zoneObj.heightInPx + layoutZone(id). removeZone+addZone called every frame + // during textarea drag-resize causes zone-rebuild jitter. layoutZone is a lightweight operation; mutate + // heightInPx first then layoutZone to let monaco recompute the viewModel whitespace. + // Measure with inner.offsetHeight (dom's height is hard-set by monaco, so offsetHeight would self-loop). + const syncHeight = (): void => { + const next = inner.offsetHeight; + if (next <= 0) return; + if (Math.abs(next - (zoneObj.heightInPx ?? 0)) < 1) return; + zoneObj.heightInPx = next; + try { + editorInst.changeViewZones((acc) => { + acc.layoutZone(zoneId); }); - ro.observe(inner); - // Sync at multiple points as a fallback covering layout jitter / React multi-phase render - requestAnimationFrame(syncHeight); - setTimeout(syncHeight, 50); - setTimeout(syncHeight, 200); - - // Width strategy: derive the inner width from Monaco's own layout info, not from getBoundingClientRect. The - // editor's DOM rect is unreliable during init / a file switch (it can report a transient too-wide box until a - // manual resize forces a remeasure), whereas getLayoutInfo() is Monaco's authoritative post-layout geometry and - // is correct as soon as the editor has laid out. The zone dom sits at the content origin (after the gutter) and - // is pinned in the viewport via translateX(scrollLeft), so the inner spans the content area minus the right - // scrollbar: width = layoutInfo.width - contentLeft - verticalScrollbarWidth. - const editorDomNode = editorInst.getDomNode(); - // Returns the width applied to inner (px), or -1 when the editor isn't laid out yet (nothing applied). - const applyInnerLayout = (): number => { - const li = editorInst.getLayoutInfo(); - const w = li.width - li.contentLeft - (li.verticalScrollbarWidth ?? 0); - if (w <= 0) return -1; // editor not laid out yet, wait for the next trigger - inner.style.marginLeft = '0'; - inner.style.width = `${w}px`; - inner.style.maxWidth = `${w}px`; - return w; - }; - // Settle loop instead of fixed one-shot timers: on a file switch / first Monaco init the editor geometry - // stabilizes at an unpredictable time (font measurement, async diff compute, hideUnchangedRegions collapse, - // loading-overlay removal). Fixed timers can all fire before the final layout, leaving the box measured too - // wide (spilling into the chat pane) until a manual resize. Re-apply on animation frames until the width - // repeats across two consecutive frames (stable) or a generous cap elapses; the permanent observers below - // then handle any later change. rAF (not setTimeout) so measurement reads a painted layout. - let settleRaf = 0; - let settleStart = -1; - let prevWidth = -1; - const settle = (now: number): void => { - if (settleStart < 0) settleStart = now; - const w = applyInnerLayout(); - // Stable: a positive width unchanged from the previous frame. Keep going while it's still moving or not - // yet laid out, but never past the cap (covers a layout that legitimately never fully settles). - if ((w > 0 && w === prevWidth) || now - settleStart > 1500) { - settleRaf = 0; - return; - } - prevWidth = w; - settleRaf = requestAnimationFrame(settle); - }; - applyInnerLayout(); // synchronous first apply avoids a one-frame flash at full width - settleRaf = requestAnimationFrame(settle); - // Dual trigger: onDidLayoutChange (geometry change) + ResizeObserver watching the editor DOM (window / splitter - // resize), non-overlapping coverage; onDidUpdateDiff (after a file switch the layout still changes while the diff is computed, stabilizing only once done). - const layoutDisp = editorInst.onDidLayoutChange(applyInnerLayout); - const diffDisp = diffEditor.onDidUpdateDiff(() => requestAnimationFrame(applyInnerLayout)); - const editorRO = editorDomNode - ? new ResizeObserver(() => requestAnimationFrame(applyInnerLayout)) - : null; - if (editorDomNode && editorRO) editorRO.observe(editorDomNode); - - // Horizontal-scroll sync: the monaco view zone dom inside .lines-content shifts left along with scrollLeft - // (after horizontal scroll the box gets clipped out of the viewport). Adding transform translateX(scrollLeft) - // to inner cancels it out, so the box sticks at its relative position within the viewport (consistent with Bitbucket / GitHub inline comments). - const applyScroll = (): void => { - inner.style.transform = `translateX(${editorInst.getScrollLeft()}px)`; - }; - applyScroll(); - const scrollDisp = editorInst.onDidScrollChange(applyScroll); - - // Also stopPropagation on inner (two-layer defense). **Must be after createRoot** — otherwise React 18's event - // delegation initialization order on inner is affected, causing onClick not to fire (clicking the cancel button does nothing). - for (const evt of stopEvents) { - inner.addEventListener(evt, stopAll); + } catch { + /* editor disposed */ + } + }; + // ResizeObserver tracks inner height changes (read↔edit switch, textarea resize, async loading of embedded images, + // nested comment expansion). requestAnimationFrame avoids the "sync layout in the callback re-triggers RO" loop. + const ro = new ResizeObserver(() => { + requestAnimationFrame(syncHeight); + }); + ro.observe(inner); + // Sync at multiple points as a fallback covering layout jitter / React multi-phase render + requestAnimationFrame(syncHeight); + setTimeout(syncHeight, 50); + setTimeout(syncHeight, 200); + + // Width strategy: derive the inner width from Monaco's own layout info, not from getBoundingClientRect. The + // editor's DOM rect is unreliable during init / a file switch (it can report a transient too-wide box until a + // manual resize forces a remeasure), whereas getLayoutInfo() is Monaco's authoritative post-layout geometry and + // is correct as soon as the editor has laid out. The zone dom sits at the content origin (after the gutter) and + // is pinned in the viewport via translateX(scrollLeft), so the inner spans the content area minus the right + // scrollbar: width = layoutInfo.width - contentLeft - verticalScrollbarWidth. + const editorDomNode = editorInst.getDomNode(); + // Returns the width applied to inner (px), or -1 when the editor isn't laid out yet (nothing applied). + const applyInnerLayout = (): number => { + const li = editorInst.getLayoutInfo(); + const w = li.width - li.contentLeft - (li.verticalScrollbarWidth ?? 0); + if (w <= 0) return -1; // editor not laid out yet, wait for the next trigger + inner.style.marginLeft = '0'; + inner.style.width = `${w}px`; + inner.style.maxWidth = `${w}px`; + return w; + }; + // Settle loop instead of fixed one-shot timers: on a file switch / first Monaco init the editor geometry + // stabilizes at an unpredictable time (font measurement, async diff compute, hideUnchangedRegions collapse, + // loading-overlay removal). Fixed timers can all fire before the final layout, leaving the box measured too + // wide (spilling into the chat pane) until a manual resize. Re-apply on animation frames until the width + // repeats across two consecutive frames (stable) or a generous cap elapses; the permanent observers below + // then handle any later change. rAF (not setTimeout) so measurement reads a painted layout. + let settleRaf = 0; + let settleStart = -1; + let prevWidth = -1; + const settle = (now: number): void => { + if (settleStart < 0) settleStart = now; + const w = applyInnerLayout(); + // Stable: a positive width unchanged from the previous frame. Keep going while it's still moving or not + // yet laid out, but never past the cap (covers a layout that legitimately never fully settles). + if ((w > 0 && w === prevWidth) || now - settleStart > 1500) { + settleRaf = 0; + return; + } + prevWidth = w; + settleRaf = requestAnimationFrame(settle); + }; + applyInnerLayout(); // synchronous first apply avoids a one-frame flash at full width + settleRaf = requestAnimationFrame(settle); + // Dual trigger: onDidLayoutChange (geometry change) + ResizeObserver watching the editor DOM (window / splitter + // resize), non-overlapping coverage; onDidUpdateDiff (after a file switch the layout still changes while the diff is computed, stabilizing only once done). + const layoutDisp = editorInst.onDidLayoutChange(applyInnerLayout); + const diffDisp = diffEditor.onDidUpdateDiff(() => requestAnimationFrame(applyInnerLayout)); + const editorRO = editorDomNode + ? new ResizeObserver(() => requestAnimationFrame(applyInnerLayout)) + : null; + if (editorDomNode && editorRO) editorRO.observe(editorDomNode); + + // Horizontal-scroll sync: the monaco view zone dom inside .lines-content shifts left along with scrollLeft + // (after horizontal scroll the box gets clipped out of the viewport). Adding transform translateX(scrollLeft) + // to inner cancels it out, so the box sticks at its relative position within the viewport (consistent with Bitbucket / GitHub inline comments). + const applyScroll = (): void => { + inner.style.transform = `translateX(${editorInst.getScrollLeft()}px)`; + }; + applyScroll(); + const scrollDisp = editorInst.onDidScrollChange(applyScroll); + + // Also stopPropagation on inner (two-layer defense). **Must be after createRoot** — otherwise React 18's event + // delegation initialization order on inner is affected, causing onClick not to fire (clicking the cancel button does nothing). + for (const evt of stopEvents) { + inner.addEventListener(evt, stopAll); + } + + zones.set(key, { + key, + editor: editorInst, + zoneId, + afterLine, + root, + // Disconnect ResizeObserver + dispose listeners first, then unmount root, to avoid the DOM height collapse + // caused by unmount triggering the observer callback + a layoutZone(disposed editor) error. + disposers: [ + () => ro.disconnect(), + () => layoutDisp.dispose(), + () => diffDisp.dispose(), + () => editorRO?.disconnect(), + () => scrollDisp.dispose(), + () => { + if (settleRaf) cancelAnimationFrame(settleRaf); + }, + ], + }); + }; + + // Dispose one zone's observers/listeners now, then unmount its root on a microtask (React 18+ forbids a synchronous + // unmount during render; deferring also lets the removeZone above settle first). + const disposeZone = (ref: ZoneRef): void => { + for (const dispose of ref.disposers) { + try { + dispose(); + } catch { + /* ignore */ + } + } + queueMicrotask(() => { + try { + ref.root.unmount(); + } catch { + /* ignore */ + } + }); + }; + + // Compute the desired zone set (key → placement) for `content`, mirroring the original mount's side routing: + // new-side on the modified editor; old-side on the original editor (side-by-side) or remapped onto the modified + // editor (unified, where the original editor is hidden). + const computeDesired = ( + content: InlineZonesContent, + ): Map => { + const desired = new Map< + string, + { editor: MonacoEditor.ICodeEditor; afterLine: number; items: T[] } + >(); + for (const [line, items] of content.newByLine) { + desired.set(`new:${line}`, { editor: modifiedEditor, afterLine: line, items }); + } + if (renderSideBySide) { + for (const [line, items] of content.oldByLine) { + desired.set(`old:${line}`, { editor: originalEditor, afterLine: line, items }); + } + } else if (content.oldByLine.size > 0) { + const remapped = remapOldByLineToModified(diffEditor.getLineChanges() ?? [], content.oldByLine); + for (const [line, items] of remapped) { + desired.set(`old:${line}`, { editor: modifiedEditor, afterLine: line, items }); + } + } + return desired; + }; + + const update = (content: InlineZonesContent): void => { + const desired = computeDesired(content); + + // Removals: a key that vanished, or whose editor/anchor line moved (the latter can't be re-rendered in place → + // drop and re-add below). Batch removeZone per editor inside changeViewZones, then dispose each ref. + const toRemove: ZoneRef[] = []; + for (const ref of zones.values()) { + const d = desired.get(ref.key); + if (!d || d.editor !== ref.editor || d.afterLine !== ref.afterLine) toRemove.push(ref); + } + if (toRemove.length > 0) { + for (const editorInst of [originalEditor, modifiedEditor]) { + const rs = toRemove.filter((r) => r.editor === editorInst); + if (rs.length === 0) continue; + try { + editorInst.changeViewZones((acc) => { + for (const r of rs) acc.removeZone(r.zoneId); + }); + } catch { + /* editor disposed */ } + } + for (const r of toRemove) { + zones.delete(r.key); + disposeZone(r); + } + } - zoneRefs.push({ - editor: editorInst, - zoneId, - root, - // Disconnect ResizeObserver + dispose listeners first, then unmount root, to avoid the DOM height collapse - // caused by unmount triggering the observer callback + a layoutZone(disposed editor) error. - disposers: [ - () => ro.disconnect(), - () => layoutDisp.dispose(), - () => diffDisp.dispose(), - () => editorRO?.disconnect(), - () => scrollDisp.dispose(), - () => { - if (settleRaf) cancelAnimationFrame(settleRaf); - }, - ], + // Re-renders (in place, preserving React state) for surviving keys; collect brand-new keys to add. + const toAdd: Array<{ + editor: MonacoEditor.ICodeEditor; + key: string; + afterLine: number; + items: T[]; + }> = []; + for (const [key, d] of desired) { + const existing = zones.get(key); + if (existing) { + existing.root.render(content.render(d.items)); + } else { + toAdd.push({ editor: d.editor, key, afterLine: d.afterLine, items: d.items }); + } + } + if (toAdd.length > 0) { + for (const editorInst of [originalEditor, modifiedEditor]) { + const as = toAdd.filter((a) => a.editor === editorInst); + if (as.length === 0) continue; + editorInst.changeViewZones((acc) => { + for (const a of as) createZone(acc, editorInst, a.key, a.afterLine, a.items, content); }); } - }); + } }; - // Side-by-side view: old side mounts on the original editor; unified view: the original editor is hidden, old side - // mounts on the modified editor's corresponding line (deleted lines are modified's view zone in the unified view, mapping the original line number to modified afterLineNumber per diff line change). - if (renderSideBySide) { - addZonesFor(originalEditor, oldByLine); - } else if (oldByLine.size > 0) { - addZonesFor( - modifiedEditor, - remapOldByLineToModified(diffEditor.getLineChanges() ?? [], oldByLine), - ); - } - addZonesFor(modifiedEditor, newByLine); - - return () => { + const dispose = (): void => { + const all = [...zones.values()]; + zones.clear(); try { originalEditor.changeViewZones((accessor) => { - for (const z of zoneRefs) { + for (const z of all) { if (z.editor === originalEditor) accessor.removeZone(z.zoneId); } }); modifiedEditor.changeViewZones((accessor) => { - for (const z of zoneRefs) { + for (const z of all) { if (z.editor === modifiedEditor) accessor.removeZone(z.zoneId); } }); } catch { /* editor disposed */ } - for (const z of zoneRefs) { + for (const z of all) { for (const dispose of z.disposers) { try { dispose(); @@ -242,7 +364,7 @@ export function mountInlineZones(opts: MountInlineZonesOptions): () => voi } // React 18+: unmount can't be called synchronously in the render phase, defer it to a microtask queueMicrotask(() => { - for (const z of zoneRefs) { + for (const z of all) { try { z.root.unmount(); } catch { @@ -251,4 +373,28 @@ export function mountInlineZones(opts: MountInlineZonesOptions): () => voi } }); }; + + return { update, dispose }; +} + +/** + * One-shot form (create + populate once, teardown on cleanup). Behaviourally identical to the pre-controller + * mechanism, kept for callers (the draft zones) that rebuild their entire zone set on each effect run. Returns a + * cleanup function to call in the effect's teardown. + */ +export function mountInlineZones(opts: MountInlineZonesOptions): () => void { + const controller = createInlineZones({ + diffEditor: opts.diffEditor, + renderSideBySide: opts.renderSideBySide, + zoneClassName: opts.zoneClassName, + innerClassName: opts.innerClassName, + stopEvents: opts.stopEvents, + }); + controller.update({ + oldByLine: opts.oldByLine, + newByLine: opts.newByLine, + initialHeight: opts.initialHeight, + render: opts.render, + }); + return () => controller.dispose(); } From f114074a4667ce2b4c0426983e7504e7006b6a31 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 14 Jul 2026 14:04:30 +0800 Subject: [PATCH 2/2] feat(review): make comment replies deferred drafts, consistent with new comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a new inline comment records a persisted draft, but replying to a comment posted immediately with no draft — so an unsubmitted reply was lost on switching PRs/files/tabs, and the two authoring flows were inconsistent. Unify replies onto the same draft model: - ReviewDraft gains kind ('comment' | 'reply', default 'comment' for back-compat) + replyTo { parentCommentId, threadId? }; anchor becomes optional (a reply snapshots its inline parent's anchor, or has none when replying to a summary comment). drafts:create validates the kind constraints; publishDraftBatch branches reply -> comments.replyToComment vs comment -> publishInlineComment. - The shared CommentReplyEditor now creates a pending reply-draft instead of posting immediately, so every surface (activity timeline + inline diff) defers identically. A new shared ReplyDraftList renders a parent's pending reply-drafts below it (reusing DraftZone), self-fetching from the drafts store, on both surfaces. - Reply-drafts join the "Publish comments" batch and count; they publish via the reply API and, on success, the local draft is dropped and the real reply is fetched back as a normal comment. - useDraftZones excludes reply-kind (they render nested, not as line zones); useLineCommentAdder ignores them for '+' occupancy; DraftsPanel / PublishReviewModal show a "reply" tag and guard the now-optional anchor; PrPanel / DiffView guard anchor too. Editing an existing comment stays immediate (it mutates a remote comment, not new authored content). i18n added for 4 locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + CHANGELOG.zh-CN.md | 1 + apps/desktop/src/main/controllers/pr.ts | 60 ++++++++++---- .../src/components/features/pr/PrPanel.tsx | 6 +- .../features/pr/tabs/comments/CommentItem.tsx | 24 +++++- .../pr/tabs/comments/CommentReplyEditor.tsx | 37 +++++++-- .../pr/tabs/comments/ReplyDraftList.tsx | 82 +++++++++++++++++++ .../features/pr/tabs/diff/DiffView.tsx | 2 +- .../pr/tabs/diff/hooks/useDraftZones.tsx | 11 ++- .../pr/tabs/diff/hooks/useLineCommentAdder.ts | 2 + .../inline-comments/InlineCommentZone.tsx | 14 ++++ .../features/pr/tabs/drafts/DraftsPanel.tsx | 72 ++++++++++------ .../pr/tabs/drafts/PublishReviewModal.tsx | 38 ++++++--- .../pr/tabs/shared/replyDraftAnchor.ts | 13 +++ .../src/renderer/src/i18n/locales/de-DE.json | 10 ++- .../src/renderer/src/i18n/locales/en-US.json | 10 ++- .../src/renderer/src/i18n/locales/ja-JP.json | 10 ++- .../src/renderer/src/i18n/locales/zh-CN.json | 10 ++- .../styles/features/diff/comment-zone.scss | 9 ++ .../src/styles/features/drafts-panel.scss | 10 +++ .../styles/features/pr/publish-review.scss | 9 ++ packages/shared/src/poller-contract.ts | 19 ++++- 22 files changed, 369 insertions(+), 81 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/comments/ReplyDraftList.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/shared/replyDraftAnchor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3551d7f6..384a3cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ 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". ## [0.11.0] - 2026-07-07 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index b6267c69..d4267fab 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -12,6 +12,7 @@ - diff 中的二进制文件(图片、Office 文档、PDF 等)现会显示其 Git LFS 状态——受 LFS 管理的文件显示「Git LFS · <大小>」标记,直接内联存入 git 的文件显示「⚠ 非 LFS」标记。 - 现可对整个文件(而不仅是某一行)添加评论(平台支持时,Bitbucket / GitHub):diff 中新增「对文件评论」入口,且已存在的文件级评论现能正确归属显示,不再被当作 PR 通用评论。 - 评审现会读取被审仓库自身的规范文件(`AGENTS.md`)作为项目上下文,使评审、描述与建议遵循该项目既定的约定。 +- 回复评论现会生成一条**回复草稿**,而非立即提交——与新增内联评论的行为一致。回复被持久化(未提交的回复可跨 PR / 文件 / 标签页切换保留),在每个界面(活动时间线 + 内联 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({
    {candidates.map((d) => { - const lineLabel = - d.anchor.endLine !== d.anchor.startLine - ? `${String(d.anchor.startLine)}-${String(d.anchor.endLine)}` - : String(d.anchor.startLine); - const sideLabel = - d.anchor.side === 'old' + const anchor = d.anchor; + const isReply = d.kind === 'reply'; + const lineLabel = anchor + ? anchor.endLine !== anchor.startLine + ? `${String(anchor.startLine)}-${String(anchor.endLine)}` + : String(anchor.startLine) + : ''; + const sideLabel = anchor + ? anchor.side === 'old' ? t('publishReviewModal.sideOld') - : t('publishReviewModal.sideNew'); + : t('publishReviewModal.sideNew') + : ''; return (
  • - {d ? `${d.anchor.path}:${d.anchor.startLine}` : r.draftId} + + {d?.anchor ? `${d.anchor.path}:${d.anchor.startLine}` : r.draftId} + {' '} — {r.error && formatBackendError(r.error).title} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/replyDraftAnchor.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/replyDraftAnchor.ts new file mode 100644 index 00000000..28025f2b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/replyDraftAnchor.ts @@ -0,0 +1,13 @@ +import type { PrCommentAnchor, ReviewDraftAnchor } from '@meebox/shared'; + +/** + * Snapshot a parent comment's anchor into a ReviewDraftAnchor for a reply-draft. Only inline/line comments yield an + * anchor (used to position the reply-draft's diff zone at the parent's line); a summary comment or a file-level comment + * (no line) yields undefined, so its reply-draft renders only under the parent / in the drafts panel, not as a diff zone. + */ +export function toReplyDraftAnchor( + anchor?: PrCommentAnchor | null, +): ReviewDraftAnchor | undefined { + if (!anchor || anchor.line == null) return undefined; + return { path: anchor.path, startLine: anchor.line, endLine: anchor.line, side: anchor.side }; +} diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index 0a299ee9..c078be57 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -240,9 +240,9 @@ "cancelTitle": "Abbrechen (Esc)", "emptyTitle": "Antwort darf nicht leer sein", "placeholder": "Antwort schreiben…", - "send": "Senden", - "sendTitle": "Senden (Cmd/Ctrl+Enter)", - "sending": "Wird gesendet…", + "saveDraft": "Antwort speichern", + "saveDraftTitle": "Antwort-Entwurf speichern (Cmd/Ctrl+Enter) – später mit dem Review-Stapel veröffentlichen", + "savingDraft": "Wird gespeichert…", "textareaAria": "Editor für Kommentarantworten" }, "commentsPanel": { @@ -431,6 +431,8 @@ "publishOneTitle": "Diesen Kommentar veröffentlichen (gleicher Pfad wie die Schaltfläche \"Veröffentlichen\" im Entwurfsbereich)", "publishing": "Veröffentlichen…", "remoteId": "Remote-ID: {{id}}", + "replyTag": "Antwort", + "replyToComment": "Antwort auf einen Kommentar", "sideNew": "Neu", "sideOld": "Basis", "statusEdited": "Bearbeitet", @@ -685,6 +687,8 @@ "publishDisabledTitle": "Mindestens einen auswählen", "publishTitle": "Auf dem Remote veröffentlichen", "publishingMessage": "{{n}} Kommentare werden nacheinander auf dem Remote veröffentlicht…", + "replyTag": "Antwort", + "replyToComment": "Antwort auf einen Kommentar", "selectAll": "Alle auswählen", "sideNew": "Neu", "sideOld": "Basis", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index 7f9498cf..b1174899 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -240,9 +240,9 @@ "cancelTitle": "Cancel (Esc)", "emptyTitle": "Reply can't be empty", "placeholder": "Write a reply…", - "send": "Send", - "sendTitle": "Send (Cmd/Ctrl+Enter)", - "sending": "Sending…", + "saveDraft": "Save reply", + "saveDraftTitle": "Save reply draft (Cmd/Ctrl+Enter) — publish it later with the review batch", + "savingDraft": "Saving…", "textareaAria": "Comment reply editor" }, "commentsPanel": { @@ -431,6 +431,8 @@ "publishOneTitle": "Publish this one (same path as the Publish button in the draft zone)", "publishing": "Publishing…", "remoteId": "remote id: {{id}}", + "replyTag": "reply", + "replyToComment": "Reply to a comment", "sideNew": "New", "sideOld": "Base", "statusEdited": "Edited", @@ -685,6 +687,8 @@ "publishDisabledTitle": "Select at least one", "publishTitle": "Publish to the remote", "publishingMessage": "Publishing {{n}} comments to the remote sequentially…", + "replyTag": "reply", + "replyToComment": "Reply to a comment", "selectAll": "Select all", "sideNew": "New", "sideOld": "Base", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 9bf1cfe5..3f6a3749 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -240,9 +240,9 @@ "cancelTitle": "キャンセル (Esc)", "emptyTitle": "返信を空にはできません", "placeholder": "返信を入力…", - "send": "送信", - "sendTitle": "送信 (Cmd/Ctrl+Enter)", - "sending": "送信中…", + "saveDraft": "返信を保存", + "saveDraftTitle": "返信ドラフトを保存 (Cmd/Ctrl+Enter) — 後でレビューの一括公開で送信します", + "savingDraft": "保存中…", "textareaAria": "コメント返信エディタ" }, "commentsPanel": { @@ -422,6 +422,8 @@ "publishOneTitle": "このコメントを公開 (下書きエリア内の「公開」ボタンと同じ動作)", "publishing": "公開中…", "remoteId": "リモート id: {{id}}", + "replyTag": "返信", + "replyToComment": "コメントへの返信", "sideNew": "新", "sideOld": "ベース", "statusEdited": "編集済み", @@ -668,6 +670,8 @@ "publishDisabledTitle": "少なくとも 1 件選択してください", "publishTitle": "リモートに公開", "publishingMessage": "{{n}} 件のコメントをリモートに順次公開しています…", + "replyTag": "返信", + "replyToComment": "コメントへの返信", "selectAll": "すべて選択", "sideNew": "新", "sideOld": "ベース", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index c6d00b0d..9d3b61b5 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -240,9 +240,9 @@ "cancelTitle": "取消 (Esc)", "emptyTitle": "回复不能为空", "placeholder": "写一条回复…", - "send": "发送", - "sendTitle": "发送 (Cmd/Ctrl+Enter)", - "sending": "发送中…", + "saveDraft": "保存回复", + "saveDraftTitle": "保存回复草稿 (Cmd/Ctrl+Enter)——稍后随评审批量发布", + "savingDraft": "保存中…", "textareaAria": "评论回复编辑器" }, "commentsPanel": { @@ -422,6 +422,8 @@ "publishOneTitle": "发布这一条 (跟 DraftZone 内\"发布\"按钮同路径)", "publishing": "发布中…", "remoteId": "远端 id: {{id}}", + "replyTag": "回复", + "replyToComment": "回复某条评论", "sideNew": "新版", "sideOld": "基线", "statusEdited": "已编辑", @@ -668,6 +670,8 @@ "publishDisabledTitle": "至少选择一条", "publishTitle": "发布到远端", "publishingMessage": "正在串行发布 {{n}} 条评论到远端…", + "replyTag": "回复", + "replyToComment": "回复某条评论", "selectAll": "全选", "sideNew": "新版", "sideOld": "基线", diff --git a/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss b/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss index d5de40e1..d5ac3c89 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss @@ -239,3 +239,12 @@ width: 3px !important; margin-left: 3px; } + +// Pending reply-drafts rendered under a comment (both the activity timeline and the inline diff zone). Reuses the +// DraftZone card styling; this wrapper only handles spacing between the parent thread and the pending reply cards. +.reply-draft-list { + margin-top: $space-2; + display: flex; + flex-direction: column; + gap: $space-2; +} diff --git a/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss b/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss index 6368f9a1..b40d71ec 100644 --- a/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss +++ b/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss @@ -169,6 +169,16 @@ white-space: nowrap; } +// "reply" pill marking a reply-draft (vs a new-comment draft) in the drafts list. +.drafts-panel-item-reply-tag { + font-size: $fs-xs; + padding: 0 6px; + border-radius: $radius-sm; + background: $bg-hover; + color: $text-muted; + white-space: nowrap; +} + .drafts-panel-item-actions { margin-left: auto; display: inline-flex; diff --git a/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss b/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss index 316a5768..880c6e07 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss @@ -73,6 +73,15 @@ color: $text-primary; word-break: break-all; } +// "reply" pill marking a reply-draft in the publish list. +.publish-review-item-reply-tag { + font-size: $fs-xs; + padding: 0 6px; + border-radius: $radius-sm; + background: $bg-hover; + color: $text-muted; + white-space: nowrap; +} // Clickable anchor variant: button demoted to link appearance (@include anchor-link) + hover primary color .publish-review-item-anchor-link { @include anchor-link; diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts index ee7f91fb..66ea2824 100644 --- a/packages/shared/src/poller-contract.ts +++ b/packages/shared/src/poller-contract.ts @@ -172,13 +172,28 @@ export interface ReviewDraft { id: string; /** PR hash localId, consistent with the parent directory */ prLocalId: string; - /** Anchor: same as FindingAnchor but startLine/endLine required (a draft must anchor to a specific line) */ - anchor: ReviewDraftAnchor; + /** + * Draft kind: a brand-new inline/file comment (`comment`) vs a reply to an existing comment (`reply`). Absent = + * `comment` (back-compat: drafts persisted before reply-drafts existed, and all `finding`-origin drafts, are comments). + * A reply defers the same way a new comment does — it sits in the draft pool and publishes via the batch — but + * publishes through the reply API against {@link replyTo} instead of a fresh inline comment. + */ + kind?: 'comment' | 'reply'; + /** + * Anchor to a specific line. **Required for a `comment`** (a new comment must land on a line). For a `reply` it is a + * snapshot of the parent comment's anchor: present when replying to an inline comment (positions the draft zone at + * the parent's line), absent when replying to a summary comment (which has no line — that reply-draft shows only in + * the drafts panel / activity timeline, never as a diff zone). + */ + anchor?: ReviewDraftAnchor; + /** Reply target (which existing comment this answers). Required when kind='reply'; unused for comments. */ + replyTo?: { parentCommentId: string; threadId?: string }; /** Current comment body. When pending = the AI suggestion's original text; when edited = after user editing */ body: string; /** * Origin: AI suggestion (`finding`) vs user-added manually (`manual`). * A draft created by the user from a DiffView line hover '+' is manual; one navigated from ChatPane is finding. + * A reply-draft is always `manual` (the user authored it). */ origin: 'finding' | 'manual'; /**