From 2b7b85fc21e8c1b752eff72a975eeeaeb46784ac Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 7 Jul 2026 16:48:25 +0800 Subject: [PATCH 01/12] chore(release): start 0.11.1-dev development Post-0.11.0 release: bump apps/desktop to the next -dev prerelease marker (0.11.1-dev) and sync the lockfile, per the -dev version-number rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/package.json | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ac2f8da0..a6a6cde3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.11.0", + "version": "0.11.1-dev", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/package-lock.json b/package-lock.json index 6e536a32..1b914ae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.11.0", + "version": "0.11.1-dev", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1", From 1eb2dbbf038113dd14e1d0c7dc90589a16d790cf Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 7 Jul 2026 16:54:06 +0800 Subject: [PATCH 02/12] docs(release): make post-release back-merge to dev a standard step After a release, master gains a merge commit dev lacks, so the branches diverge and later dev->master PRs show phantom "behind" commits. Document the mandatory back-merge of master into dev (verify git log dev..master is empty) alongside the -dev bump, in both the AGENTS.md release-flow summary and the packaging-release checklist. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +++- docs/development/packaging-release.md | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e1cada0a..f8a739e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,10 +70,12 @@ After changing code, run these four steps locally before wrapping up (this is ex ## Release flow -Release: merge `dev` into `master` → tag `v*` on `master`, which triggers [release.yml](.github/workflows/release.yml) (produces Windows / macOS installers + CLI binaries + a GitHub Release). +Release: merge `dev` into `master` → tag `v*` on `master`, which triggers [release.yml](.github/workflows/release.yml) (produces Windows / macOS installers + CLI binaries + a GitHub Release) → **back-merge `master` into `dev`** + bump `dev` to the next `-dev`. ⚠️ **Before tagging, complete the three prerequisites (version / CHANGELOG / proofread) in the same batch of changes, flowing through `dev` → `master` with the release** — miss any step and CI won't error (only `::warning::`) but will produce a wrong Release. **The full prerequisite checklist, the `-dev` version-number rule, and CHANGELOG writing style are in [Packaging & release](docs/development/packaging-release.md)**. The tag name must equal the package.json version (`v`); a prerelease tag with a `-` in the name is automatically marked prerelease and does not claim Latest. +**Post-release back-merge (mandatory)**: after the `dev → master` release PR merges, `master` gains a merge commit that `dev` does not contain, so the branches immediately diverge — leave it and every subsequent `dev → master` PR shows phantom "behind" commits and risks messy merges. Right after tagging, **merge `master` back into `dev`** (`git checkout dev && git merge master && git push`) so `master` becomes an ancestor of `dev` again (verify with `git log dev..master` = empty). The `-dev` version bump on `dev` may land before or after this back-merge; the version line resolves to `dev`'s newer `-dev` automatically (no conflict), since `dev` changed it last relative to the merge base. + ## CLI sub-project (cli/) `cli/` is the independently distributed cross-platform command-line client `meebox` (for external agents / scripts to integrate via the [local API service](docs/arch/04-integration/01-service-api.md)). Design in [docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md), usage in [docs/guide/06-cli.md](docs/guide/06-cli.md). diff --git a/docs/development/packaging-release.md b/docs/development/packaging-release.md index 94cd9dd4..644278ca 100644 --- a/docs/development/packaging-release.md +++ b/docs/development/packaging-release.md @@ -52,6 +52,17 @@ Complete these **in the same batch of changes**, flowing through `dev` → `mast The tag name and the package.json version must match (`v`). A prerelease tag with a `-` in the name (e.g. `-alpha.N`) is automatically marked prerelease by release.yml and does not claim Latest. +## Post-release steps (mandatory, right after tagging) + +After the `v` tag is pushed and the Release is building, do **both** of these on `dev`, or the branches drift: + +1. **Back-merge `master` into `dev`** — the `dev → master` release PR adds a merge commit to `master` that `dev` doesn't have, so the branches diverge the moment the release merges. Left unaligned, every later `dev → master` PR shows phantom "behind" commits and risks messy merges. Realign by merging `master` back: + ```bash + git checkout dev && git merge master && git push + ``` + Verify alignment with `git log dev..master` (must be **empty** — `master` is fully contained in `dev`). The `package.json` / lockfile version line does not conflict: it resolves to `dev`'s newer `-dev` value, because relative to the merge base only `dev` changed it (see the `-dev` rule below). +2. **Bump `dev` to the next `-dev`** — see the version-number rule below. This may be committed before or after the back-merge; both orders are conflict-free. + **Version-number rule (`-dev`)**: right after each stable release, `dev` immediately bumps [apps/desktop/package.json](../../apps/desktop/package.json) to **the next version's `-dev` prerelease number** (e.g. after shipping `0.6.0`, switch to `0.7.0-dev`, and `npm install` to sync the lockfile), marking the development state. `-dev` is a development marker only — **not tagged, not released**; at release time change it to the target number (`0.7.0-alpha.N` or `0.7.0`) per above. `-dev` is valid semver (`0.6.0` < `0.7.0-dev` < `0.7.0`), so it doesn't affect update checking ([update-check.ts](../../apps/desktop/src/main/utils/update-check.ts) compares with `semver.gt`, not a range) or the build. ## CHANGELOG writing style (user-facing, concise) From 2f4d14d6a02e1f50d982d4f53253b3fa6e679ed7 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 10:32:55 +0800 Subject: [PATCH 03/12] feat(diff): show Git LFS status on binary-file diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binary files (images, office docs, PDFs, …) render a "binary, not rendered" placeholder with no indication of whether they are Git LFS-managed. Detect LFS and surface the status. - repo-mirror getFileContent: detect the Git LFS pointer blob (the mirror never smudges, so an LFS-managed file's content is the pointer text) and return it as binary + the real byte size from the pointer, instead of dumping pointer text as a bogus diff - contract: extend FileContent / DiffFileContent binary variant with an optional lfs { size } field - DiffPane: on the binary placeholder show a "Git LFS · " tag for LFS-managed files, or a "⚠ Not LFS" tag for plain inline binaries (per side, preferring head); i18n across all four locales - test: cover LFS pointer detection + size parsing in repo-mirror Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/pr/tabs/diff/DiffPane.tsx | 23 ++++++++++++- .../pr/tabs/diff/search/diff-search.ts | 4 +-- .../src/renderer/src/i18n/locales/de-DE.json | 3 ++ .../src/renderer/src/i18n/locales/en-US.json | 3 ++ .../src/renderer/src/i18n/locales/ja-JP.json | 3 ++ .../src/renderer/src/i18n/locales/zh-CN.json | 3 ++ .../src/styles/features/diff/view.scss | 32 +++++++++++++++++++ packages/ipc/src/common.ts | 9 +++++- .../repo-mirror/src/repo-mirror-manager.ts | 10 +++++- packages/repo-mirror/src/types.ts | 10 +++++- .../tests/repo-mirror-manager.test.ts | 22 +++++++++++++ 11 files changed, 116 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx index 0d5c984b..132b2b9d 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx @@ -8,6 +8,7 @@ import { useMonacoEditorTheme } from '../../../../../hooks/useTheme'; import { useEditorAppearance } from '../../../../../stores/editor-appearance-store'; import { resolveEditorFontFamily } from '../../../../../theme'; import { languageFor } from '../../../../../utils/language'; +import { formatBytes } from '../../../settings/utils'; import { PaneLoading } from '../../../../common'; import { Spinner } from './DiffStatus'; import type { LoadedContent } from './diff-types'; @@ -119,7 +120,27 @@ export function DiffPane({ ); } if (content.base.binary || content.head.binary) { - return
{t('diffView.binaryNotRendered')}
; + // Git LFS status: prefer the head side's pointer info (the current version), fall back to base (e.g. a deleted file). + const headLfs = content.head.binary ? content.head.lfs : undefined; + const baseLfs = content.base.binary ? content.base.lfs : undefined; + const lfs = headLfs ?? baseLfs; + return ( +
+ {t('diffView.binaryNotRendered')} + {lfs ? ( + + Git LFS{lfs.size != null ? ` · ${formatBytes(lfs.size)}` : ''} + + ) : ( + + + {t('diffView.notLfs')} + + )} +
+ ); } return (
diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts index 69962153..d678f52e 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts @@ -208,8 +208,8 @@ export async function loadContent( side, path, }); - // DiffFileContent union: {binary:false, content:string} or {binary:true}. - // binary files skip search (no comparable text); non-binary takes the content field + // DiffFileContent union: {binary:false, content:string} or {binary:true} (the latter incl. Git LFS pointers). + // binary / LFS files skip search (no comparable text); only non-binary takes the content field const text = c.binary === false ? c.content : null; cache.set(k, text); return text; 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 e0967820..80cefd4e 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -345,6 +345,7 @@ "dismissNotificationTitle": "Diese Benachrichtigung schließen", "fileCount_one": "{{count}} Datei", "fileCount_other": "{{count}} Dateien", + "lfsManagedTitle": "Von Git LFS verwaltet", "loadChangedFilesFailed": "Geänderte Dateien konnten nicht geladen werden", "loadCommentsFailed": "Kommentare konnten nicht geladen werden", "loadingContentHint": "git blob wird aus dem lokalen Mirror gelesen; große oder binäre Dateien können langsamer sein", @@ -352,6 +353,8 @@ "loadingContentSuffix": "Inhalt…", "noFileChanges": "Dieser PR enthält keine Dateiänderungen", "noResultFromMain": "Kein Ergebnis vom main-Prozess zurückgegeben", + "notLfs": "Kein LFS", + "notLfsTitle": "Binärdatei direkt in Git gespeichert, nicht von Git LFS verwaltet", "preparingSync": "Synchronisierung wird vorbereitet", "readFileContentFailed": "Dateiinhalt konnte nicht gelesen werden", "readFileContentFailedNamed": "Inhalt von {{path}} konnte nicht gelesen werden", 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 605e6276..8404f98e 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -345,6 +345,7 @@ "dismissNotificationTitle": "Dismiss this notification", "fileCount_one": "{{count}} file", "fileCount_other": "{{count}} files", + "lfsManagedTitle": "Managed by Git LFS", "loadChangedFilesFailed": "Failed to load changed files", "loadCommentsFailed": "Failed to load comments", "loadingContentHint": "Reading git blob from the local mirror; large or binary files may be slower", @@ -352,6 +353,8 @@ "loadingContentSuffix": "content…", "noFileChanges": "This PR has no file changes", "noResultFromMain": "No result returned from main process", + "notLfs": "Not LFS", + "notLfsTitle": "Binary stored inline in git, not managed by Git LFS", "preparingSync": "Preparing sync", "readFileContentFailed": "Failed to read file content", "readFileContentFailedNamed": "Failed to read content of {{path}}", 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 658352e6..783b3c52 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -337,6 +337,7 @@ "diffRenderFailedHint": "ファイルを切り替えるか再試行すると通常は復旧します。元のエラーは console に記録されています。", "dismissNotificationTitle": "この通知を閉じる", "fileCount_other": "{{count}} ファイル", + "lfsManagedTitle": "Git LFS で管理", "loadChangedFilesFailed": "変更ファイルの読み込みに失敗しました", "loadCommentsFailed": "コメントの読み込みに失敗しました", "loadingContentHint": "ローカルミラーから git blob を読み込んでいます。大きいファイルやバイナリファイルは時間がかかる場合があります", @@ -344,6 +345,8 @@ "loadingContentSuffix": "の内容…", "noFileChanges": "この PR にはファイル変更がありません", "noResultFromMain": "main プロセスから結果が返されませんでした", + "notLfs": "非 LFS", + "notLfsTitle": "バイナリが git に直接保存されており、Git LFS で管理されていません", "preparingSync": "同期を準備中", "readFileContentFailed": "ファイル内容の読み込みに失敗しました", "readFileContentFailedNamed": "{{path}} の内容の読み込みに失敗しました", 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 d70b1112..ca3d5247 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -337,6 +337,7 @@ "diffRenderFailedHint": "切换文件 / 重试通常能恢复。底层异常已记录到 console。", "dismissNotificationTitle": "收起此通知", "fileCount_other": "{{count}} 个文件", + "lfsManagedTitle": "由 Git LFS 管理", "loadChangedFilesFailed": "拉取变更文件列表失败", "loadCommentsFailed": "拉取评论失败", "loadingContentHint": "从本地镜像读 git blob,大文件 / 二进制判定时可能略慢", @@ -344,6 +345,8 @@ "loadingContentSuffix": "内容…", "noFileChanges": "该 PR 无文件变更", "noResultFromMain": "main 端未返回结果", + "notLfs": "非 LFS", + "notLfsTitle": "二进制内联存储于 git,未由 Git LFS 管理", "preparingSync": "准备同步", "readFileContentFailed": "读取文件内容失败", "readFileContentFailedNamed": "读取 {{path}} 内容失败", diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index a79dd917..6bcc6963 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -272,6 +272,7 @@ // Binary file (image/binary) placeholder .diff-binary { + position: relative; display: flex; align-items: center; justify-content: center; @@ -280,3 +281,34 @@ color: $color-warning-bright; font-size: $fs-lg; } +// Git LFS status tag, pinned to the pane's top-right corner: LFS-managed (neutral) vs inline binary / not LFS (warning). +.diff-lfs-tag, +.diff-nonlfs-tag { + position: absolute; + top: $space-3; + right: $space-3; + display: inline-flex; + align-items: center; + gap: 4px; + line-height: 1; + font-size: $fs-sm; + padding: 2px 8px; + border-radius: $radius-sm; + border: 1px solid transparent; + white-space: nowrap; +} +// The ⚠️ emoji sits on the text baseline by default; give it its own tight box so the flex align-items centers it against the label. +.diff-lfs-icon { + display: inline-flex; + align-items: center; + line-height: 1; + font-size: 0.9em; +} +.diff-lfs-tag { + color: $text-muted; + border-color: $border-default; +} +.diff-nonlfs-tag { + color: $color-warning-bright; + border-color: $color-warning-bright; +} diff --git a/packages/ipc/src/common.ts b/packages/ipc/src/common.ts index 6fe8221d..dbb94b5d 100644 --- a/packages/ipc/src/common.ts +++ b/packages/ipc/src/common.ts @@ -16,7 +16,14 @@ export interface DiffChangedFile { similarity?: number; } -export type DiffFileContent = { binary: false; content: string } | { binary: true }; +/** + * File content for diff rendering. `binary: true` = not rendered as a text diff; the optional `lfs` field marks a Git + * LFS-managed file (the mirror holds the LFS pointer, so we show an LFS placeholder + the real byte size instead of the + * pointer text). `binary: true` with no `lfs` is a plain inline binary (committed to git directly, not LFS). + */ +export type DiffFileContent = + | { binary: false; content: string } + | { binary: true; lfs?: { size: number | null } }; export type DiffSide = 'base' | 'head'; diff --git a/packages/repo-mirror/src/repo-mirror-manager.ts b/packages/repo-mirror/src/repo-mirror-manager.ts index 46acc269..43e1a09e 100644 --- a/packages/repo-mirror/src/repo-mirror-manager.ts +++ b/packages/repo-mirror/src/repo-mirror-manager.ts @@ -508,7 +508,8 @@ export class RepoMirrorManager { /** * Read a file's content at a given commit. Under a full bare clone all blobs are local, so git show directly. * If the file is not in that commit (add/delete scenarios) returns empty content. - * Simple null-byte heuristic to detect binary (first 8000 characters). + * A Git LFS pointer is detected first (surfaced as an LFS marker + real size); otherwise a simple null-byte + * heuristic (first 8000 characters) flags an inline binary. */ async getFileContent(repo: RepoIdentity, sha: string, filePath: string): Promise { const mirrorPath = this.mirrorPath(repo); @@ -519,6 +520,13 @@ export class RepoMirrorManager { // File does not exist at that commit (before an add / after a delete), return empty return { binary: false, content: '' }; } + // Git LFS: the mirror stores the pointer blob (LFS objects are never smudged, see the worktree filter config above), + // so an LFS-managed file's content here is the small pointer text. Detect it and surface an LFS marker + the real + // byte size (from the pointer's `size` line), instead of rendering the pointer text as a bogus diff. + if (content.startsWith('version https://git-lfs.github.com/spec/v1')) { + const m = /^size (\d+)$/m.exec(content); + return { binary: true, lfs: { size: m ? Number(m[1]) : null } }; + } if (content.slice(0, 8000).includes('')) { return { binary: true }; } diff --git a/packages/repo-mirror/src/types.ts b/packages/repo-mirror/src/types.ts index fa1e8f96..b97687bd 100644 --- a/packages/repo-mirror/src/types.ts +++ b/packages/repo-mirror/src/types.ts @@ -35,7 +35,15 @@ export interface ChangedFile { similarity?: number; } -export type FileContent = { binary: false; content: string } | { binary: true }; +/** + * File content at a commit for diff rendering. `binary: true` means "not rendered as a text diff"; the optional `lfs` + * field marks a Git LFS-managed file — the mirror stores the small LFS pointer blob (objects are never smudged), so we + * detect the pointer and surface it as an LFS placeholder (with the real byte size from the pointer) instead of dumping + * the pointer text as a diff. A `binary: true` without `lfs` is a plain inline binary (stored in git directly, not LFS). + */ +export type FileContent = + | { binary: false; content: string } + | { binary: true; lfs?: { size: number | null } }; /** Single-line blame info. Parsed from `git blame --porcelain -- `. */ export interface BlameLine { diff --git a/packages/repo-mirror/tests/repo-mirror-manager.test.ts b/packages/repo-mirror/tests/repo-mirror-manager.test.ts index 7e18299f..ddc752de 100644 --- a/packages/repo-mirror/tests/repo-mirror-manager.test.ts +++ b/packages/repo-mirror/tests/repo-mirror-manager.test.ts @@ -395,6 +395,28 @@ describe('RepoMirrorManager diff/content', () => { const r = await mgr.getFileContent(repo, sha, 'icon.png'); expect(r.binary).toBe(true); + // A plain inline binary is not LFS-managed. + expect(r.binary === true && r.lfs).toBeUndefined(); + }); + + it('getFileContent detects a Git LFS pointer and surfaces its size', async () => { + // An LFS-managed file is stored in git as a small pointer blob (the mirror never smudges), so getFileContent sees + // the pointer text; it should flag binary + carry the declared size instead of returning the pointer as text. + const upstream = simpleGit(upstreamPath); + const pointer = + 'version https://git-lfs.github.com/spec/v1\n' + + 'oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n' + + 'size 12345\n'; + await fs.writeFile(path.join(upstreamPath, 'big.psd'), pointer); + await upstream.add('.'); + await upstream.commit('add lfs pointer'); + const sha = (await upstream.revparse(['HEAD'])).trim(); + + const mgr = makeManager(); + await mgr.syncMirror(repo); + + const r = await mgr.getFileContent(repo, sha, 'big.psd'); + expect(r).toEqual({ binary: true, lfs: { size: 12345 } }); }); it('parseMergeTreeConflictsZ takes conflict file names between the first OID and the section-separating double NUL (deduped)', () => { From 9f338c0720287b343ab35f1ba890bb84cfa8b751 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 11:32:38 +0800 Subject: [PATCH 04/12] feat(review): support file-level comments (whole-file, not line-anchored) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments could only be PR summaries or line-anchored inline comments. Add file-level comments — anchored to a whole file — which Bitbucket and GitHub support. Previously such remote comments were silently collapsed to summaries, losing their file association (notably Bitbucket file comments). - model: PrCommentAnchor.line/lineType become optional (absent line = file-level); comment kind gains 'file'; new capability fileLevelComments - adapters: Bitbucket maps/publishes a line-less anchor; GitHub uses subject_type: "file"; GitLab has no file-level diff-comment API → capability false + defensive guard (degrades to local menu / hides the entry) - ipc: comments:createFile channel + controller (posts via the inline-publish path with a line-less anchor) - renderer: a file-level comment strip above the diff editor shows the file's file-level comments (reusing CommentItem for full interaction parity) plus an icon "comment on file" entry (capability-gated); the header shows the file's project-relative path as a breadcrumb. CommentItem renders a path-only, non-clickable chip for file-level anchors and skips the line code context; useCommentZones excludes line-less anchors from Monaco line zones - docs: capability field + per-platform table + a file-level comments section in the comment-interactions design Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/controllers/pr.ts | 19 +++ apps/desktop/src/main/ipc.ts | 1 + .../src/main/services/notifications.ts | 8 +- .../src/components/features/pr/PrPanel.tsx | 8 +- .../pr/tabs/comments/CommentComposer.tsx | 14 +- .../features/pr/tabs/comments/CommentItem.tsx | 33 ++-- .../pr/tabs/comments/InlineCodeContext.tsx | 10 +- .../features/pr/tabs/diff/DiffView.tsx | 16 ++ .../pr/tabs/diff/FileCommentStrip.tsx | 146 ++++++++++++++++++ .../pr/tabs/diff/hooks/useCommentZones.tsx | 8 +- .../features/pr/tabs/shared/ReactionBar.tsx | 6 +- .../src/renderer/src/i18n/locales/de-DE.json | 7 +- .../src/renderer/src/i18n/locales/en-US.json | 7 +- .../src/renderer/src/i18n/locales/ja-JP.json | 7 +- .../src/renderer/src/i18n/locales/zh-CN.json | 7 +- .../src/styles/features/diff/view.scss | 86 +++++++++++ docs/arch/01-platform/01-adapter.md | 3 +- .../01-platform/04-comment-interactions.md | 9 ++ packages/ipc/src/pr.ts | 9 ++ .../src/features/comment.ts | 34 ++-- .../src/features/connection.ts | 2 + .../tests/adapter.test.ts | 6 +- .../platform-github/src/features/comment.ts | 36 +++-- .../src/features/connection.ts | 2 + packages/platform-github/src/types.ts | 2 + .../platform-gitlab/src/features/comment.ts | 5 + .../src/features/connection.ts | 2 + packages/poller/tests/poller.test.ts | 2 + packages/shared/src/platform.ts | 31 ++-- 29 files changed, 447 insertions(+), 79 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index ca3d2537..ba170ae2 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -70,6 +70,25 @@ export const createComment: IpcController<'comments:create'> = async (_event, re return created; }; +/** + * Post a file-level comment (anchored to a whole file, no line) via the inline-comment publish path with a line-less + * anchor. Only reachable where the fileLevelComments capability is true (the UI gates the entry). On success clears the + * comments cache + broadcasts comments:changed so the UI refetches. + */ +export const createFileComment: IpcController<'comments:createFile'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const created = await adapter.comments.publishInlineComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + { path: req.path, side: req.side ?? 'new' }, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return created; +}; + /** Minimum query length before hitting the remote user-search endpoint (avoids a request per keystroke on 1 char). */ const MENTION_SEARCH_MIN_QUERY = 2; diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index bf9945a2..736ace14 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -65,6 +65,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { */ ipcMain.handle('comments:reply', pr.replyComment); // Reply to a comment ipcMain.handle('comments:create', pr.createComment); // Create a summary comment + ipcMain.handle('comments:createFile', pr.createFileComment); // Create a file-level comment (whole file, no line) ipcMain.handle('comments:delete', pr.deleteComment); // Delete your own comment ipcMain.handle('comments:edit', pr.editComment); // Edit your own comment ipcMain.handle('comments:toggleReaction', pr.toggleReaction); // Toggle a comment emoji reaction diff --git a/apps/desktop/src/main/services/notifications.ts b/apps/desktop/src/main/services/notifications.ts index baaa8ec5..29865c7e 100644 --- a/apps/desktop/src/main/services/notifications.ts +++ b/apps/desktop/src/main/services/notifications.ts @@ -64,9 +64,11 @@ function activateOnClick(e: PollNotificationEvent): () => void { broadcast('notification:activate', { localId: e.localId, kind: e.kind, - anchor: e.comment?.anchor - ? { path: e.comment.anchor.path, line: e.comment.anchor.line } - : null, + anchor: + // File-level comments (no line) aren't line-navigable → no anchor (clicking just opens the PR). + e.comment?.anchor && e.comment.anchor.line != null + ? { path: e.comment.anchor.path, line: e.comment.anchor.line } + : null, }); }; } 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 d2e27763..6b69f74b 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -229,11 +229,13 @@ export function PrPanel({ onComposeClose={() => setComposingComment(false)} currentUserName={currentUserName} onViewCommit={viewCommit} - onJumpToAnchor={(a: PrCommentAnchor) => + onJumpToAnchor={(a: PrCommentAnchor) => { + // File-level anchors (no line) aren't line-navigable; CommentItem doesn't make them clickable, guard anyway. + if (a.line == null) return; onRequestDiffNav?.({ anchor: { path: a.path, startLine: a.line, endLine: a.line }, - }) - } + }); + }} /> diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx index 9996fb68..6b5f7ea0 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx @@ -16,14 +16,20 @@ interface CommentComposerProps { attachmentsEnabled?: boolean; /** Whether the platform supports remote user search (capabilities.userSearch); enables the mention editor's remote fallback when true. */ userSearchEnabled?: boolean; + /** + * Override the post action. Default posts a PR summary comment (`comments:create`); pass this to post elsewhere with + * the same composer (e.g. a file-level comment via `comments:createFile`). Receives the body, resolves on success. + */ + onSubmit?: (body: string) => Promise; onCancel: () => void; /** Called after posting succeeds (collapses the composer; the timeline auto-refreshes via the comments:changed event, the new comment appears at the top) */ onPosted: () => void; } /** - * Composer for a new summary (not anchored to a file) comment: textarea + send/cancel. Appears at the top of the activity timeline. - * Cmd/Ctrl+Enter sends, Esc cancels; send is disabled on an empty body. Layout reuses the reply composer's styles. + * Composer for a new comment: textarea + send/cancel. Posts a PR summary comment by default (top of the activity + * timeline), or whatever `onSubmit` overrides it to (e.g. a file-level comment). Cmd/Ctrl+Enter sends, Esc cancels; + * send is disabled on an empty body. Layout reuses the reply composer's styles. */ export function CommentComposer({ prLocalId, @@ -31,6 +37,7 @@ export function CommentComposer({ platform, attachmentsEnabled = false, userSearchEnabled = false, + onSubmit, onCancel, onPosted, }: CommentComposerProps) { @@ -46,7 +53,8 @@ export function CommentComposer({ setPosting(true); setError(null); try { - await invoke('comments:create', { localId: prLocalId, body }); + if (onSubmit) await onSubmit(body); + else await invoke('comments:create', { localId: prLocalId, body }); onPosted(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); 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 b0cf0c18..3ffcb5d2 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 @@ -141,32 +141,43 @@ export function CommentItem({ // inline comment anchor chip: path:line + side (old=base / new=head), letting the user locate the code position from the comment. // When onJumpToAnchor is provided (activity view) the chip becomes clickable → jump to the corresponding file/line in the Diff. const anchor = comment.anchor; + // File-level comments (no line) show just the path; line comments show `path:line`. + const anchorLabel = anchor ? ( + <> + {anchor.path} + {anchor.line != null ? `:${String(anchor.line)}` : ''} + + ) : null; + const anchorHint = anchor + ? anchor.line == null + ? t('commentsPanel.anchorFileTitle') + : t('commentsPanel.anchorTitle', { + side: anchor.side === 'old' ? 'base' : 'head', + lineType: anchor.lineType, + }) + : ''; const anchorChip = anchor ? ( - onJumpToAnchor ? ( + // File-level anchors (no line) have no line to jump to → render as a non-clickable chip even in the activity view. + onJumpToAnchor && anchor.line != null ? ( ) : ( - - {anchor.path}:{anchor.line} + + {anchorLabel} ) ) : null; // inline comment: embed a code context (Monaco read-only) above the body. replies (depth > 0) do not repeat it. + // File-level comments (no line) have no single line to show → skip the code context. const inlineCode = - comment.anchor && depth === 0 ? ( + comment.anchor && comment.anchor.line != null && depth === 0 ? ( {t('commentsPanel.loadingCodeContext')}
} > diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx index c8beaf97..214bc030 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx @@ -58,6 +58,10 @@ function InlineCodeContextImpl({ let cancelled = false; setSnippet(null); setError(null); + // File-level anchors (no line) have no line to contextualize; the caller doesn't mount this for them, but guard anyway. + // Capture as a const so the narrowing carries into the async closure below. + const line = anchor.line; + if (line == null) return; void (async () => { try { const c = await invoke('diff:getFileContent', { @@ -72,10 +76,10 @@ function InlineCodeContextImpl({ return; } const allLines = c.content.split('\n'); - const startLine = Math.max(1, anchor.line - contextLines); - const endLine = Math.min(allLines.length, anchor.line + contextLines); + const startLine = Math.max(1, line - contextLines); + const endLine = Math.min(allLines.length, line + contextLines); const text = allLines.slice(startLine - 1, endLine).join('\n'); - setSnippet({ text, startLine, anchorInSnippet: anchor.line - startLine + 1 }); + setSnippet({ text, startLine, anchorInSnippet: line - startLine + 1 }); } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : String(e)); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx index 87cfa2e5..02070298 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx @@ -16,6 +16,7 @@ import { DiffSearchPanel } from './DiffSearchPanel'; import { FileTree } from './FileTree'; import { DiffScopeSelect } from './DiffScopeSelect'; import { DiffPane } from './DiffPane'; +import { FileCommentStrip } from './FileCommentStrip'; import { BackendErrorBanner, BackendErrorView, SyncProgress } from './DiffStatus'; import { BlameColumn } from './blame/BlameColumn'; import { fileKey, type PendingCommitView } from './diff-types'; @@ -396,6 +397,21 @@ export function DiffView({ onDismiss={() => setBlameError(null)} /> )} + {selected && ( + + )} {selected && (
{showBlame && blame && blameLayout && diffEditor && ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx new file mode 100644 index 00000000..465b37a5 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx @@ -0,0 +1,146 @@ +import { useMemo, useState } from 'react'; +import type { + PlatformCapabilities, + PlatformUser, + PrComment, + StoredPullRequest, +} from '@meebox/shared'; +import { invoke } from '../../../../../api'; +import { ChatIcon } from '../../../../common'; +import { CommentComposer } from '../comments/CommentComposer'; +import { CommentItem } from '../comments/CommentItem'; + +/** + * File-level comments for the currently open diff file: comments anchored to the whole file (no line) — which the + * line-based inline zones can't host — plus a "comment on this file" entry. Rendered above the diff editor. + * + * Reuses {@link CommentItem} / {@link CommentComposer} so file-level comments have the **same** interactions (reactions, + * mention, reply, edit, delete) as every other comment surface (see the comment-interaction consistency rule). The + * compose entry is gated on the `fileLevelComments` capability (Bitbucket / GitHub; GitLab has none). + */ +export function FileCommentStrip({ + pr, + path, + oldPath, + comments, + capabilities, + hardBreaks, + reactionsMode, + mentionCandidates, + attachmentsEnabled = false, + userSearchEnabled = false, + readOnly = false, +}: { + pr: StoredPullRequest; + path: string; + oldPath?: string; + comments: PrComment[]; + capabilities?: PlatformCapabilities; + hardBreaks: boolean; + reactionsMode?: 'fixed' | 'free'; + mentionCandidates?: PlatformUser[]; + attachmentsEnabled?: boolean; + userSearchEnabled?: boolean; + readOnly?: boolean; +}) { + const [composing, setComposing] = useState(false); + const [copied, setCopied] = useState(false); + // The file's project-relative path as breadcrumb segments (last = the file name). + const segments = useMemo(() => path.split('/'), [path]); + // Whole-breadcrumb click copies the relative path (VS Code-style, no per-segment navigation), with a brief ✓. + const copyPath = (): void => { + void navigator.clipboard.writeText(path).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }, + () => { + /* clipboard denied — best-effort, ignore */ + }, + ); + }; + const fileComments = useMemo( + () => + comments.filter( + (c) => + c.anchor && + c.anchor.line == null && + (c.anchor.path === path || (oldPath != null && c.anchor.path === oldPath)), + ), + [comments, path, oldPath], + ); + const canComment = !readOnly && (capabilities?.fileLevelComments ?? false); + // Nothing to show and nothing to add → render nothing (keep the diff clean for files without file-level comments). + if (fileComments.length === 0 && !canComment) return null; + + return ( +
+
+ + {canComment && !composing && ( + + )} +
+ {(fileComments.length > 0 || composing) && ( +
+ {fileComments.length > 0 && ( +
    + {fileComments.map((c) => ( + + ))} +
+ )} + {composing && ( + + invoke('comments:createFile', { localId: pr.localId, path, body }) + } + onCancel={() => setComposing(false)} + onPosted={() => setComposing(false)} + /> + )} +
+ )} +
+ ); +} 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 3ced7b84..bf3cfbad 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 @@ -63,6 +63,8 @@ export function useCommentZones(opts: { const fileComments = comments.filter( (c) => c.anchor && + // File-level comments (no line) are not line-anchored; they render in DiffView's file-level strip, not here. + c.anchor.line != null && (c.anchor.path === selected.path || (selected.oldPath && c.anchor.path === selected.oldPath)), ); @@ -70,10 +72,12 @@ export function useCommentZones(opts: { const oldByLine = new Map(); const newByLine = new Map(); for (const c of fileComments) { + // fileComments already excludes line-less (file-level) anchors, so line is present here. + const line = c.anchor!.line!; const target = c.anchor!.side === 'old' ? oldByLine : newByLine; - const arr = target.get(c.anchor!.line) ?? []; + const arr = target.get(line) ?? []; arr.push(c); - target.set(c.anchor!.line, arr); + target.set(line, arr); } const buildDecorations = ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx index 02253388..ca3f3ee6 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx @@ -38,8 +38,10 @@ export function useReactions( ): { reactions: PrReaction[]; busy: boolean; toggle: (emoji: string, add: boolean) => void } { const [busy, setBusy] = useState(false); const reactions = comment.reactions ?? []; - // GitHub picks the issue / review reaction endpoint by kind; other platforms ignore it. anchor is the fallback (old data has no kind). - const kind: 'summary' | 'inline' = comment.kind ?? (comment.anchor ? 'inline' : 'summary'); + // GitHub picks the issue / review reaction endpoint by kind; other platforms ignore it. anchor is the fallback (old + // data has no kind). File-level comments are review comments too → use the 'inline' (review) reaction endpoint. + const kind: 'summary' | 'inline' = + (comment.kind ?? (comment.anchor ? 'inline' : 'summary')) === 'summary' ? 'summary' : 'inline'; const toggle = useCallback( (emoji: string, add: boolean): void => { if (busy || readOnly) return; 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 80cefd4e..0a299ee9 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -246,6 +246,7 @@ "textareaAria": "Editor für Kommentarantworten" }, "commentsPanel": { + "anchorFileTitle": "Dateikommentar (ganze Datei)", "anchorJumpTitle": "Zur Stelle im Diff springen", "anchorTitle": "Verankert an {{side}}-Seite · {{lineType}}", "daysAgo_one": "vor {{count}} Tag", @@ -339,11 +340,9 @@ "binaryNotRendered": "⚠️ Binärdatei, Diff wird nicht dargestellt", "blameChangeRangeTitle": "Dieser Bereich wurde durch diesen PR geändert", "blameFailed": "blame fehlgeschlagen", - "blameFailedNamed": "blame für {{path}} fehlgeschlagen", - "diffRenderFailed": "Diff-Darstellung fehlgeschlagen: {{message}}", + "blameFailedNamed": "blame für {{path}} fehlgeschlagen", "diffRenderFailed": "Diff-Darstellung fehlgeschlagen: {{message}}", "diffRenderFailedHint": "Ein Dateiwechsel oder erneuter Versuch behebt das Problem meist. Der zugrunde liegende Fehler wurde in der console protokolliert.", - "dismissNotificationTitle": "Diese Benachrichtigung schließen", - "fileCount_one": "{{count}} Datei", + "dismissNotificationTitle": "Diese Benachrichtigung schließen", "fileCount_one": "{{count}} Datei", "fileCount_other": "{{count}} Dateien", "lfsManagedTitle": "Von Git LFS verwaltet", "loadChangedFilesFailed": "Geänderte Dateien konnten nicht geladen werden", 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 8404f98e..7f9498cf 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -246,6 +246,7 @@ "textareaAria": "Comment reply editor" }, "commentsPanel": { + "anchorFileTitle": "File-level comment (whole file)", "anchorJumpTitle": "Jump to this line in Diff", "anchorTitle": "Anchored to {{side}} side · {{lineType}}", "daysAgo_one": "{{count}} day ago", @@ -339,11 +340,9 @@ "binaryNotRendered": "⚠️ Binary file, diff not rendered", "blameChangeRangeTitle": "This range was changed by this PR", "blameFailed": "Blame failed", - "blameFailedNamed": "Blame failed for {{path}}", - "diffRenderFailed": "Diff render failed: {{message}}", + "blameFailedNamed": "Blame failed for {{path}}", "diffRenderFailed": "Diff render failed: {{message}}", "diffRenderFailedHint": "Switching files or retrying usually recovers. The underlying error has been logged to the console.", - "dismissNotificationTitle": "Dismiss this notification", - "fileCount_one": "{{count}} file", + "dismissNotificationTitle": "Dismiss this notification", "fileCount_one": "{{count}} file", "fileCount_other": "{{count}} files", "lfsManagedTitle": "Managed by Git LFS", "loadChangedFilesFailed": "Failed to load changed files", 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 783b3c52..9bf1cfe5 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -246,6 +246,7 @@ "textareaAria": "コメント返信エディタ" }, "commentsPanel": { + "anchorFileTitle": "ファイルレベルのコメント(ファイル全体)", "anchorJumpTitle": "Diff の該当位置へ移動", "anchorTitle": "{{side}} 側にアンカー · {{lineType}}", "daysAgo_other": "{{count}} 日前", @@ -332,12 +333,10 @@ "binaryNotRendered": "⚠️ バイナリファイル、差分は表示されません", "blameChangeRangeTitle": "この範囲はこの PR で変更されました", "blameFailed": "blame に失敗しました", - "blameFailedNamed": "{{path}} の blame に失敗しました", - "diffRenderFailed": "差分の表示に失敗しました: {{message}}", + "blameFailedNamed": "{{path}} の blame に失敗しました", "diffRenderFailed": "差分の表示に失敗しました: {{message}}", "diffRenderFailedHint": "ファイルを切り替えるか再試行すると通常は復旧します。元のエラーは console に記録されています。", "dismissNotificationTitle": "この通知を閉じる", - "fileCount_other": "{{count}} ファイル", - "lfsManagedTitle": "Git LFS で管理", + "fileCount_other": "{{count}} ファイル", "lfsManagedTitle": "Git LFS で管理", "loadChangedFilesFailed": "変更ファイルの読み込みに失敗しました", "loadCommentsFailed": "コメントの読み込みに失敗しました", "loadingContentHint": "ローカルミラーから git blob を読み込んでいます。大きいファイルやバイナリファイルは時間がかかる場合があります", 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 ca3d5247..c6d00b0d 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -246,6 +246,7 @@ "textareaAria": "评论回复编辑器" }, "commentsPanel": { + "anchorFileTitle": "文件级评论(整个文件)", "anchorJumpTitle": "跳转到 Diff 对应位置", "anchorTitle": "锚定 {{side}} 侧 · {{lineType}}", "daysAgo_other": "{{count}} 天前", @@ -332,12 +333,10 @@ "binaryNotRendered": "⚠️ 二进制文件,不渲染 diff", "blameChangeRangeTitle": "此区段为本 PR 引入的改动", "blameFailed": "blame 失败", - "blameFailedNamed": "{{path}} blame 失败", - "diffRenderFailed": "diff 渲染失败:{{message}}", + "blameFailedNamed": "{{path}} blame 失败", "diffRenderFailed": "diff 渲染失败:{{message}}", "diffRenderFailedHint": "切换文件 / 重试通常能恢复。底层异常已记录到 console。", "dismissNotificationTitle": "收起此通知", - "fileCount_other": "{{count}} 个文件", - "lfsManagedTitle": "由 Git LFS 管理", + "fileCount_other": "{{count}} 个文件", "lfsManagedTitle": "由 Git LFS 管理", "loadChangedFilesFailed": "拉取变更文件列表失败", "loadCommentsFailed": "拉取评论失败", "loadingContentHint": "从本地镜像读 git blob,大文件 / 二进制判定时可能略慢", diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index 6bcc6963..dcaebbd6 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -67,6 +67,92 @@ flex-direction: column; } +// File-level comment strip: sits above the diff editor inside .diff-content (a flex column). Natural height, capped so +// many comments never crowd the editor out; the editor (.diff-pane-wrapper, flex:1) keeps the remaining space. +.diff-file-comments { + flex: 0 0 auto; + display: flex; + flex-direction: column; + max-height: 40%; + overflow: hidden; +} +// Header bar: mirrors .diff-file-list-header (same padding + divider) so its bottom border lines up with the file tree's. +.diff-file-comments-head { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-4; + padding: $space-3 $space-6; + font-size: $fs-sm; + color: $text-muted; + border-bottom: 1px solid $border-muted; +} +// Comments / composer scroll below the header bar within the capped strip. +.diff-file-comments-body { + overflow-y: auto; + padding: $space-2 $space-3; +} +// Project-relative path breadcrumb of the open file (VS Code-style, chevron-separated). The whole thing is one button: +// clicking copies the relative path (no per-segment navigation / expansion). Single line, truncates on overflow. +.diff-file-crumbs { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; + // Tighten so the text line never exceeds the 22px content height that keeps the header bar level with the file tree's. + line-height: 1; + // Button reset. + padding: 0; + background: transparent; + border: none; + color: inherit; + font: inherit; + cursor: pointer; + + &:hover { + color: $text-body; + } +} +.diff-file-crumb { + display: inline-flex; + align-items: center; +} +.diff-file-crumb-sep { + margin: 0 3px; + opacity: 0.6; +} +.diff-file-crumb-name { + font-weight: 600; +} +.diff-file-crumb-copied { + margin-left: 6px; + font-weight: 600; + color: $color-success; +} +// Icon-only "comment on file" button (pushed right by the header's space-between). Fixed 22px box so it matches the +// file tree header's content height exactly (row = 12px padding + 1px border + 22px = 35px, level with the file tree). +.diff-file-comment-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + color: $text-muted; + background: transparent; + border: none; + border-radius: $radius-sm; + cursor: pointer; + + &:hover { + background: $bg-hover; + color: inherit; + } +} + // Main area wrapper: the left blame-column is fixed width; ErrorBoundary or DiffPane take flex:1 .diff-pane-wrapper { flex: 1; diff --git a/docs/arch/01-platform/01-adapter.md b/docs/arch/01-platform/01-adapter.md index c4f955e8..3b78b80d 100644 --- a/docs/arch/01-platform/01-adapter.md +++ b/docs/arch/01-platform/01-adapter.md @@ -138,7 +138,7 @@ Neutral-type highlights: Capabilities that cannot be implemented equivalently on all platforms are declared explicitly via the **`PlatformCapabilities`** returned by `capabilities()`; the UI shows / hides / greys accordingly, and the business layer switches strategy accordingly — **never `try/catch` to guess at the call site, and never write `if (platform === ...)`**. -`PlatformCapabilities` fields: `reviewStatuses` (supported review decisions), `inlineComments`, `inlineMultiline`, `commentOptimisticLock`, `commentHardBreaks` (whether a single `\n` renders as a hard break), `mergeVetoFidelity` ('full' | 'partial'), `discoveryRateLimited`, `discoveryFilters` (PR discovery categories), `resolvableThreads`, `suggestions`, `reviewGrouping`, `activityTimeline` (whether a review-decision activity event stream is provided), `commentCountIncludesReplies` (whether `PullRequest.commentCount` includes replies — determines the poller's comment-tracking trigger strategy, see [Notifications](../03-gui/03-notifications.md); true for GitHub/GitLab, false for Bitbucket), `userSearch` (whether the platform exposes a user-search endpoint for `@mention` autocomplete — repo-scoped where a normal reviewer can call it, else instance-wide; true on all three, see §3). +`PlatformCapabilities` fields: `reviewStatuses` (supported review decisions), `inlineComments`, `fileLevelComments` (whether a comment can anchor to a whole file, not a line — Bitbucket / GitHub yes, GitLab no; see §3), `inlineMultiline`, `commentOptimisticLock`, `commentHardBreaks` (whether a single `\n` renders as a hard break), `mergeVetoFidelity` ('full' | 'partial'), `discoveryRateLimited`, `discoveryFilters` (PR discovery categories), `resolvableThreads`, `suggestions`, `reviewGrouping`, `activityTimeline` (whether a review-decision activity event stream is provided), `commentCountIncludesReplies` (whether `PullRequest.commentCount` includes replies — determines the poller's comment-tracking trigger strategy, see [Notifications](../03-gui/03-notifications.md); true for GitHub/GitLab, false for Bitbucket), `userSearch` (whether the platform exposes a user-search endpoint for `@mention` autocomplete — repo-scoped where a normal reviewer can call it, else instance-wide; true on all three, see §3). **Merge-veto reasons go through neutral codes**: `MergeVeto` does not assemble user-facing localized text in the backend. GitHub / GitLab normalize their derived reasons to the stable codes `MergeVetoCode` in `@meebox/platform-core` (`conflict` / `branchProtected` / `behind` / `checksFailed` / `checking` / `draft` / `discussionsUnresolved` / `notApproved` / `notOpen` / `blockedByDependency` / `notMergeable`), and the frontend does i18n by code (`mergeVeto.`); Bitbucket passes the server text through directly (`summary`, no code). Likewise, backend user-facing errors such as unsupported version from the connection probe are carried by error codes (see [Error codes](../99-core/04-error-codes.md)), not assembled as Chinese text in the backend. @@ -151,6 +151,7 @@ Per-platform capability overview: | mergeVetoFidelity | full (/merge vetoes) | partial (assembled from mergeable_state) | full (detailed_merge_status) | | discoveryRateLimited | no | yes (search 30/min) | no | | resolvableThreads / suggestions | no / no | conceptually present, not yet implemented | conceptually present, not yet implemented | +| fileLevelComments | yes (anchor without a line) | yes (`subject_type: "file"`) | no (no file-level diff-comment API) | ### The three degradation states & the decision criteria diff --git a/docs/arch/01-platform/04-comment-interactions.md b/docs/arch/01-platform/04-comment-interactions.md index cae6bd54..89ee0131 100644 --- a/docs/arch/01-platform/04-comment-interactions.md +++ b/docs/arch/01-platform/04-comment-interactions.md @@ -20,6 +20,7 @@ Each of the three is declared by a capability flag (`commentReactions` / `commen | Attachments `commentAttachments` | ✗ (no public upload API) | ✓ | ✓ | | @mention local completion | ✓ (no flag; always on) | ✓ | ✓ | | @mention remote search `userSearch` | ✓ `collaborators` → `/search/users` on 401/403 | ✓ `/users?permission=LICENSED_USER&filter=` (native picker endpoint) | ✓ `/projects/:id/users` (any member) | +| File-level comments `fileLevelComments` | ✓ `subject_type: "file"` | ✓ anchor without a line | ✗ (no file-level diff-comment API) | ### emoji reactions: unify the emoji character as a neutral key @@ -71,6 +72,14 @@ Both layers are a **pure convenience**: the user can still freely type any `@nam **Consistency across surfaces (design philosophy)**: every comment-interaction behavior — reactions, `@mention` (local + remote), attachments, reply / edit / delete — must be **identical on all comment surfaces**: the comments/activity page (`CommentItem`), the inline diff comment zone (`InlineCommentZone`), and the inline draft editor (`DraftZone`). This is enforced by **sharing the leaf components / hooks** (`CommentReplyEditor`, `MentionTextarea`, `useReactions`, `useCommentThread`) rather than reimplementing per surface, so a surface can only differ in layout, never in interaction behavior. When adding or changing an interaction, wire it into **all** surfaces (thread the same props down each path) — a capability reaching only one surface is a bug, not a scope choice. +### File-level comments: whole-file anchor + capability degradation + +A comment can anchor to a **whole file** (not a specific line) where the platform supports it (`fileLevelComments`). This is modeled by a `PrCommentAnchor` **without a `line`** (path + side only): `anchor == null` → PR summary; `anchor` with a line → inline; `anchor` without a line → file-level. The comment `kind` (`'summary' | 'inline' | 'file'`) mirrors this. + +- **Read (all platforms)**: previously a line-less remote anchor was collapsed to a summary, losing its file association (notably Bitbucket, whose web UI creates file comments). Now each adapter maps it to a file-level anchor: Bitbucket (anchor without `line`), GitHub (`subject_type: "file"`). GitLab has no file-level diff-comment concept, so its notes stay summary. File-level comments render in a **strip above the diff editor** for that file (line-based inline zones can't host a line-less anchor), and in the comments/activity list they show a path-only chip (non-clickable — there's no line to jump to). +- **Write (Bitbucket / GitHub)**: a "comment on file" entry in the diff file-comment strip posts via `comments:createFile` → the adapter's inline-publish path with a line-less anchor (Bitbucket sends only path + fileType; GitHub sends `subject_type: "file"`). GitLab's `fileLevelComments` is `false`, so the entry is hidden; the adapter also guards `publishInlineComment` against a line-less anchor defensively. +- **Interaction parity**: the file-comment strip reuses `CommentItem` / `CommentComposer`, so reactions / mention / reply / edit / delete behave identically to every other comment surface (see the consistency rule below). File-level comments are review comments, so reactions use the inline (review) endpoint. + ### Image attachments: platform-native upload + reuse of existing rendering Paste an image → the render layer intercepts → hand the bytes to the adapter via IPC to upload → backfill the platform-returned markdown into the body. Per platform: diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 5c64cc53..4ceebad4 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -40,6 +40,15 @@ export interface PrChannels { request: { localId: string; body: string }; response: PrComment; }; + /** + * Post a file-level comment — anchored to a whole file rather than a specific line (Bitbucket / GitHub). Exposed only + * where the `fileLevelComments` capability is true; `side` defaults to 'new' (the head-side file). On success the main + * side clears the comment cache + broadcasts comments:changed, and the activity / comments panel refetches. + */ + 'comments:createFile': { + request: { localId: string; path: string; side?: 'old' | 'new'; body: string }; + response: PrComment; + }; /** * Delete a remote comment you authored. Bitbucket requires a version (optimistic lock), which the caller takes from an existing PrComment; * mismatch / comment already has replies / not being the author all fail (Bitbucket 409/403). On success the main diff --git a/packages/platform-bitbucket-server/src/features/comment.ts b/packages/platform-bitbucket-server/src/features/comment.ts index 70f3058e..853da15d 100644 --- a/packages/platform-bitbucket-server/src/features/comment.ts +++ b/packages/platform-bitbucket-server/src/features/comment.ts @@ -182,13 +182,15 @@ export class BitbucketCommentService extends BaseCommentService { * Passes through the Bitbucket optimistic-lock version (the caller must carry it back on edit/delete, otherwise 409); an empty anchor means a summary comment. */ private mapBitbucketComment(c: BitbucketComment, anchor?: BitbucketCommentAnchor): PrComment { + const mappedAnchor = anchor ? this.mapBitbucketAnchor(anchor) : null; return { remoteId: String(c.id), author: mapUser(c.author), body: c.text, createdAt: new Date(c.createdDate).toISOString(), updatedAt: new Date(c.updatedDate).toISOString(), - anchor: anchor ? this.mapBitbucketAnchor(anchor) : null, + anchor: mappedAnchor, + kind: mappedAnchor == null ? 'summary' : mappedAnchor.line == null ? 'file' : 'inline', replies: (c.comments ?? []).map((r) => this.mapBitbucketComment(r)), reactions: this.mapReactions(c.properties?.reactions), version: c.version, @@ -221,32 +223,40 @@ export class BitbucketCommentService extends BaseCommentService { /** * Bitbucket comment anchor → neutral anchor. * - * No line number = file-level / orphan anchor, cannot anchor to a specific line → return null (degrade to - * summary); when lineType is occasionally absent, fall back to 'context' (the most conservative value, - * consistent with the publish-anchor fallback). + * No line number = a file-level comment (attached to the whole file) or an orphaned anchor (the anchored line no + * longer exists) → keep it as a **file-level** anchor (path + side, no line) so the UI can still associate it with its + * file, rather than degrading to a summary. When lineType is occasionally absent on a line anchor, fall back to + * 'context' (the most conservative value, consistent with the publish-anchor fallback). */ - private mapBitbucketAnchor(a: BitbucketCommentAnchor): PrCommentAnchor | null { - if (a.line == null) return null; + private mapBitbucketAnchor(a: BitbucketCommentAnchor): PrCommentAnchor { + const side: PrCommentAnchor['side'] = a.fileType === 'FROM' ? 'old' : 'new'; + if (a.line == null) return { path: a.path, side }; return { path: a.path, line: a.line, - side: a.fileType === 'FROM' ? 'old' : 'new', - lineType: (a.lineType?.toLowerCase() ?? 'context') as PrCommentAnchor['lineType'], + side, + lineType: (a.lineType?.toLowerCase() ?? 'context') as NonNullable, }; } /** - * Neutral anchor → Bitbucket REST anchor fields (for publishing inline comments, the reverse of mapBitbucketAnchor). + * Neutral anchor → Bitbucket REST anchor fields (for publishing comments, the reverse of mapBitbucketAnchor). * - * diffType is explicitly set to 'EFFECTIVE', anchoring the comment to the "currently effective diff" rather than a specific commit, so it still follows across subsequent PR pushes. + * A file-level anchor (no line) sends only path + fileType (Bitbucket attaches the comment to the file). diffType is + * explicitly set to 'EFFECTIVE', anchoring the comment to the "currently effective diff" rather than a specific + * commit, so it still follows across subsequent PR pushes. */ private toBBAnchor(a: PrCommentAnchor): BitbucketCommentAnchor { + const fileType: BitbucketCommentAnchor['fileType'] = a.side === 'old' ? 'FROM' : 'TO'; + if (a.line == null) { + return { diffType: 'EFFECTIVE', path: a.path, fileType }; + } return { diffType: 'EFFECTIVE', path: a.path, line: a.line, - lineType: a.lineType.toUpperCase() as BitbucketCommentAnchor['lineType'], - fileType: a.side === 'old' ? 'FROM' : 'TO', + lineType: (a.lineType?.toUpperCase() ?? 'CONTEXT') as BitbucketCommentAnchor['lineType'], + fileType, }; } } diff --git a/packages/platform-bitbucket-server/src/features/connection.ts b/packages/platform-bitbucket-server/src/features/connection.ts index dcfcaa31..8013461f 100644 --- a/packages/platform-bitbucket-server/src/features/connection.ts +++ b/packages/platform-bitbucket-server/src/features/connection.ts @@ -36,6 +36,8 @@ export class BitbucketServerConnection extends BaseConnection { return { reviewStatuses: ['approved', 'needsWork', 'unapproved'], inlineComments: true, + // Bitbucket supports commenting on a whole file (anchor without a line). + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: true, // Comment emoji reactions since 7.x (minimum supported version is 7.0); emoticon supports any emoji → free. diff --git a/packages/platform-bitbucket-server/tests/adapter.test.ts b/packages/platform-bitbucket-server/tests/adapter.test.ts index 411f59c8..8975531c 100644 --- a/packages/platform-bitbucket-server/tests/adapter.test.ts +++ b/packages/platform-bitbucket-server/tests/adapter.test.ts @@ -517,7 +517,7 @@ describe('BitbucketServerAdapter.listPullRequestComments anchor mapping', () => expect(cs[0]!.anchor).toEqual({ path: 'src/a.ts', line: 42, side: 'new', lineType: 'added' }); }); - it('binary / file-level comment anchor with no line/lineType → degrades to anchor=null (no crash)', async () => { + it('file-level comment anchor (no line/lineType) → maps to a file-level anchor (path + side, no line)', async () => { const adapter = makeAdapter( mockFetch( activities([ @@ -536,7 +536,9 @@ describe('BitbucketServerAdapter.listPullRequestComments anchor mapping', () => '1022', ); expect(cs).toHaveLength(1); - expect(cs[0]!.anchor).toBeNull(); + // No line = a file-level comment: keep it anchored to the file (path + side), not degraded to a summary. + expect(cs[0]!.anchor).toEqual({ path: 'assets/logo.png', side: 'new' }); + expect(cs[0]!.kind).toBe('file'); }); it('has line but missing lineType → lineType falls back to context', async () => { diff --git a/packages/platform-github/src/features/comment.ts b/packages/platform-github/src/features/comment.ts index e6d0721a..623a768d 100644 --- a/packages/platform-github/src/features/comment.ts +++ b/packages/platform-github/src/features/comment.ts @@ -165,7 +165,8 @@ export class GitHubCommentService extends BaseCommentService { } /** - * Publish an inline comment: first fetch the PR to get head sha as commit_id, then create a review comment by the anchor (path / line / side). + * Publish a comment anchored to the diff: to a specific line (path / line / side) or, when the anchor has no line, to + * the whole file via `subject_type: "file"` (a file-level comment). First fetches the PR to get head sha as commit_id. */ async publishInlineComment( repo: RepoRef, @@ -176,13 +177,20 @@ export class GitHubCommentService extends BaseCommentService { const prefix = `/repos/${repo.projectKey}/${repo.repoSlug}`; // Inline comments need commit_id = head sha; per the Phase 0 decision, the adapter internally fetches the PR to get head sha const pull = await this.client.get(`${prefix}/pulls/${prId}`); - const created = await this.client.post(`${prefix}/pulls/${prId}/comments`, { - body, - commit_id: pull.head.sha, - path: anchor.path, - line: anchor.line, - side: anchor.side === 'old' ? 'LEFT' : 'RIGHT', - }); + const req = + anchor.line == null + ? { body, commit_id: pull.head.sha, path: anchor.path, subject_type: 'file' as const } + : { + body, + commit_id: pull.head.sha, + path: anchor.path, + line: anchor.line, + side: anchor.side === 'old' ? 'LEFT' : 'RIGHT', + }; + const created = await this.client.post( + `${prefix}/pulls/${prId}/comments`, + req, + ); return this.mapReviewComment(created); } @@ -302,16 +310,20 @@ export class GitHubCommentService extends BaseCommentService { */ private mapReviewComment(c: GhReviewComment, mine?: Set): PrComment { const line = c.line ?? c.original_line ?? null; + const side: PrCommentAnchor['side'] = c.side === 'LEFT' ? 'old' : 'new'; const anchor: PrCommentAnchor | null = line != null ? { path: c.path, line, - side: c.side === 'LEFT' ? 'old' : 'new', + side, // GitHub does not directly give added/removed/context; take a conservative default by side (display-only) - lineType: c.side === 'LEFT' ? 'removed' : 'added', + lineType: side === 'old' ? 'removed' : 'added', } - : null; + : c.subject_type === 'file' + ? // file-level review comment (no line): keep it anchored to the file, don't degrade to summary + { path: c.path, side } + : null; return { remoteId: String(c.id), author: mapUser(c.user), @@ -320,7 +332,7 @@ export class GitHubCommentService extends BaseCommentService { updatedAt: c.updated_at, anchor, replies: [], - kind: 'inline', + kind: anchor != null && anchor.line == null ? 'file' : 'inline', threadId: String(c.id), nativeId: String(c.id), reactions: this.buildReactions(c.reactions, mine), diff --git a/packages/platform-github/src/features/connection.ts b/packages/platform-github/src/features/connection.ts index d00e22f1..6c96b225 100644 --- a/packages/platform-github/src/features/connection.ts +++ b/packages/platform-github/src/features/connection.ts @@ -23,6 +23,8 @@ export class GitHubConnection extends BaseConnection { return { reviewStatuses: ['approved', 'needsWork', 'unapproved'], inlineComments: true, + // GitHub supports file-level review comments (subject_type: "file"). + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: false, // GitHub Reactions API has only a fixed 8 kinds → fixed. diff --git a/packages/platform-github/src/types.ts b/packages/platform-github/src/types.ts index 810e8029..bc6ce74c 100644 --- a/packages/platform-github/src/types.ts +++ b/packages/platform-github/src/types.ts @@ -87,6 +87,8 @@ export interface GhReviewComment { original_line?: number | null; side?: 'LEFT' | 'RIGHT'; start_line?: number | null; + /** 'file' = a file-level review comment (no line); 'line' (default) = anchored to a line. */ + subject_type?: 'line' | 'file'; in_reply_to_id?: number; html_url?: string; reactions?: GhReactionRollup; diff --git a/packages/platform-gitlab/src/features/comment.ts b/packages/platform-gitlab/src/features/comment.ts index cdf4b602..0af5ec62 100644 --- a/packages/platform-gitlab/src/features/comment.ts +++ b/packages/platform-gitlab/src/features/comment.ts @@ -138,6 +138,11 @@ export class GitLabCommentService extends BaseCommentService { anchor: PrCommentAnchor, body: string, ): Promise { + // GitLab has no file-level diff-comment API (position_type is only text/image), so file-level anchors are + // unsupported — the fileLevelComments capability is false, so the UI never offers it; guard defensively here. + if (anchor.line == null) { + throw new Error('GitLab does not support file-level diff comments'); + } const base = `/projects/${projectId(repo)}/merge_requests/${prId}`; // inline comment = discussion with a position; position needs the three base/start/head shas → first fetch the MR to get diff_refs. const mr = await this.client.get(base); diff --git a/packages/platform-gitlab/src/features/connection.ts b/packages/platform-gitlab/src/features/connection.ts index 419c3fcd..abb4ad2e 100644 --- a/packages/platform-gitlab/src/features/connection.ts +++ b/packages/platform-gitlab/src/features/connection.ts @@ -33,6 +33,8 @@ export class GitLabConnection extends BaseConnection { return { reviewStatuses, inlineComments: true, + // GitLab has no file-level diff-comment API (position_type is text/image only) → unsupported. + fileLevelComments: false, inlineMultiline: false, commentOptimisticLock: false, // GitLab Award Emoji supports arbitrary emoji → free. diff --git a/packages/poller/tests/poller.test.ts b/packages/poller/tests/poller.test.ts index 7d3d018f..0856aa05 100644 --- a/packages/poller/tests/poller.test.ts +++ b/packages/poller/tests/poller.test.ts @@ -80,6 +80,7 @@ class FakeAdapter implements PlatformAdapter { capabilities: () => ({ reviewStatuses: ['approved', 'needsWork', 'unapproved'] as const, inlineComments: true, + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: true, commentReactions: 'free' as const, @@ -303,6 +304,7 @@ describe('Poller.tick', () => { capabilities: () => ({ reviewStatuses: ['approved', 'needsWork', 'unapproved'], inlineComments: true, + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: true, commentReactions: 'free', diff --git a/packages/shared/src/platform.ts b/packages/shared/src/platform.ts index b91c6674..20bd6940 100644 --- a/packages/shared/src/platform.ts +++ b/packages/shared/src/platform.ts @@ -183,12 +183,15 @@ export interface PingResult { export interface PrCommentAnchor { /** Current path (for a renamed file, the dst side) */ path: string; - /** Anchor line number */ - line: number; + /** + * Anchor line number. **Absent = a file-level comment** — anchored to the whole file rather than a specific line + * (Bitbucket / GitHub support this; see {@link PlatformCapabilities.fileLevelComments}). + */ + line?: number; /** 'old' = anchor to base / FROM; 'new' = anchor to head / TO */ side: 'old' | 'new'; - /** The diff role of the anchored line */ - lineType: 'added' | 'removed' | 'context'; + /** The diff role of the anchored line; absent for a file-level comment (no line). */ + lineType?: 'added' | 'removed' | 'context'; } /** @@ -510,7 +513,10 @@ export interface PrComment { createdAt: string; /** ISO */ updatedAt: string; - /** null = PR top-level summary comment; set = inline comment anchored to a specific file line */ + /** + * null = PR top-level summary comment; set = anchored to a file — to a specific line (`anchor.line` present, an inline + * comment) or to the whole file (`anchor.line` absent, a file-level comment). + */ anchor: PrCommentAnchor | null; /** Nested replies (Bitbucket uses comment.comments[]) */ replies: PrComment[]; @@ -538,11 +544,12 @@ export interface PrComment { */ canEdit?: boolean; /** - * Comment kind (multi-platform abstraction): 'summary' = PR-level discussion; 'inline' = anchored to a file line. - * Currently whether anchor is null already distinguishes them; this field is the explicit label during normalization for GitHub (issue/review comments split across two APIs) / - * GitLab (note/discussion), convenient for UI and write-back. Optional, not filled for old data. + * Comment kind (multi-platform abstraction): 'summary' = PR-level discussion; 'inline' = anchored to a file line; + * 'file' = anchored to a whole file (no line). Redundant with anchor (null → summary; anchor with line → inline; + * anchor without line → file), but an explicit label during normalization for GitHub (issue/review comments split + * across two APIs) / GitLab (note/discussion), convenient for UI and write-back. Optional, not filled for old data. */ - kind?: 'summary' | 'inline'; + kind?: 'summary' | 'inline' | 'file'; /** * Thread identifier (abstraction of the reply target). Bitbucket=parent comment id, GitHub=review-comment id, * GitLab=discussion id. Passed through to the adapter on reply; Bitbucket currently just uses remoteId. @@ -608,6 +615,12 @@ export interface PlatformCapabilities { reviewStatuses: ReadonlyArray; /** Whether inline comments are supported */ inlineComments: boolean; + /** + * Whether a comment can be anchored to a **whole file** (not a specific line). Bitbucket (anchor without a line) and + * GitHub (`subject_type: "file"`) support it → true; GitLab has no file-level diff-comment API → false (the UI hides + * the "comment on file" entry, and any remote file-level comment degrades to a summary). See {@link PrCommentAnchor}. + */ + fileLevelComments: boolean; /** Whether multi-line inline comments are supported */ inlineMultiline: boolean; /** Whether comment edit/delete requires a version optimistic lock (Bitbucket only) */ From 3d4d8345b30eefc76a065bb1dc7db912291276c1 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 11:56:46 +0800 Subject: [PATCH 05/12] docs(changelog): add Git LFS status + file-level comments under Unreleased Recreate the [Unreleased] section (consumed by the 0.11.0 release) with Added entries for the two features on this branch, in both locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ CHANGELOG.zh-CN.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ad06fe..a36082a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project are recorded here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the versioning follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### ✨ Added + +- 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. + ## [0.11.0] - 2026-07-07 > Highlights of this release: @@ -465,6 +472,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/). License: [Apache-2.0](LICENSE). The package bundles third-party components (pr-agent, Electron, etc.), each distributed under its own license, see [NOTICE](NOTICE). +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...HEAD [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 13ae6ca1..40995a5e 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -5,6 +5,13 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +### ✨ 新增 + +- diff 中的二进制文件(图片、Office 文档、PDF 等)现会显示其 Git LFS 状态——受 LFS 管理的文件显示「Git LFS · <大小>」标记,直接内联存入 git 的文件显示「⚠ 非 LFS」标记。 +- 现可对整个文件(而不仅是某一行)添加评论(平台支持时,Bitbucket / GitHub):diff 中新增「对文件评论」入口,且已存在的文件级评论现能正确归属显示,不再被当作 PR 通用评论。 + ## [0.11.0] - 2026-07-07 > 本次发布要点: @@ -465,6 +472,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...HEAD [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 From 5c6d268d93ca4977b5af1028fa6629930ae2f120 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 12:58:23 +0800 Subject: [PATCH 06/12] style(diff): match file breadcrumb font size to the file tree Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/renderer/src/styles/features/diff/view.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index dcaebbd6..8358d071 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -101,6 +101,8 @@ min-width: 0; overflow: hidden; white-space: nowrap; + // Match the file tree's node font size (.tree-row) rather than the header bar's, since it reads as a path. + font-size: $fs-md; // Tighten so the text line never exceeds the 22px content height that keeps the header bar level with the file tree's. line-height: 1; // Button reset. From 5c0f748325de859120572a7dbc3a8dbf99543bd7 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 13:36:46 +0800 Subject: [PATCH 07/12] fix(diff): align the file breadcrumb bar to the file tree (font size + background) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button reset's `font: inherit` came after `font-size: $fs-md` and reset the size back to the header bar's 12px, so the breadcrumb never matched the file tree node (13px) — order the override after the reset. Also give the breadcrumb bar the tree's $bg-panel background so it reads as one surface with the file tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/renderer/src/styles/features/diff/view.scss | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index 8358d071..3faa8c73 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -76,7 +76,7 @@ max-height: 40%; overflow: hidden; } -// Header bar: mirrors .diff-file-list-header (same padding + divider) so its bottom border lines up with the file tree's. +// Header bar: mirrors .diff-file-list-header (same padding + divider + panel background) so it lines up with the file tree's. .diff-file-comments-head { flex-shrink: 0; display: flex; @@ -86,6 +86,7 @@ padding: $space-3 $space-6; font-size: $fs-sm; color: $text-muted; + background: $bg-panel; border-bottom: 1px solid $border-muted; } // Comments / composer scroll below the header bar within the capped strip. @@ -101,16 +102,17 @@ min-width: 0; overflow: hidden; white-space: nowrap; - // Match the file tree's node font size (.tree-row) rather than the header bar's, since it reads as a path. - font-size: $fs-md; // Tighten so the text line never exceeds the 22px content height that keeps the header bar level with the file tree's. line-height: 1; - // Button reset. + // Button reset. NOTE: `font: inherit` resets font-size too, so the font-size override MUST come after it — otherwise + // the shorthand pulls the size back to the header bar's $fs-sm (12px). padding: 0; background: transparent; border: none; color: inherit; font: inherit; + // Match the file tree's node font size (.tree-row / $fs-md, 13px) rather than the header bar's, since it reads as a path. + font-size: $fs-md; cursor: pointer; &:hover { From 4f484b4b1784053209b6841a2403eea8bdf0aa71 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 9 Jul 2026 19:39:03 +0800 Subject: [PATCH 08/12] feat(agent): support repo_context_files on LocalGitProvider via shim pr-agent 0.39.0 ships a new default `repo_context_files = ["AGENTS.md"]`, but LocalGitProvider (the only provider meebox uses) inherits the base no-op `get_repo_file_content`, so the feature is skipped with a per-run WARNING and never injects project guidance into the review. Patch LocalGitProvider.get_repo_file_content in the version-guarded shim to read the blob from the base branch's tree (`git show :`, never the working tree, so it stays independent of _prepare_repo's /ask worktree sanitizing). Missing files / git errors degrade to "" so build_repo_context treats them as "no context" and caches no fetch error. Result: /review /describe /improve now inject the reviewed repo's AGENTS.md (capped at repo_context_max_lines) as , and the WARNING no longer fires. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../patches/local_git_provider.py | 28 ++++++++++++++++++- docs/arch/02-agent/05-pragent-runtime.md | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py index 809ca1c5..62d153ac 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py @@ -1,4 +1,4 @@ -"""LocalGitProvider patch (version-guarded): binary-safe get_diff_files + get_line_link anchor.""" +"""LocalGitProvider patch (version-guarded): binary-safe get_diff_files + get_line_link anchor + repo-context file fetch.""" from ..runtime import _EXPECTED_PRAGENT_VERSION, _pragent_version, _warn @@ -122,3 +122,29 @@ def get_pr_labels(self, update=False): return [] module.LocalGitProvider.get_pr_labels = get_pr_labels + + # get_repo_file_content: pr-agent 0.39.0's configuration.toml ships a new default + # `repo_context_files = ["AGENTS.md"]` — build_repo_context() fetches those files from the reviewed + # repo and injects them as so /review /describe /improve follow the project's own + # conventions. The base class returns "" (no-op), so LocalGitProvider is judged "does not support + # repository file fetching" and logs a WARNING each run while silently skipping the feature. Implement it + # by reading the blob straight from the base branch's tree object (not the working tree): the review's diff + # is head.commit vs merge-base(target_branch_name), and target_branch_name is the branch the PR merges + # into — the trusted "default/base branch" content the feature wants (repo_context_from_default_branch=true). + # Reading the tree object (never the working tree) also keeps this independent of _prepare_repo's working-tree + # sanitizing of agent instruction files (that guards the /ask CLI subprocess; repo_context serves the other + # tools). A missing file / any git error degrades to "" (no context) rather than raising, so + # build_repo_context treats it as "no context" and never caches a fetch error. + def get_repo_file_content(self, file_path, from_default_branch=False): + rel = (file_path or "").lstrip("/") + if not rel: + return "" + try: + # For a local provider there is no remote "default branch" distinct from the PR base, so both the + # default-branch and target-branch cases collapse to target_branch_name (guaranteed to exist by + # _prepare_repo). `git show :` returns the file text, or errors if the path is absent. + return self.repo.git.show(f"{self.target_branch_name}:{rel}") + except Exception: + return "" + + module.LocalGitProvider.get_repo_file_content = get_repo_file_content diff --git a/docs/arch/02-agent/05-pragent-runtime.md b/docs/arch/02-agent/05-pragent-runtime.md index 6fc56739..b0b904e2 100644 --- a/docs/arch/02-agent/05-pragent-runtime.md +++ b/docs/arch/02-agent/05-pragent-runtime.md @@ -62,6 +62,7 @@ Current patches: letting `/review`'s key_issues render with a structured file:line. - **Anthropic drops temperature**: new Claude models deprecate temperature, so all `anthropic/*` are put into the "don't send temperature" set. - **load_yaml tolerance**: an anchor marker taking a whole line breaks YAML → on parse failure, strip the marker and retry, avoiding a whole review crash. +- **repo-context file fetch**: pr-agent 0.39.0 defaults `repo_context_files = ["AGENTS.md"]`, but `LocalGitProvider` inherits the base no-op `get_repo_file_content` → the feature is skipped with a per-run WARNING. Implement it by reading the blob from the base branch's tree (`git show :`, never the working tree), so `/review /describe /improve` inject the reviewed repo's `AGENTS.md`/etc. as ``; a missing file degrades to `""`. - **Local CLI provider**: when `MEEBOX_CLI_MODE` is set, replace `chat_completion` wholesale with the "call the local CLI" version (see below). - **token usage collection**: see below. From 2d4bb521bbe93275d54670a8966bd5de0c7042e9 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 10 Jul 2026 09:02:06 +0800 Subject: [PATCH 09/12] docs(changelog): note repo-context guidance-file injection under Unreleased Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + CHANGELOG.zh-CN.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a36082a1..3551d7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,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. ## [0.11.0] - 2026-07-07 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 40995a5e..b6267c69 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -11,6 +11,7 @@ - diff 中的二进制文件(图片、Office 文档、PDF 等)现会显示其 Git LFS 状态——受 LFS 管理的文件显示「Git LFS · <大小>」标记,直接内联存入 git 的文件显示「⚠ 非 LFS」标记。 - 现可对整个文件(而不仅是某一行)添加评论(平台支持时,Bitbucket / GitHub):diff 中新增「对文件评论」入口,且已存在的文件级评论现能正确归属显示,不再被当作 PR 通用评论。 +- 评审现会读取被审仓库自身的规范文件(`AGENTS.md`)作为项目上下文,使评审、描述与建议遵循该项目既定的约定。 ## [0.11.0] - 2026-07-07 From 6be1c603226bc1c7c0c609665fd3b97ff942502c Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 14 Jul 2026 10:25:14 +0800 Subject: [PATCH 10/12] 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 11/12] 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'; /** From f2cea7530f1f4b474bd22f556c16227e4a36a405 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 14 Jul 2026 14:30:08 +0800 Subject: [PATCH 12/12] chore(release): 0.11.1 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++-- CHANGELOG.zh-CN.md | 14 ++++++++++++-- apps/desktop/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ae3839..95f4fe7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to this project are recorded here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the versioning follows [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [0.11.1] - 2026-07-14 + +> Highlights of this release: +> +> - **File-level comments**: comment on a whole file, not just a single line, where the platform supports it (Bitbucket / GitHub). +> - **Replies as drafts**: replying to a comment now creates a deferred draft — consistent with a new comment, persisted across switches, and published with the review batch. +> - **Project-aware reviews**: the review now reads the reviewed repository's own `AGENTS.md` as context. +> - **Git LFS status in the diff**: binary files show whether they're Git LFS-managed. +> +> Plus a fix so an in-progress inline comment isn't discarded when the comment list refreshes. ### ✨ Added @@ -478,7 +487,8 @@ and the versioning follows [Semantic Versioning](https://semver.org/). License: [Apache-2.0](LICENSE). The package bundles third-party components (pr-agent, Electron, etc.), each distributed under its own license, see [NOTICE](NOTICE). -[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...HEAD +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.1...HEAD +[0.11.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 36bb5012..f3bd38cf 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -5,7 +5,16 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 -## [Unreleased] +## [0.11.1] - 2026-07-14 + +> 本次发布要点: +> +> - **文件级评论**:可对整个文件(而非仅某一行)添加评论(平台支持时,Bitbucket / GitHub)。 +> - **回复即草稿**:回复评论现会生成延迟草稿——与新建评论一致,跨切换保留,随评审批量发布。 +> - **项目感知的评审**:评审现会读取被审仓库自身的 `AGENTS.md` 作为上下文。 +> - **diff 中的 Git LFS 状态**:二进制文件会显示其是否由 Git LFS 管理。 +> +> 另修复:评论列表刷新时,正在编写的内联评论不再被丢弃。 ### ✨ 新增 @@ -478,7 +487,8 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 -[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...HEAD +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.1...HEAD +[0.11.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a6a6cde3..d21fbfee 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.11.1-dev", + "version": "0.11.1", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/package-lock.json b/package-lock.json index 1b914ae0..7eed3d82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.11.1-dev", + "version": "0.11.1", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1",