From 13be36a9a50a353398d26d41c10e3cf358124f97 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Sun, 5 Jul 2026 17:53:22 +0800 Subject: [PATCH 01/19] docs(release): drop the "Early preview (0.x)" warning from release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 0.10 on, the GitHub Release body no longer carries the early-preview caveat. Removes the banner from RELEASE_NOTES.md — it lived only in the release-notes template (the CHANGELOG and website changelog never had it). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/RELEASE_NOTES.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index 7e9e2d13..c9f3c4d1 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -1,6 +1,3 @@ -> ⚠️ **Early preview (0.x)**: features, configuration, and data formats may change incompatibly, and stability is not fully verified. -> Do not use it for critical production review workflows — assess the risk and back up your data first. - ## What's changed %%CHANGELOG_SECTION%% From e0579a1cf94a1db939ba5e5f76323952badbf536 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Sun, 5 Jul 2026 17:57:18 +0800 Subject: [PATCH 02/19] docs(release): lead with per-language changelog links in release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The website-changelog pointer moves to the top of the Release body and becomes two markdown links — `[English](…) · [简体中文](…)` — each deep-linking to this version on its locale's changelog (EN root, ZH under /zh/). release.yml fills the new `%%CHANGELOG_URL_EN%%` / `%%CHANGELOG_URL_ZH%%` placeholders; sync-docs already emits the `{#v…}` anchor on both locales. packaging-release.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/RELEASE_NOTES.md | 4 ++-- .github/workflows/release.yml | 14 +++++++++----- docs/development/packaging-release.md | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index c9f3c4d1..7689fc73 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -1,9 +1,9 @@ +> 📖 This release on the website changelog: [English](%%CHANGELOG_URL_EN%%) · [简体中文](%%CHANGELOG_URL_ZH%%) + ## What's changed %%CHANGELOG_SECTION%% -> 📖 This version on the website changelog (English · 简体中文): %%CHANGELOG_URL%% - ## Installation The installers are ready to use — the review runtime is bundled in, so **no extra setup is needed after installing**. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b81d76b5..ae55047b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,9 +221,12 @@ jobs: shell: bash run: | VERSION="${GITHUB_REF_NAME#v}" - # Deep-link to this version on the website changelog. The anchor is stable and date-independent: - # sync-docs.mjs injects `{#v}`; build the same slug here from the tag. - CHANGELOG_URL="https://huhamhire.github.io/code-meeseeks/changelog#v$(echo "$VERSION" | tr '.' '-')" + # Deep-link to this version on the website changelog, per language. The anchor is stable and + # date-independent: sync-docs.mjs injects `{#v}` on both locales' pages; + # build the same slug here from the tag. EN is the root locale, ZH lives under /zh/. + SLUG="v$(echo "$VERSION" | tr '.' '-')" + CHANGELOG_EN="https://huhamhire.github.io/code-meeseeks/changelog#${SLUG}" + CHANGELOG_ZH="https://huhamhire.github.io/code-meeseeks/zh/changelog#${SLUG}" # Extract the body between `## [VERSION] ...` and the next `## [` (index==1 does a literal prefix match, avoiding regex metacharacters) awk -v ver="## [$VERSION]" 'index($0,ver)==1{f=1;next} f&&/^## \[/{exit} f{print}' CHANGELOG.md > changelog-section.md if [ -s changelog-section.md ]; then @@ -233,8 +236,9 @@ jobs: echo "::warning::CHANGELOG.md has no [$VERSION] section, Release body falls back to the original RELEASE_NOTES.md" sed 's/%%CHANGELOG_SECTION%%//' .github/RELEASE_NOTES.md > RELEASE_BODY.tmp fi - # Fill the version deep-link (use | as the sed delimiter — the URL contains slashes) - sed "s|%%CHANGELOG_URL%%|${CHANGELOG_URL}|" RELEASE_BODY.tmp > RELEASE_BODY.md + # Fill the per-language version deep-links (use | as the sed delimiter — the URLs contain slashes) + sed -e "s|%%CHANGELOG_URL_EN%%|${CHANGELOG_EN}|" -e "s|%%CHANGELOG_URL_ZH%%|${CHANGELOG_ZH}|" \ + RELEASE_BODY.tmp > RELEASE_BODY.md - name: Upload to Release (single point) uses: softprops/action-gh-release@v2 diff --git a/docs/development/packaging-release.md b/docs/development/packaging-release.md index 2a4f4416..94cd9dd4 100644 --- a/docs/development/packaging-release.md +++ b/docs/development/packaging-release.md @@ -47,7 +47,7 @@ In scope: build / package / sign / produce artifacts / CI. Out of scope: how the Complete these **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: 1. **Version** — set the `version` in [apps/desktop/package.json](../../apps/desktop/package.json) to the target version (drop the `v` prefix; prereleases carry a suffix like `0.5.0-alpha.1`). electron-builder's `artifactName: code-meeseeks-${version}-...` reads this value directly — not updating it means the installer filename won't match the tag. After the change, run `npm install` to sync the lockfile. -2. **CHANGELOG** — the changelog is **bilingual, two files**: [CHANGELOG.md](../../CHANGELOG.md) is the English canonical and [CHANGELOG.zh-CN.md](../../CHANGELOG.zh-CN.md) is the Chinese mirror (**keep both in sync**). In **each** file, rename `## [Unreleased]` to `## [] - ` and add a `[]: …/compare/…` link reference at the bottom. **Releasing consumes Unreleased — leave no empty section**; create a new one (in both files) on the next development-phase changelog change. release.yml extracts the `## []` section **from the English canonical** (CHANGELOG.md) literally to inject into the Release body — the GitHub Release page is **single-language English**, and RELEASE_NOTES.md **deep-links to this version on the bilingual [website changelog](https://huhamhire.github.io/code-meeseeks/changelog)** (built from both files, where readers switch language) for other languages; a missing section means the body falls back with no change notes. The deep-link is a stable, date-independent anchor `changelog#v` (e.g. `0.10.0` → `#v0-10-0`): [sync-docs.mjs](../../website/scripts/sync-docs.mjs) injects that `{#…}` anchor onto each version heading in the site copy (GitHub's CHANGELOG stays clean), and release.yml builds the same slug from the tag to fill RELEASE_NOTES.md's `%%CHANGELOG_URL%%` placeholder — so the two always agree without hand-maintenance. **If the stable release's content comes from a prior alpha/prerelease**: the development phase usually has no separate Unreleased (the content is already in the prerelease section), so just rename that prerelease section to the stable-version section and remove the corresponding `[-alpha.N]:` link reference (the content merges into the stable section, no empty stub left); other prerelease sections with no corresponding stable version are kept. +2. **CHANGELOG** — the changelog is **bilingual, two files**: [CHANGELOG.md](../../CHANGELOG.md) is the English canonical and [CHANGELOG.zh-CN.md](../../CHANGELOG.zh-CN.md) is the Chinese mirror (**keep both in sync**). In **each** file, rename `## [Unreleased]` to `## [] - ` and add a `[]: …/compare/…` link reference at the bottom. **Releasing consumes Unreleased — leave no empty section**; create a new one (in both files) on the next development-phase changelog change. release.yml extracts the `## []` section **from the English canonical** (CHANGELOG.md) literally to inject into the Release body — the GitHub Release page is **single-language English**, and RELEASE_NOTES.md **leads with per-language deep-links to this version on the bilingual [website changelog](https://huhamhire.github.io/code-meeseeks/changelog)** — `[English](…) · [简体中文](…)` markdown links (EN root, ZH under `/zh/`, both landing on this version) — for readers of either language; a missing section means the body falls back with no change notes. The deep-link is a stable, date-independent anchor `changelog#v` (e.g. `0.10.0` → `#v0-10-0`): [sync-docs.mjs](../../website/scripts/sync-docs.mjs) injects that `{#…}` anchor onto each version heading in both locales' site copies (GitHub's CHANGELOG stays clean), and release.yml builds the same slug from the tag to fill RELEASE_NOTES.md's `%%CHANGELOG_URL_EN%%` / `%%CHANGELOG_URL_ZH%%` placeholders — so the two always agree without hand-maintenance. **If the stable release's content comes from a prior alpha/prerelease**: the development phase usually has no separate Unreleased (the content is already in the prerelease section), so just rename that prerelease section to the stable-version section and remove the corresponding `[-alpha.N]:` link reference (the content merges into the stable section, no empty stub left); other prerelease sections with no corresponding stable version are kept. 3. **Proofread** — confirm the `## []` section covers every key point (Added / Changed / Fixed) merged into `dev` since the last version, **in both language files** (the English canonical and the Chinese mirror must not drift). 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. From 8f4eab12fa1dc9dbcf4db04bf75c1788faaa0f7c Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Sun, 5 Jul 2026 18:02:58 +0800 Subject: [PATCH 03/19] docs(release): shorten the changelog link label to "Full changelog" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim the wordy "This release on the website changelog:" lead-in to a concise "Full changelog:" — the language links and the deep-link anchor already carry the rest of the meaning. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/RELEASE_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index 7689fc73..872d9105 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -1,4 +1,4 @@ -> 📖 This release on the website changelog: [English](%%CHANGELOG_URL_EN%%) · [简体中文](%%CHANGELOG_URL_ZH%%) +> 📖 Full changelog: [English](%%CHANGELOG_URL_EN%%) · [简体中文](%%CHANGELOG_URL_ZH%%) ## What's changed From c26bac6508f1ba9c1c180c7efd883e2226cd3325 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Sun, 5 Jul 2026 19:01:57 +0800 Subject: [PATCH 04/19] chore(release): switch dev to 0.10.1-dev Post-0.10.0 development marker: bump apps/desktop to the next patch's -dev prerelease (next release is expected to be a small maintenance version) and sync the lockfile. Not tagged / not released; a new CHANGELOG [Unreleased] section is created on the next development-phase change. 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 fe51ea79..80e831e7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.10.0", + "version": "0.10.1-dev", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/package-lock.json b/package-lock.json index 1e0deeb9..c1459330 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.10.0", + "version": "0.10.1-dev", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1", From ed1d0328ab51f6b0cd42d2aa6724685b1b831d5a Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Mon, 6 Jul 2026 13:02:33 +0800 Subject: [PATCH 05/19] fix(desktop): keep image preview close button clear of window controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zoom-preview close button sat at top: 16px, under the frameless window's native controls (top-right on Windows/Linux, drawn above web content). Drop it below the 36px title-bar zone, and replace the `×` text glyph with the shared CloseIcon SVG so it centers cleanly instead of sitting off-center from the font metrics. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/common/BitbucketImage.tsx | 3 ++- .../renderer/src/styles/common/bitbucket-image.scss | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx b/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx index 0ad08474..92429827 100644 --- a/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx +++ b/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { invoke } from '../../api'; +import { CloseIcon } from './icons'; /** * react-markdown's default url sanitize only allows the http/https/mailto/tel protocols; @@ -173,7 +174,7 @@ function ImageZoomOverlay({ onClick={onClose} aria-label={t('bitbucketImage.closeZoomAria')} > - × + , document.body, diff --git a/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss b/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss index c73a511e..7299fd19 100644 --- a/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss +++ b/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss @@ -48,13 +48,18 @@ } .bitbucket-image-zoom-close { position: absolute; - top: 16px; + // Drop below the 36px frameless title-bar zone: the native window controls (min/max/close on + // Windows/Linux top-right, traffic lights on macOS top-left) are drawn on top of all web content, + // so a top: 16px button would sit under them. 48px clears the overlay on every platform. + top: 48px; right: 24px; width: 36px; height: 36px; - font-size: 24px; - line-height: 32px; - text-align: center; + // Flex-center the CloseIcon SVG (symmetric geometry → true optical center, unlike the old `×` + // text glyph whose font metrics left it off-center). + display: flex; + align-items: center; + justify-content: center; border-radius: 50%; background: rgba(255, 255, 255, 0.12); color: #fff; From 7ee94786e2bd695c57f259484dc962d3708801e1 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Mon, 6 Jul 2026 13:32:42 +0800 Subject: [PATCH 06/19] fix(review): wrap default code suggestion layout in outer brackets The default code_suggestion_layout missed the outer square brackets, so the linked "AI suggestion" label + model prefix rendered unbracketed. Change the default to [[](<HOME>) (<MODEL>)] and sync both the English and Chinese config-reference guides. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/guide/04-config-reference.md | 2 +- docs/guide/zh-CN/04-config-reference.md | 2 +- packages/shared/src/product.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/04-config-reference.md b/docs/guide/04-config-reference.md index 59fbb846..cecfa06e 100644 --- a/docs/guide/04-config-reference.md +++ b/docs/guide/04-config-reference.md @@ -202,7 +202,7 @@ Two optional fields control how AI code suggestions read and how they land in a If the layout contains `<SUGGESTIONS>` it is used as the full comment body; otherwise the whole string is prepended as a prefix before the body. Empty falls back to the default layout: ```text -[<TITLE>](<HOME>) (<MODEL>) +[[<TITLE>](<HOME>) (<MODEL>)] <SUGGESTIONS> ``` diff --git a/docs/guide/zh-CN/04-config-reference.md b/docs/guide/zh-CN/04-config-reference.md index bb1d17c0..46df9eee 100644 --- a/docs/guide/zh-CN/04-config-reference.md +++ b/docs/guide/zh-CN/04-config-reference.md @@ -202,7 +202,7 @@ Agent **无独立启用开关**——配置了 LLM 且 pr-agent 就绪即可用 模板含 `<SUGGESTIONS>` 时作为完整评论正文,否则整段作为前缀拼在正文之前。留空则回退到默认排版: ```text -[<TITLE>](<HOME>) (<MODEL>) +[[<TITLE>](<HOME>) (<MODEL>)] <SUGGESTIONS> ``` diff --git a/packages/shared/src/product.ts b/packages/shared/src/product.ts index 2ae61fc0..007523aa 100644 --- a/packages/shared/src/product.ts +++ b/packages/shared/src/product.ts @@ -12,4 +12,4 @@ export const PRODUCT_HOME_URL = 'https://huhamhire.github.io/code-meeseeks/'; * Single source of truth for both the deterministic fallback (renderCodeSuggestionDraft) and the settings editor's * placeholder, so "placeholder == actual default". Placeholders: `<TITLE>` / `<HOME>` / `<MODEL>` / `<SUGGESTIONS>`. */ -export const DEFAULT_CODE_SUGGESTION_LAYOUT = '[<TITLE>](<HOME>) (<MODEL>)\n<SUGGESTIONS>'; +export const DEFAULT_CODE_SUGGESTION_LAYOUT = '[[<TITLE>](<HOME>) (<MODEL>)]\n<SUGGESTIONS>'; From 066b43549fc3b3ea98ac2d7d1fcbbe176375e8b5 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Mon, 6 Jul 2026 13:38:18 +0800 Subject: [PATCH 07/19] fix(review): enable @mention autocomplete in inline draft comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline draft comments passed candidates={[]} to MentionTextarea, so the @mention menu never opened there (only the activity/reply composers had it). Extract collectMentionCandidates into a shared util, and thread a candidate set (loaded comment authors + the PR author) from DiffView through useDraftZones → DraftZoneList → DraftZone into the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../pr/tabs/activity/ActivityPanel.tsx | 26 +------------ .../features/pr/tabs/diff/DiffView.tsx | 9 +++++ .../features/pr/tabs/diff/DraftZoneList.tsx | 6 ++- .../pr/tabs/diff/hooks/useDraftZones.tsx | 7 +++- .../features/pr/tabs/drafts/DraftZone.tsx | 7 +++- .../pr/tabs/shared/mentionCandidates.ts | 37 +++++++++++++++++++ 6 files changed, 63 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionCandidates.ts diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx index 89e7f6ed..f77e5285 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx @@ -22,6 +22,7 @@ import { PaneLoading, } from '../../../../common'; import { CommentComposer } from '../comments/CommentComposer'; +import { collectMentionCandidates } from '../shared/mentionCandidates'; import { CommentItem, formatExactTime, @@ -297,31 +298,6 @@ export function ActivityPanel({ ); } -/** - * Derives @mention candidate users from loaded comments (recursing into replies) + commits: dedup by name, order-preserving. The candidate source deliberately takes only - * participants who already appear in this PR (bounded, zero extra fetches, doesn't enumerate everyone from the remote), a safe data source for @ autocomplete. - */ -function collectMentionCandidates(comments: PrComment[], commits: PrCommit[]): PlatformUser[] { - const seen = new Set<string>(); - const out: PlatformUser[] = []; - const push = (u: PlatformUser | undefined): void => { - if (!u?.name || seen.has(u.name)) return; - seen.add(u.name); - out.push(u); - }; - const walk = (list: PrComment[]): void => { - for (const c of list) { - push(c.author); - if (c.replies.length > 0) walk(c.replies); - } - }; - walk(comments); - for (const cm of commits) { - push(cm.author); - push(cm.committer); - } - return out; -} /** A commit event on the timeline: commit icon + short SHA + subject + author + time; clickable to jump to the remote commit page. */ function CommitEvent({ 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 df539a90..43aa298e 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 @@ -11,6 +11,7 @@ import type { } from '@meebox/shared'; import { useDraftsForPr } from '../../../../../stores/drafts-store'; import { ErrorBoundary, PaneLoading, FileTreeIcon, SearchIcon } from '../../../../common'; +import { collectMentionCandidates } from '../shared/mentionCandidates'; import { DiffSearchPanel } from './DiffSearchPanel'; import { FileTree } from './FileTree'; import { DiffScopeSelect } from './DiffScopeSelect'; @@ -186,6 +187,13 @@ export function DiffView({ } }, [pr.url, pr.repo.projectKey, pr.repo.repoSlug]); + // @mention candidates for inline draft editing: same bounded/safe source as the activity composer — loaded comment + // authors (incl. replies) seeded with the PR author (who may not have commented). No extra fetches; the user can still type any @name. + const mentionCandidates = useMemo( + () => collectMentionCandidates(comments, [], [pr.author]), + [comments, pr.author], + ); + // For the file tree: path → number of comments anchored to that file (including dual-path aliases + renamed oldPath) const commentCountByPath = useMemo(() => { const m = new Map<string, number>(); @@ -243,6 +251,7 @@ export function DiffView({ renderSideBySide, commentHardBreaks, attachmentsEnabled, + mentionCandidates, scopeKind: scope.kind, }); // Line hover '+' to create a new draft (not mounted in the commit read-only view) diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx index fc480c97..f8f710b3 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import type { ReviewDraft } from '@meebox/shared'; +import type { PlatformUser, ReviewDraft } from '@meebox/shared'; import { invoke } from '../../../../../api'; import { formatBackendError } from '../../../../../errors'; import { DraftZone } from '../drafts/DraftZone'; @@ -16,6 +16,7 @@ export function DraftZoneList({ registerEditTrigger, hardBreaks, attachmentsEnabled = false, + mentionCandidates, }: { drafts: ReviewDraft[]; prLocalId: string; @@ -23,6 +24,8 @@ export function DraftZoneList({ hardBreaks: boolean; /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the draft editor to enable paste / pick upload. */ attachmentsEnabled?: boolean; + /** `@mention` autocomplete candidates for the draft editor (bounded PR participants; see collectMentionCandidates). */ + mentionCandidates?: PlatformUser[]; }) { const { t } = useTranslation(); const onSave = async (draftId: string, body: string): Promise<void> => { @@ -58,6 +61,7 @@ export function DraftZoneList({ prLocalId={prLocalId} attachmentsEnabled={attachmentsEnabled} hardBreaks={hardBreaks} + mentionCandidates={mentionCandidates} registerEditTrigger={registerEditTrigger} onSave={(body) => onSave(d.id, body)} onDelete={() => onDelete(d.id)} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx index 78454682..6ccaf010 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { type editor as MonacoEditor } from 'monaco-editor'; -import type { ReviewDraft } from '@meebox/shared'; +import type { PlatformUser, ReviewDraft } from '@meebox/shared'; import type { DiffChangedFile } from '@meebox/ipc'; import { DraftZoneList } from '../DraftZoneList'; import { mountInlineZones } from '../zones/mountInlineZones'; @@ -24,6 +24,8 @@ export function useDraftZones(opts: { commentHardBreaks: boolean; /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the draft editor to enable paste / picker upload. */ attachmentsEnabled?: boolean; + /** `@mention` autocomplete candidates for the draft editor (bounded PR participants; see collectMentionCandidates). */ + mentionCandidates?: PlatformUser[]; scopeKind: 'all' | 'commit'; }): void { const { @@ -36,6 +38,7 @@ export function useDraftZones(opts: { renderSideBySide, commentHardBreaks, attachmentsEnabled = false, + mentionCandidates, scopeKind, } = opts; @@ -86,6 +89,7 @@ export function useDraftZones(opts: { registerEditTrigger={registerEditTrigger} hardBreaks={commentHardBreaks} attachmentsEnabled={attachmentsEnabled} + mentionCandidates={mentionCandidates} /> ), }); @@ -101,6 +105,7 @@ export function useDraftZones(opts: { renderSideBySide, commentHardBreaks, attachmentsEnabled, + mentionCandidates, scopeKind, ]); } diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx index 7aa225b4..bee0cf96 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import type { ReviewDraft } from '@meebox/shared'; +import type { PlatformUser, ReviewDraft } from '@meebox/shared'; import { ConfirmModal, TrashIcon } from '../../../../common'; import { MentionTextarea } from '../shared/MentionTextarea'; import { uploadCommentImage } from '../shared/uploadCommentImage'; @@ -16,6 +16,8 @@ interface DraftZoneProps { attachmentsEnabled?: boolean; /** Comment line-break policy (active platform commentHardBreaks): decides whether the preview enables remark-breaks, making the draft preview WYSIWYG. */ hardBreaks: boolean; + /** `@mention` autocomplete candidates for the editor (bounded PR participants; see collectMentionCandidates). Empty/undefined = no completion menu, but manual `@name` still works. */ + mentionCandidates?: PlatformUser[]; /** * Register the "enter edit mode" trigger fn into an external ref map. When DiffView calls the * registered fn, this component setIsEditing(true). Uses a ref-based fn instead of a props token to @@ -47,6 +49,7 @@ export function DraftZone({ prLocalId, attachmentsEnabled = false, hardBreaks, + mentionCandidates, registerEditTrigger, onSave, onDelete, @@ -148,7 +151,7 @@ export function DraftZone({ className="draft-zone-textarea" value={editingBody} onChange={setEditingBody} - candidates={[]} + candidates={mentionCandidates ?? []} onKeyDown={onKeyDown} onUpload={ attachmentsEnabled ? (f) => uploadCommentImage(prLocalId, f) : undefined diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionCandidates.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionCandidates.ts new file mode 100644 index 00000000..7a872a12 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionCandidates.ts @@ -0,0 +1,37 @@ +import type { PlatformUser, PrComment, PrCommit } from '@meebox/shared'; + +/** + * Collect a bounded, deduped `@mention` candidate set from data already loaded for a PR — comment authors + * (including nested replies) + commit authors/committers, plus any explicitly seeded users (e.g. the PR author). + * Deduped by `name`, order preserved (seeds first). No extra remote fetches, and only this PR's participants + * are exposed (not an enumeration of all remote members); the user can still type any `@name` by hand. + * + * Shared by the activity timeline composer (comments + commits) and the inline diff draft editor (comments + + * PR author) so both offer the same completion set. + */ +export function collectMentionCandidates( + comments: readonly PrComment[], + commits: readonly PrCommit[], + seed: readonly PlatformUser[] = [], +): PlatformUser[] { + const seen = new Set<string>(); + const out: PlatformUser[] = []; + const push = (u: PlatformUser | undefined): void => { + if (!u?.name || seen.has(u.name)) return; + seen.add(u.name); + out.push(u); + }; + for (const u of seed) push(u); + const walk = (list: readonly PrComment[]): void => { + for (const c of list) { + push(c.author); + if (c.replies.length > 0) walk(c.replies); + } + }; + walk(comments); + for (const cm of commits) { + push(cm.author); + push(cm.committer); + } + return out; +} From 22fdbd4208fa19c3b24758832769ced94ab36e5f Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Mon, 6 Jul 2026 13:49:08 +0800 Subject: [PATCH 08/19] fix(review): disambiguate the "no security concern" verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-agent emits the security verdict in many surface forms (a terse "否" / "No", the English template phrase, or a localized "未发现安全风险"), so the card sometimes read as a bare, ambiguous "安全 · 否". Normalize any no-concern verdict at parse time to pr-agent's canonical English phrase, which the renderer's label dictionary maps to one unambiguous localized string per language. A real concern (descriptive body) is left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/poller/src/parse-output.ts | 29 ++++++++++++++++++++++ packages/poller/tests/parse-output.test.ts | 29 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/poller/src/parse-output.ts b/packages/poller/src/parse-output.ts index ef681798..0a3bf21d 100644 --- a/packages/poller/src/parse-output.ts +++ b/packages/poller/src/parse-output.ts @@ -518,6 +518,27 @@ function inferAnchorFromIssueText(text: string): FindingAnchor | undefined { return undefined; } +/** pr-agent's canonical English "no security concern" verdict; the renderer's label dictionary maps it per language. */ +const NO_SECURITY_CONCERN_CANONICAL = 'No security concerns identified'; + +/** + * Whether a security-section verdict text means "no concern". pr-agent emits this verdict in many surface forms + * across versions / response languages — a terse "否" / "No", the English template phrase, or a localized + * "未发现安全风险" — and a bare "安全: 否" reads ambiguously ("unsafe?" vs "no issues?"). Matches only when the + * WHOLE value is a negative verdict (a terse pure negative, or a short "无/未发现…安全/风险…" phrasing bounded in + * length), so a descriptive real concern that merely starts with such a word is not swallowed. + */ +function isNoSecurityConcern(text: string): boolean { + const s = text.replace(/[\s。.!!,,、;;::]+$/u, '').trim(); + if (!s) return false; + // Terse pure negatives (the whole value) + if (/^(?:否|无|无风险|没有|未发现|不适用|no|none|n\/?a|nil)$/i.test(s)) return true; + // Short "no … security/risk …" phrasing (whole value, bounded so a long real concern isn't matched) + if (/^(?:未发现|没有|无|不存在)[^,,。.;;]{0,12}(?:安全|风险|问题|隐患)[^,,。.;;]{0,8}$/u.test(s)) return true; + if (/^no\s+(?:security\s+)?concerns?(?:\s+identified)?$/i.test(s)) return true; + return false; +} + /** * Parse a single markdown segment into a Finding. Recognizes pr-agent's common * `**File:** path` + `**Lines:** N-M` pattern → code-feedback; otherwise returns general / description. @@ -583,6 +604,14 @@ export function sectionToFinding(sec: Section, index: number, tool: ReviewRunToo } } + // Security verdict normalization: collapse every "no concern" surface form (verdict lives in the body for the + // `Security concerns: <value>` shape, or in the title for a standalone `No security concerns identified` strong) + // to the single canonical phrase, so the card always reads unambiguously (e.g. zh "安全 · 未发现安全风险") instead + // of a bare "安全 · 否". A real concern (descriptive body) doesn't match and passes through untouched. + if (mappedKey === 'security' && isNoSecurityConcern(body.trim() || displayTitle || '')) { + return { id, category: 'general', sectionKey: 'security', body: NO_SECURITY_CONCERN_CANONICAL }; + } + return { id, category: tool === 'describe' ? 'description' : 'general', diff --git a/packages/poller/tests/parse-output.test.ts b/packages/poller/tests/parse-output.test.ts index e2bc0c97..0e26e369 100644 --- a/packages/poller/tests/parse-output.test.ts +++ b/packages/poller/tests/parse-output.test.ts @@ -479,7 +479,34 @@ describe('parseReviewOutput', () => { const { findings } = parseReviewOutput(md, 'review'); // the old implementation only recognized "Relevant tests"/"Security concerns"; these two common conclusions would degrade to general (no chip/coloring) expect(findings.find((f) => /tests/i.test(f.title ?? ''))?.sectionKey).toBe('relevant-tests'); - expect(findings.find((f) => /security/i.test(f.title ?? ''))?.sectionKey).toBe('security'); + // the standalone "No security concerns" strong is classified as security and its verdict normalized into the canonical body (title cleared). + const sec = findings.find((f) => f.sectionKey === 'security'); + expect(sec?.body).toBe('No security concerns identified'); + }); + + it('GFM /review: every "no concern" security verdict (terse / template / localized) collapses to one canonical phrase', () => { + const build = (verdict: string): string => + [ + '<table>', + `<tr><td>🔒 <strong>Security concerns</strong>: ${verdict}</td></tr>`, + '</table>', + ].join('\n'); + for (const verdict of ['否', 'No', 'N/A', '无风险', '未发现安全风险', '未发现明显安全风险']) { + const { findings } = parseReviewOutput(build(verdict), 'review'); + const sec = findings.find((f) => f.sectionKey === 'security'); + expect(sec?.body).toBe('No security concerns identified'); + } + }); + + it('GFM /review: a real security concern is not swallowed by the no-concern normalization', () => { + const md = [ + '<table>', + '<tr><td>🔒 <strong>Security concerns</strong>: 存在 SQL 注入风险,用户输入未过滤后拼接查询</td></tr>', + '</table>', + ].join('\n'); + const { findings } = parseReviewOutput(md, 'review'); + const sec = findings.find((f) => f.sectionKey === 'security'); + expect(sec?.body).toContain('SQL 注入'); }); }); From 5c73ea2cb972eb91a14a6e5d5b017c064afbda67 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Mon, 6 Jul 2026 14:03:35 +0800 Subject: [PATCH 09/19] fix(review): quote Bitbucket @mentions with non-simple usernames Bitbucket Server resolves a mention only when a username containing characters like a dot is double-quoted (@"first.last"); the bare form rendered as plain text with no notification. Centralize the mention syntax in a shared formatMention helper (Bitbucket quotes non-simple usernames, GitHub/GitLab stay bare) and thread the active platform into the comment, reply, and inline-draft editors so autocomplete inserts the platform-correct token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../pr/tabs/activity/ActivityPanel.tsx | 1 + .../pr/tabs/comments/CommentComposer.tsx | 6 +++- .../features/pr/tabs/comments/CommentItem.tsx | 1 + .../pr/tabs/comments/CommentReplyEditor.tsx | 6 +++- .../features/pr/tabs/diff/DiffView.tsx | 1 + .../features/pr/tabs/diff/DraftZoneList.tsx | 6 +++- .../pr/tabs/diff/hooks/useDraftZones.tsx | 7 ++++- .../features/pr/tabs/drafts/DraftZone.tsx | 6 +++- .../pr/tabs/shared/MentionTextarea.tsx | 11 ++++++-- packages/shared/src/index.ts | 1 + packages/shared/src/mention.ts | 28 +++++++++++++++++++ 11 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/mention.ts diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx index f77e5285..9baa9ea8 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx @@ -277,6 +277,7 @@ export function ActivityPanel({ <CommentComposer prLocalId={pr.localId} mentionCandidates={mentionCandidates} + platform={pr.platform} attachmentsEnabled={attachmentsEnabled} onCancel={() => onComposeClose?.()} onPosted={() => onComposeClose?.()} 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 b7ab3869..f4ad6981 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 @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { PlatformUser } from '@meebox/shared'; +import type { PlatformKind, PlatformUser } from '@meebox/shared'; import { invoke } from '../../../../../api'; import { MentionTextarea } from '../shared/MentionTextarea'; import { uploadCommentImage } from '../shared/uploadCommentImage'; @@ -9,6 +9,8 @@ interface CommentComposerProps { prLocalId: string; /** `@mention` autocomplete candidates (PR participants + comment authors, derived by the parent from loaded data). */ mentionCandidates?: PlatformUser[]; + /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ + platform?: PlatformKind; /** Whether the platform supports image attachment upload (capabilities.commentAttachments); paste-to-upload is enabled only when true. */ attachmentsEnabled?: boolean; onCancel: () => void; @@ -23,6 +25,7 @@ interface CommentComposerProps { export function CommentComposer({ prLocalId, mentionCandidates = [], + platform, attachmentsEnabled = false, onCancel, onPosted, @@ -66,6 +69,7 @@ export function CommentComposer({ value={body} onChange={setBody} candidates={mentionCandidates} + platform={platform} onKeyDown={onKeyDown} onUpload={attachmentsEnabled ? (f) => uploadCommentImage(prLocalId, f) : undefined} placeholder={t('commentComposer.placeholder')} 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 99423a43..d8b94690 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 @@ -264,6 +264,7 @@ export function CommentItem({ // Reply target abstraction (threadId): GitLab=discussion id (required for reply); Bitbucket empty / GitHub=remoteId → fall back to remoteId. parentCommentId={comment.threadId ?? comment.remoteId} mentionCandidates={mentionCandidates} + platform={pr.platform} attachmentsEnabled={attachmentsEnabled} onCancel={() => setReplyOpen(false)} onPosted={() => setReplyOpen(false)} 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 48ff98e4..fd142fb4 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 { PlatformUser } from '@meebox/shared'; +import type { PlatformKind, PlatformUser } from '@meebox/shared'; import { invoke } from '../../../../../api'; import { MentionTextarea } from '../shared/MentionTextarea'; import { uploadCommentImage } from '../shared/uploadCommentImage'; @@ -10,6 +10,8 @@ interface CommentReplyEditorProps { parentCommentId: string; /** `@mention` autocomplete candidates (PR participants + comment authors). */ mentionCandidates?: PlatformUser[]; + /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ + platform?: PlatformKind; /** Whether the platform supports image attachment upload; paste upload is enabled only when true. */ attachmentsEnabled?: boolean; onCancel: () => void; @@ -25,6 +27,7 @@ export function CommentReplyEditor({ prLocalId, parentCommentId, mentionCandidates = [], + platform, attachmentsEnabled = false, onCancel, onPosted, @@ -68,6 +71,7 @@ export function CommentReplyEditor({ value={body} onChange={setBody} candidates={mentionCandidates} + platform={platform} onKeyDown={onKeyDown} onUpload={attachmentsEnabled ? (f) => uploadCommentImage(prLocalId, f) : undefined} placeholder={t('commentReplyEditor.placeholder')} 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 43aa298e..e7893731 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 @@ -252,6 +252,7 @@ export function DiffView({ commentHardBreaks, attachmentsEnabled, mentionCandidates, + platform: pr.platform, scopeKind: scope.kind, }); // Line hover '+' to create a new draft (not mounted in the commit read-only view) diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx index f8f710b3..c8c98940 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import type { PlatformUser, ReviewDraft } from '@meebox/shared'; +import type { PlatformKind, PlatformUser, ReviewDraft } from '@meebox/shared'; import { invoke } from '../../../../../api'; import { formatBackendError } from '../../../../../errors'; import { DraftZone } from '../drafts/DraftZone'; @@ -17,6 +17,7 @@ export function DraftZoneList({ hardBreaks, attachmentsEnabled = false, mentionCandidates, + platform, }: { drafts: ReviewDraft[]; prLocalId: string; @@ -26,6 +27,8 @@ export function DraftZoneList({ attachmentsEnabled?: boolean; /** `@mention` autocomplete candidates for the draft editor (bounded PR participants; see collectMentionCandidates). */ mentionCandidates?: PlatformUser[]; + /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ + platform?: PlatformKind; }) { const { t } = useTranslation(); const onSave = async (draftId: string, body: string): Promise<void> => { @@ -62,6 +65,7 @@ export function DraftZoneList({ attachmentsEnabled={attachmentsEnabled} hardBreaks={hardBreaks} mentionCandidates={mentionCandidates} + platform={platform} registerEditTrigger={registerEditTrigger} onSave={(body) => onSave(d.id, body)} onDelete={() => onDelete(d.id)} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx index 6ccaf010..59c94126 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { type editor as MonacoEditor } from 'monaco-editor'; -import type { PlatformUser, ReviewDraft } from '@meebox/shared'; +import type { PlatformKind, PlatformUser, ReviewDraft } from '@meebox/shared'; import type { DiffChangedFile } from '@meebox/ipc'; import { DraftZoneList } from '../DraftZoneList'; import { mountInlineZones } from '../zones/mountInlineZones'; @@ -26,6 +26,8 @@ export function useDraftZones(opts: { attachmentsEnabled?: boolean; /** `@mention` autocomplete candidates for the draft editor (bounded PR participants; see collectMentionCandidates). */ mentionCandidates?: PlatformUser[]; + /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ + platform?: PlatformKind; scopeKind: 'all' | 'commit'; }): void { const { @@ -39,6 +41,7 @@ export function useDraftZones(opts: { commentHardBreaks, attachmentsEnabled = false, mentionCandidates, + platform, scopeKind, } = opts; @@ -90,6 +93,7 @@ export function useDraftZones(opts: { hardBreaks={commentHardBreaks} attachmentsEnabled={attachmentsEnabled} mentionCandidates={mentionCandidates} + platform={platform} /> ), }); @@ -106,6 +110,7 @@ export function useDraftZones(opts: { commentHardBreaks, attachmentsEnabled, mentionCandidates, + platform, scopeKind, ]); } diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx index bee0cf96..0f7bad46 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import type { PlatformUser, ReviewDraft } from '@meebox/shared'; +import type { PlatformKind, PlatformUser, ReviewDraft } from '@meebox/shared'; import { ConfirmModal, TrashIcon } from '../../../../common'; import { MentionTextarea } from '../shared/MentionTextarea'; import { uploadCommentImage } from '../shared/uploadCommentImage'; @@ -18,6 +18,8 @@ interface DraftZoneProps { hardBreaks: boolean; /** `@mention` autocomplete candidates for the editor (bounded PR participants; see collectMentionCandidates). Empty/undefined = no completion menu, but manual `@name` still works. */ mentionCandidates?: PlatformUser[]; + /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ + platform?: PlatformKind; /** * Register the "enter edit mode" trigger fn into an external ref map. When DiffView calls the * registered fn, this component setIsEditing(true). Uses a ref-based fn instead of a props token to @@ -50,6 +52,7 @@ export function DraftZone({ attachmentsEnabled = false, hardBreaks, mentionCandidates, + platform, registerEditTrigger, onSave, onDelete, @@ -152,6 +155,7 @@ export function DraftZone({ value={editingBody} onChange={setEditingBody} candidates={mentionCandidates ?? []} + platform={platform} onKeyDown={onKeyDown} onUpload={ attachmentsEnabled ? (f) => uploadCommentImage(prLocalId, f) : undefined diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx index f9afe221..333a215d 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { PlatformUser } from '@meebox/shared'; +import { formatMention, type PlatformKind, type PlatformUser } from '@meebox/shared'; import { ImageIcon } from '../../../../common'; /** Max number of suggestions shown in the popup (the source is already a bounded set of PR participants; truncate further to keep the list from growing too long). */ @@ -41,6 +41,7 @@ export function MentionTextarea({ value, onChange, candidates, + platform, onKeyDown, onUpload, placeholder, @@ -54,6 +55,11 @@ export function MentionTextarea({ value: string; onChange: (v: string) => void; candidates: PlatformUser[]; + /** + * Active platform, deciding the inserted mention syntax (see formatMention): Bitbucket quotes non-simple + * usernames like `@"first.last"`. Omitted → GitHub-style bare `@name` (safe default; only affects insertion, not typing). + */ + platform?: PlatformKind; onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; /** * Upload callback for pasted images: returns insertable markdown on success (otherwise null). When provided, enables image paste upload @@ -116,7 +122,8 @@ export function MentionTextarea({ const select = (user: PlatformUser): void => { if (!menu) return; const end = menu.at + 1 + menu.query.length; - const insert = `@${user.name} `; + // Platform-aligned mention syntax (Bitbucket quotes non-simple usernames); trailing space added here. + const insert = `${platform ? formatMention(platform, user) : `@${user.name}`} `; const next = value.slice(0, menu.at) + insert + value.slice(end); onChange(next); setMenu(null); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5aba15aa..17ff6442 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './config.js'; export * from './error-code.js'; export * from './inline-comment-policy.js'; export * from './language.js'; +export * from './mention.js'; export * from './platform.js'; export * from './poller-contract.js'; export * from './pr-agent-status.js'; diff --git a/packages/shared/src/mention.ts b/packages/shared/src/mention.ts new file mode 100644 index 00000000..2d521dc5 --- /dev/null +++ b/packages/shared/src/mention.ts @@ -0,0 +1,28 @@ +/** + * Platform-aligned `@mention` token formatting for comment bodies. The mention syntax differs by platform, so the + * insertion form (used by the comment / reply / inline-draft editors' autocomplete) is centralized here rather than + * hardcoded `@name` at each call site. + */ +import type { PlatformKind, PlatformUser } from './platform.js'; + +/** Whether an identifier is a "simple" mention token that needs no quoting (letters / digits / `_` / `-` only). */ +function isSimpleMentionId(id: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(id); +} + +/** + * Format a user as an `@mention` token to insert into a comment body, per platform syntax. No trailing space is + * added — the caller decides surrounding whitespace. + * + * - **Bitbucket Server / Data Center**: mentions resolve by username; when the username contains characters beyond + * `[A-Za-z0-9_-]` (notably a dot, e.g. `first.last`) the token must be double-quoted — `@"first.last"` — otherwise + * the server does not link it to the user (it renders as plain text and sends no notification). + * - **GitHub / GitLab**: a bare `@username` is used as-is (their usernames never contain characters needing quoting). + */ +export function formatMention(platform: PlatformKind, user: Pick<PlatformUser, 'name'>): string { + const id = user.name; + if (platform === 'bitbucket-server' && !isSimpleMentionId(id)) { + return `@"${id}"`; + } + return `@${id}`; +} From af6b452436cb1826548e0cd2e397bdd7a616720a Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Mon, 6 Jul 2026 14:12:02 +0800 Subject: [PATCH 10/19] fix(diff): measure inline comment width from Monaco layout info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a commented file from the tree could render the inline comment box at the wrong width (spilling past the editor) until a manual window resize. The width was computed from getBoundingClientRect arithmetic that mixed the editor's DOM rect with the zone's scrolled position — unreliable during init / a file switch. Derive it instead from Monaco's authoritative getLayoutInfo() (width - contentLeft - verticalScrollbarWidth), which is correct as soon as the editor has laid out. A self-terminating rAF settle loop plus the layout/resize/diff observers re-apply the formula until the geometry stabilizes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../pr/tabs/diff/zones/mountInlineZones.ts | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) 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 7ad632aa..e2173d49 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 @@ -119,34 +119,46 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi setTimeout(syncHeight, 50); setTimeout(syncHeight, 200); - // Width + position strategy (aligned with Bitbucket / GitHub inline comments): use BoundingClientRect to get the - // browser's actual render coordinates (clientWidth / layoutInfo.width occasionally exceed the editor's visual - // boundary in monaco inline view, observed the comment box spilling into the ChatPane area). inner starts at the - // dom origin and extends at most to the editor's visual right boundary - verticalScrollbar. When dom isn't yet - // attached to the DOM tree rect.width=0, fall back to the editor's left boundary. + // 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(); - const applyInnerLayout = (): void => { - if (!editorDomNode) return; - const editorRect = editorDomNode.getBoundingClientRect(); - if (editorRect.width <= 0) return; // editor not laid out yet, wait for the next trigger - const domRect = dom.getBoundingClientRect(); - const sbW = editorInst.getLayoutInfo().verticalScrollbarWidth ?? 0; - const innerLeft = domRect.width > 0 ? domRect.left : editorRect.left; - const innerRight = editorRect.right - sbW; - const w = Math.max(0, innerRight - innerLeft); - if (w > 0) { - inner.style.marginLeft = '0'; - inner.style.width = `${w}px`; - inner.style.maxWidth = `${w}px`; + // 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(); - // Multi-point fallback: on file switch + autoEdit jump, monaco is still computing the diff / the file mount isn't - // done, and getBoundingClientRect gives a non-stable layout (observed the box width blowing out when jumping to a new file, recovering after resize). - requestAnimationFrame(applyInnerLayout); - setTimeout(applyInnerLayout, 50); - setTimeout(applyInnerLayout, 200); - setTimeout(applyInnerLayout, 500); + 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); @@ -183,6 +195,9 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi () => diffDisp.dispose(), () => editorRO?.disconnect(), () => scrollDisp.dispose(), + () => { + if (settleRaf) cancelAnimationFrame(settleRaf); + }, ], }); } From b1717efcf94ea240f055090899782e136c3f49d4 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Mon, 6 Jul 2026 19:56:29 +0800 Subject: [PATCH 11/19] fix(pragent): stop single-line hunks showing the old value as still present pr-agent renders a single-line file change (VERSION 1.5.2 -> 1.5.3, a hash/checksum file, any hunk whose old side is exactly one line) so the review model sees the old value as a still-present unchanged line, then wrongly reports the file contains both the old and new value. Root cause: git omits the hunk line-count when it is 1 (`@@ -1 +1 @@`), and pr-agent's extract_hunk_headers coerces the omitted size to 0 instead of 1; process_patch_lines' trailing-context slice then re-reads the changed line itself and appends it as a phantom unchanged line into __new hunk__. Add a version-guarded, self-disabling shim patch that defaults an omitted hunk size to 1 (an explicit 0, e.g. the new-file @@ -0,0 +1 @@ case, is left untouched). The shim probes the live function and no-ops if upstream ever fixes it; a maintenance note documents the removal/upgrade checklist. Verified against the vendored runtime: the phantom line is gone and normal multi-line hunks are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../meebox_pragent_shim/__init__.py | 8 ++ .../patches/git_patch_processing.py | 87 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py index d3e86794..9a57b442 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py @@ -10,6 +10,7 @@ applied it silently degrades, never letting a shim exception block the flow. """ from .patches.describe_assessment import patch as _patch_describe_assessment +from .patches.git_patch_processing import patch as _patch_git_patch_processing from .patches.litellm_handler import patch as _patch_litellm_handler from .patches.load_yaml import patch as _patch_load_yaml from .patches.local_git_provider import patch as _patch_local_git_provider @@ -39,4 +40,11 @@ def apply() -> None: "pr_agent.tools.pr_description", _patch_describe_assessment, ) + # git patch processing: fix the phantom "unchanged" line on single-line hunks (omitted hunk size defaulted to 0 + # instead of 1), which made single-line file changes look like they contained both the old and new value. Self- + # disabling once upstream fixes it — see patches/git_patch_processing.py. + _register_post_import( + "pr_agent.algo.git_patch_processing", + _patch_git_patch_processing, + ) _debug("meebox shim loaded") diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py new file mode 100644 index 00000000..bfbd56a0 --- /dev/null +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py @@ -0,0 +1,87 @@ +"""git_patch_processing patch (version-guarded, self-disabling): fix phantom "unchanged" line on single-line hunks. + +=============================================================================================================== +MAINTENANCE NOTE — remove this shim once pr-agent fixes the bug upstream. +--------------------------------------------------------------------------------------------------------------- +Bug: pr-agent renders a single-line file change (e.g. VERSION `1.5.2` -> `1.5.3`, a hash/checksum file, any +hunk whose OLD side is exactly one line) so the model sees the OLD value as a still-present, unchanged line — +it then wrongly reports "the file now contains both the old and new value". + +Root cause (in pr_agent/algo/git_patch_processing.py): + 1. git omits the hunk line-count when it is 1, so a single-line change emits `@@ -1 +1 @@` + (which per the unified-diff spec means `@@ -1,1 +1,1 @@`). + 2. `extract_hunk_headers` coerces every missing regex group to 0, so the omitted size becomes size1=0 + (it should be 1). + 3. `process_patch_lines`' trailing-context slice `file_original_lines[start1 + size1 - 1 : ...]` then reads + from `1 + 0 - 1 = 0` — i.e. re-reads the CHANGED line itself — and appends it as a phantom unchanged + context line, which `decouple_and_convert_to_hunks_with_lines_numbers` places into `__new hunk__`. + +Fix: default an OMITTED hunk size to 1 (spec-correct) instead of 0, keeping an EXPLICIT 0 (e.g. `@@ -0,0 +1 @@` +new-file case) as 0. This makes the trailing-context slice start after the hunk, so no phantom line is emitted. + +Upstream status (as of pr-agent 0.36.0): still present on `main`; no upstream issue/PR addresses it (PRs #2322 / +#2330 guard a DIFFERENT None — a fully malformed `@@` line where `match` itself is None — not the size default). + +Removal / upgrade checklist: + * This patch is BOTH version-guarded (only applies to the pinned _EXPECTED_PRAGENT_VERSION) AND self-disabling + (it probes the live `extract_hunk_headers`; if upstream already returns size1=1 for `@@ -1 +1 @@`, it does + nothing). So on a pr-agent bump, re-verify: if the probe reports the bug is gone, DELETE this file and its + registration in `../__init__` apply(); if it persists, bump _EXPECTED_PRAGENT_VERSION and keep it. + * Reproduce quickly against the vendored runtime with `MEEBOX_SHIM_DEBUG=1` (the probe logs which branch it took). +=============================================================================================================== +""" +from ..runtime import _EXPECTED_PRAGENT_VERSION, _debug, _pragent_version, _warn + + +def patch(module) -> None: + # Version guard: this reimplementation mirrors the pinned pr-agent's extract_hunk_headers structure; on a + # version mismatch skip rather than risk a wrong reimplementation (safe degradation, consistent with the + # other shim patches). The warning is the "pay attention on upgrade" signal. + installed = _pragent_version() + if installed != _EXPECTED_PRAGENT_VERSION: + _warn( + f"pr-agent {installed} does not match the {_EXPECTED_PRAGENT_VERSION} that the meebox patch is " + "adapted for; git_patch_processing patch skipped (single-line-hunk phantom-line fix disabled). If " + "this is an intentional upgrade, sync runtime.py's _EXPECTED_PRAGENT_VERSION + pragent-runtime.json, " + "re-verify whether upstream has fixed the omitted-hunk-size bug, and remove this shim if so." + ) + return + + orig_extract = module.extract_hunk_headers + re_hunk_header = module.RE_HUNK_HEADER + + # Self-disable if upstream already fixed it: git omits the size for `@@ -1 +1 @@`, so a correct implementation + # yields size1 == 1. If the live function already returns a non-zero size here, the bug is gone — do nothing. + probe = re_hunk_header.match("@@ -1 +1 @@") + try: + _, probe_size1, _, _, _ = orig_extract(probe) + except Exception as exc: # noqa: BLE001 - unexpected upstream signature change → skip, don't crash the run + _debug(f"git_patch_processing: extract_hunk_headers probe failed ({exc}); patch skipped") + return + if probe_size1 != 0: + _debug( + "git_patch_processing: upstream already defaults an omitted hunk size correctly " + "(probe size1=%r); phantom-line shim skipped — safe to delete this file" % probe_size1 + ) + return + + def extract_hunk_headers(match): + # Faithful mirror of upstream extract_hunk_headers, with ONE correction: an omitted hunk line-count + # (regex group is None) defaults to 1 per the unified-diff spec — git drops ",N" only when N == 1 — + # instead of upstream's blanket 0. An EXPLICIT 0 (present in the text, e.g. the `@@ -0,0 +1 @@` new-file + # case) is untouched because its group is "0", not None. Only the two size groups (indices 1 and 3) get + # the size default; any other missing group keeps the original 0 fallback. + res = list(match.groups()) + for i in range(len(res)): + if res[i] is None: + res[i] = 1 if i in (1, 3) else 0 + try: + start1, size1, start2, size2 = map(int, res[:4]) + except (ValueError, TypeError): # '@@ -0,0 +1 @@' case (mirrors upstream's bare except) + start1, size1, size2 = map(int, res[:3]) + start2 = 0 + section_header = res[4] + return section_header, size1, size2, start1, start2 + + module.extract_hunk_headers = extract_hunk_headers + _debug("git_patch_processing: applied single-line-hunk phantom-line fix (omitted hunk size -> 1)") From 2eaa76ef126a70df6e9ede490c011320b76eadd5 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Mon, 6 Jul 2026 20:22:58 +0800 Subject: [PATCH 12/19] fix(repo-mirror): re-auth git mirror with a fresh token, never persist it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rotating a Bitbucket PAT, syncing an existing repo mirror failed with `fatal: Authentication failed` until the cache dir was cleared: the token was baked into remote.origin.url at clone time, and every later `git fetch origin` reused that stale token. getCloneUrl (fresh token) was consulted only for a brand-new clone. Re-assemble the authenticated URL fresh from configured inputs for each remote op and fetch by explicit URL instead of the stored origin (syncMirror + fetchRefspecs), so a rotated token applies immediately. Store only a credential-free origin URL (new stripGitCredentials helper) — never persist the token in .git/config, and strip it from historical mirrors best-effort. Existing token-baked mirrors keep working without a re-clone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../repo-mirror/src/repo-mirror-manager.ts | 45 ++++++++++++- .../tests/repo-mirror-manager.test.ts | 63 ++++++++++++++++++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/repo-mirror/src/repo-mirror-manager.ts b/packages/repo-mirror/src/repo-mirror-manager.ts index 782e9c97..46acc269 100644 --- a/packages/repo-mirror/src/repo-mirror-manager.ts +++ b/packages/repo-mirror/src/repo-mirror-manager.ts @@ -161,7 +161,10 @@ export class RepoMirrorManager { return; } try { - await this.withProxyEnv(simpleGit({ baseDir: mp })).raw(['fetch', 'origin', ...refspecs]); + // Fetch by a freshly re-assembled authenticated URL (not the stored origin), so a rotated token applies + // here too — origin may still carry a historical/stale token on older mirrors. + const url = await this.opts.getCloneUrl(repo); + await this.withProxyEnv(simpleGit({ baseDir: mp })).raw(['fetch', url, ...refspecs]); } catch (err) { this.opts.logger?.debug( { @@ -627,6 +630,16 @@ export class RepoMirrorManager { if (hasMirror) { try { this.opts.logger?.debug({ repo: key }, 'mirror exists, fetching'); + // Re-assemble the authenticated URL fresh every sync and fetch by explicit URL rather than via the + // stored `origin` remote: a rotated token then takes effect immediately. Historically the token was + // baked into remote.origin.url at clone time, so after a PAT rotation an existing mirror kept fetching + // with the stale token → `fatal: Authentication failed` until the cache dir was cleared. + const url = await this.opts.getCloneUrl(repo); + // Best-effort: rewrite origin to the credential-free URL. Strips a token any historically-cached mirror + // baked in (security), and normalizes origin for the health check; the fetch below does not depend on it. + await simpleGit({ baseDir: mirrorPath }) + .raw(['remote', 'set-url', 'origin', stripGitCredentials(url)]) + .catch(() => undefined); // Explicit refspec, force-overwrite fetch: // - refs/heads/*: all branches (including the PR target and source branches) // - refs/pull-requests/*/from: Bitbucket stores the PR source sha separately here. @@ -636,7 +649,7 @@ export class RepoMirrorManager { await this.withProxyEnv(simpleGit({ baseDir: mirrorPath, ...gitProgressOpt })).raw([ 'fetch', '--progress', - 'origin', + url, '+refs/heads/*:refs/heads/*', '+refs/pull-requests/*/from:refs/pull-requests/*/from', ]); @@ -675,6 +688,13 @@ export class RepoMirrorManager { '--no-hardlinks', '--progress', ]); + // Never persist the token: `git clone` writes the authenticated URL into remote.origin.url, so immediately + // rewrite origin to the credential-free form. Subsequent fetches re-assemble the authenticated URL fresh + // (see the fetch path above), so a later token rotation needs no cache clear. Best-effort — a failure here + // doesn't invalidate the freshly-cloned mirror. + await simpleGit({ baseDir: mirrorPath }) + .raw(['remote', 'set-url', 'origin', stripGitCredentials(url)]) + .catch(() => undefined); // On systems like Windows the FS may still be flushing after a fresh clone, and the immediately following git diff // can hit a "refs/packs state inconsistent" error. Wait until git itself can rev-parse HEAD // stably a few times before returning, so the mirror the caller receives is guaranteed usable. @@ -717,6 +737,27 @@ export class RepoMirrorManager { } } +/** + * Strip any embedded credentials from a git remote URL. The authenticated clone URL carries the PAT + * (`https://<user>:<token>@host/...`); we never want that token persisted into the mirror's `.git/config` + * (a plaintext-secret / security concern) nor baked into `remote.origin.url` where it goes stale on the next + * token rotation (the historical "Authentication failed until you clear the cache" bug). Instead the + * authenticated URL is re-assembled fresh (from configured repo + upstream) for each remote op, and only this + * credential-free form is stored. scp-like SSH remotes (`git@host:proj/repo.git`) aren't valid URL() inputs and + * carry no embedded token, so they're returned unchanged. + */ +export function stripGitCredentials(url: string): string { + try { + const u = new URL(url); + if (!u.username && !u.password) return url; + u.username = ''; + u.password = ''; + return u.toString(); + } catch { + return url; + } +} + /** * Whether the error message points to a **locally corrupt / incomplete mirror** (rather than a retryable remote issue like network / auth / remote rejection). After a fetch failure * this decides whether to "delete and re-clone" as a self-heal — self-heal only on local corruption, avoiding a pointless full rebuild from misjudging a transient network issue as corruption. diff --git a/packages/repo-mirror/tests/repo-mirror-manager.test.ts b/packages/repo-mirror/tests/repo-mirror-manager.test.ts index 25721b5d..7e18299f 100644 --- a/packages/repo-mirror/tests/repo-mirror-manager.test.ts +++ b/packages/repo-mirror/tests/repo-mirror-manager.test.ts @@ -8,6 +8,7 @@ import { parseBlamePorcelain, parseHunkAddedLines, parseMergeTreeConflictsZ, + stripGitCredentials, } from '../src/repo-mirror-manager.js'; import type { RepoIdentity } from '../src/types.js'; @@ -85,6 +86,39 @@ describe('RepoMirrorManager.syncMirror', () => { expect(log.total).toBeGreaterThanOrEqual(2); }); + it('fetch re-assembles the URL fresh instead of reusing the stored origin (token rotation)', async () => { + // Simulate a token rotation: getCloneUrl returns upstreamA for the clone, then a DIFFERENT upstreamB for the + // fetch. If the fetch reused the stored origin (upstreamA) it would miss upstreamB's new commit; the fix fetches + // by the freshly re-assembled URL, so the rotated location/token takes effect without clearing the cache. + const upstreamB = path.join(tmpRoot, 'upstream-b'); + await simpleGit(tmpRoot).clone(upstreamPath, upstreamB); // shares history with upstreamA + const bGit = simpleGit(upstreamB); + await bGit.addConfig('user.email', 'test@example.com', false, 'local'); + await bGit.addConfig('user.name', 'Test', false, 'local'); + await bGit.addConfig('commit.gpgsign', 'false', false, 'local'); + await fs.writeFile(path.join(upstreamB, 'ROTATED.md'), 'after rotate'); + await bGit.add('.'); + await bGit.commit('after rotate'); + const sha2 = (await bGit.revparse(['HEAD'])).trim(); + + let call = 0; + const mgr = new RepoMirrorManager({ + reposDir, + getCloneUrl: () => Promise.resolve(++call === 1 ? upstreamPath : upstreamB), + }); + await mgr.syncMirror(repo); // clone from upstreamA + const r = await mgr.syncMirror(repo); // fetch — must use the fresh URL (upstreamB), not stored origin + + expect(r.freshClone).toBe(false); // fetched, did not fall back to a self-heal re-clone + // upstreamB's new commit is only reachable if the fetch used the fresh URL + expect(await mgr.hasCommit(repo, sha2)).toBe(true); + // origin was migrated to the credential-free URL, and never left pointing at a stale one + const originUrl = ( + await simpleGit(r.mirrorPath).raw(['config', '--get', 'remote.origin.url']) + ).trim(); + expect(originUrl).toBe(upstreamB); + }); + it('dedups concurrent syncMirror calls for the same repo (shared in-flight)', async () => { let urlCalls = 0; const mgr = new RepoMirrorManager({ @@ -119,7 +153,9 @@ describe('RepoMirrorManager.syncMirror', () => { await mgr.syncMirror(repo); // after one sync completes, call again; should trigger a new sync (a fetch here, since the mirror already exists) await mgr.syncMirror(repo); - expect(urlCalls).toBe(1); // clone only once; fetch does not go through getCloneUrl + // clone (1) + fetch (2): the fetch now re-assembles the authenticated URL fresh via getCloneUrl (instead of + // reusing the stored origin), so a rotated token takes effect without clearing the cache. + expect(urlCalls).toBe(2); }); it('serializes syncMirror across different repos via global queue', async () => { @@ -501,3 +537,28 @@ describe('RepoMirrorManager.materializeWorktree', () => { } }); }); + +describe('stripGitCredentials', () => { + it('strips user + token from an https clone URL', () => { + expect(stripGitCredentials('https://alice:PAT123@bb.example.com/scm/FX/fx-help.git')).toBe( + 'https://bb.example.com/scm/FX/fx-help.git', + ); + }); + + it('strips a token even when only a username (or only a password) is present', () => { + expect(stripGitCredentials('https://x-token-auth:tok@host/o/r.git')).toBe('https://host/o/r.git'); + expect(stripGitCredentials('https://tokenonly@host/o/r.git')).toBe('https://host/o/r.git'); + }); + + it('leaves a credential-free https URL unchanged', () => { + expect(stripGitCredentials('https://bb.example.com/scm/FX/fx-help.git')).toBe( + 'https://bb.example.com/scm/FX/fx-help.git', + ); + }); + + it('leaves an scp-like ssh remote unchanged (not a URL, no embedded token)', () => { + expect(stripGitCredentials('git@bb.example.com:FX/fx-help.git')).toBe( + 'git@bb.example.com:FX/fx-help.git', + ); + }); +}); From 773af9f56d6c4c6707c4404825df1f1420093b75 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Mon, 6 Jul 2026 21:53:59 +0800 Subject: [PATCH 13/19] docs(changelog): record 0.10.1-dev fixes since 0.10.0 Add an Unreleased section (EN canonical + ZH mirror) covering the fixes merged after 0.10.0: @mention in inline drafts, Bitbucket mention quoting, unambiguous no-security-concern verdict, single-line hunk misread, inline comment width on tree-open, image-preview close button, stale-token mirror fetch, and the default suggestion-layout brackets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 14 ++++++++++++++ CHANGELOG.zh-CN.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8103eef7..5c865b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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] + +### 🔧 Fixed + +- Inline review-draft comments now offer `@mention` participant autocomplete, matching the activity and reply composers. +- `@mentions` of a Bitbucket username containing special characters (e.g. a dot) are now inserted in the correctly quoted form, so the mention resolves and notifies the user. +- The review "Security" result now reads unambiguously when there are no concerns (a consistent "no security concerns") instead of a terse "No" that could be mistaken for a warning. +- Reviews of single-line file changes (e.g. a `VERSION` or hash file) no longer misread the change as the file containing both the old and new value. +- Inline comments no longer render at the wrong width (spilling past the editor) when a commented file is opened from the file tree — a manual window resize is no longer needed. +- The close button on the enlarged comment-image preview no longer sits beneath the window controls, and its icon is centered. +- Fetching upstream code no longer fails with an authentication error after a code-platform access token is changed (which previously required clearing the repo cache to recover). +- The default AI-suggestion draft layout again wraps its linked label and model name in brackets (the intended `[label (model)]` form). + ## [0.10.0] - 2026-07-05 > Highlights of this release: @@ -435,6 +448,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.10.0...HEAD [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 [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 65a8d36d..c0d17f36 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -5,6 +5,19 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +### 🔧 修复 + +- 行内评审草稿评论现支持 `@` 提及参与者的自动补全,与活动 / 回复输入框一致。 +- Bitbucket 用户名包含特殊字符(如点号)时,`@` 提及会以正确的引号形式插入,确保提及生效并通知到对方。 +- 评审「安全」结论在无风险时表达更清晰(统一为「未发现安全风险」),不再是易被误读为告警的简短「否」。 +- 单行文件变更(如 `VERSION` 或哈希文件)的评审不再把改动误读为文件同时包含新旧两个值。 +- 通过文件树打开带评论的文件时,行内评论不再出现宽度计算错误(超出编辑器区域),也无需再手动缩放窗口恢复。 +- 放大的评论图片预览:关闭按钮不再被窗口控件遮挡,且图标居中显示。 +- 更换代码平台访问令牌后,拉取上游代码不再因鉴权失败而报错(此前需清理仓库缓存才能恢复)。 +- 默认的 AI 建议草稿排版重新以方括号包裹其链接标签与模型名(恢复为预期的 `[标签 (模型)]` 形式)。 + ## [0.10.0] - 2026-07-05 > 本版重点: @@ -435,6 +448,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...HEAD [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 [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 From 0f4a448e2a3e0b93a54ae85f1bb7d0d54d0af368 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Tue, 7 Jul 2026 10:43:43 +0800 Subject: [PATCH 14/19] chore(pragent): upgrade embedded runtime to 0.39.0, bump dev to 0.11.0-dev Bump the pinned pr-agent from 0.36.0 to 0.39.0 (runtime manifest + _EXPECTED_PRAGENT_VERSION in lockstep so the version-guarded shims stay active). All five shim patches remain compatible and none are removable: the patched functions are unchanged (git_patch_processing, load_yaml) or only touched in orthogonal ways (local_git_provider write_output; litellm Databricks/GPT-5 routing), and the underlying gaps are still present upstream (single-line-hunk size, new-Claude temperature, community-edition describe assessment). Verified against the freshly assembled runtime: all shims apply and behave correctly. Also switch dev to 0.11.0-dev (the engine upgrade warrants a minor over a patch) and record the change in the CHANGELOG (EN + ZH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 4 ++++ CHANGELOG.zh-CN.md | 4 ++++ apps/desktop/package.json | 2 +- apps/desktop/scripts/pragent-runtime.json | 2 +- .../meebox_pragent_shim/patches/git_patch_processing.py | 5 +++-- .../scripts/pragent-shim/meebox_pragent_shim/runtime.py | 2 +- package-lock.json | 2 +- 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c865b5e..25a98e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and the versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### ♻️ Changed + +- Updated the embedded review engine (pr-agent) to 0.39.0, picking up upstream model-provider routing improvements and fixes. + ### 🔧 Fixed - Inline review-draft comments now offer `@mention` participant autocomplete, matching the activity and reply composers. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index c0d17f36..053b2555 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -7,6 +7,10 @@ ## [Unreleased] +### ♻️ 变更 + +- 将内置评审引擎(pr-agent)升级至 0.39.0,纳入上游的模型服务商路由改进与修复。 + ### 🔧 修复 - 行内评审草稿评论现支持 `@` 提及参与者的自动补全,与活动 / 回复输入框一致。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 80e831e7..b93da4c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.10.1-dev", + "version": "0.11.0-dev", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/apps/desktop/scripts/pragent-runtime.json b/apps/desktop/scripts/pragent-runtime.json index 88f7c75c..0216b6ef 100644 --- a/apps/desktop/scripts/pragent-runtime.json +++ b/apps/desktop/scripts/pragent-runtime.json @@ -8,6 +8,6 @@ }, "prAgent": { "_comment": "嵌入式运行时安装的 pr-agent 版本(pinned);须与 sitecustomize.py 的 _EXPECTED_PRAGENT_VERSION 对齐,升级时两处同步", - "version": "0.36.0" + "version": "0.39.0" } } diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py index bfbd56a0..cfe29630 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/git_patch_processing.py @@ -19,8 +19,9 @@ Fix: default an OMITTED hunk size to 1 (spec-correct) instead of 0, keeping an EXPLICIT 0 (e.g. `@@ -0,0 +1 @@` new-file case) as 0. This makes the trailing-context slice start after the hunk, so no phantom line is emitted. -Upstream status (as of pr-agent 0.36.0): still present on `main`; no upstream issue/PR addresses it (PRs #2322 / -#2330 guard a DIFFERENT None — a fully malformed `@@` line where `match` itself is None — not the size default). +Upstream status (re-checked at pr-agent 0.39.0): still present — `extract_hunk_headers` is byte-identical to +0.36.0 and still coerces the omitted size to 0; no upstream issue/PR addresses it (PRs #2322 / #2330 guard a +DIFFERENT None — a fully malformed `@@` line where `match` itself is None — not the size default). Removal / upgrade checklist: * This patch is BOTH version-guarded (only applies to the pinned _EXPECTED_PRAGENT_VERSION) AND self-disabling diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py index 967b27ad..5e5ea899 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py @@ -15,7 +15,7 @@ # skipped (safe degradation, preferring to apply fewer patches over applying wrong ones). # When upgrading pr-agent: sync this constant + prAgent.version in scripts/pragent-runtime.json # (the assemble script verifies the two match and extracts this constant from this file), and re-verify patch behavior. -_EXPECTED_PRAGENT_VERSION = "0.36.0" +_EXPECTED_PRAGENT_VERSION = "0.39.0" # System context "cache break" marker: assembleSystemContext (TS, packages/agent/src/assemble.ts) inserts this string # (along with the --- separators on both sides) between the **globally stable prefix** (SOUL/AGENTS/tool directory/memory/user diff --git a/package-lock.json b/package-lock.json index c1459330..a2636a52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.10.1-dev", + "version": "0.11.0-dev", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1", From c96e8ca066dd06665cd856a452ed13da6a6549c3 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Tue, 7 Jul 2026 11:32:44 +0800 Subject: [PATCH 15/19] feat(desktop): standardize displayed timestamps to a locale-independent format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Displayed timestamps were inconsistent — some hardcoded 24h numeric, others via toLocaleString() with no locale arg, which followed the OS locale rather than the app language (so a Japanese UI on a Chinese OS showed Chinese-format times, and formats differed between machines). Standardize on one house format: 24-hour, ISO-ordered yyyy-mm-dd HH:mm:ss, with same-day timestamps showing only HH:mm:ss (the date is redundant for "today"); exact-time hover tooltips always include the date. Relative time ("3 minutes ago") for recent items is preserved. This is unambiguous and sortable regardless of language/OS — the right fit for an engineering tool (mirrors GitHub's relative-first + neutral-absolute approach, without their locale-driven absolute that many users can't override). The pure formatters (formatTimestamp/formatDate) live in @meebox/shared so they are unit-tested (the desktop app has no test target); the renderer re-exports them and every display site is migrated off toLocale*. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 1 + CHANGELOG.zh-CN.md | 1 + .../chat/components/RunResultView.tsx | 4 +- .../features/chat/components/RunningView.tsx | 4 +- .../components/features/chat/utils/format.ts | 31 +++------- .../features/pr/statusbar/LastSyncChip.tsx | 4 +- .../features/pr/tabs/CommitsPanel.tsx | 5 +- .../features/pr/tabs/PrInfoView.tsx | 7 ++- .../features/pr/tabs/comments/CommentItem.tsx | 12 ++-- .../pr/tabs/diff/blame/BlameColumn.tsx | 3 +- .../inline-comments/InlineCommentZone.tsx | 5 +- apps/desktop/src/renderer/src/utils/time.ts | 9 ++- packages/shared/src/index.ts | 1 + packages/shared/src/time-format.ts | 35 ++++++++++++ packages/shared/tests/time-format.test.ts | 56 +++++++++++++++++++ 15 files changed, 131 insertions(+), 47 deletions(-) create mode 100644 packages/shared/src/time-format.ts create mode 100644 packages/shared/tests/time-format.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a98e2d..46fd9193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/). ### ♻️ Changed - Updated the embedded review engine (pr-agent) to 0.39.0, picking up upstream model-provider routing improvements and fixes. +- Displayed timestamps now use a consistent, locale-independent format — 24-hour `yyyy-mm-dd HH:mm:ss`, with same-day times showing only `HH:mm:ss` — instead of following the OS locale (so they no longer differ from the app's language or between machines). ### 🔧 Fixed diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 053b2555..7b4ca9f0 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -10,6 +10,7 @@ ### ♻️ 变更 - 将内置评审引擎(pr-agent)升级至 0.39.0,纳入上游的模型服务商路由改进与修复。 +- 界面中展示的时间戳统一为与地区无关的固定格式——24 小时制 `yyyy-mm-dd HH:mm:ss`,当天时间仅显示 `HH:mm:ss`——不再跟随操作系统区域设置(因而不会与应用语言不一致,也不会在不同机器上呈现不同格式)。 ### 🔧 修复 diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx index 40f4634d..2387d75c 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { Finding, FindingClosure, ReviewDraft, ReviewRun } from '@meebox/shared'; import { CommitIcon, RepeatIcon, RetryIcon, ShareIcon, TrashIcon } from '../../../common'; import { orderFindings } from '../utils/findings'; -import { formatStartTime, runStatusLabel } from '../utils/format'; +import { formatStartTime, formatTimestamp, runStatusLabel } from '../utils/format'; import { extractTokenUsage, type TokenUsage } from '../utils/tokens'; import { AnsiPre, AskQuestion, BreakablePath, TokenStat } from './shared'; import { FindingCard } from './FindingCard'; @@ -78,7 +78,7 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { tool/status/strategy chips, one visual weight lighter than a chip */} <span className="chat-run-time" - title={t('chatPane.startedAtTitle', { time: new Date(run.startedAt).toLocaleString() })} + title={t('chatPane.startedAtTitle', { time: formatTimestamp(run.startedAt, { full: true }) })} > {formatStartTime(run.startedAt)} </span> diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx index 5ede0534..5835d065 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ReviewRunCommitScope, ReviewRunTool } from '@meebox/shared'; import { CommitIcon } from '../../../common'; -import { formatElapsed, formatStartTime, inferPhase, runStatusLabel } from '../utils/format'; +import { formatElapsed, formatStartTime, formatTimestamp, inferPhase, runStatusLabel } from '../utils/format'; import { AnsiPre, AskQuestion, Spinner } from './shared'; export function RunningView({ @@ -81,7 +81,7 @@ export function RunningView({ across the running and succeeded states */} <span className="chat-run-time" - title={t('chatPane.startedAtTitle', { time: new Date(startedAt).toLocaleString() })} + title={t('chatPane.startedAtTitle', { time: formatTimestamp(startedAt, { full: true }) })} > {formatStartTime(startedAt)} </span> diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts index a612d549..cc48cc1f 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts @@ -1,8 +1,9 @@ import type { TFunction } from 'i18next'; import type { ReviewRun } from '@meebox/shared'; -// Duration formatting lives in utils/time (status-bar compact version uses the compact option); re-exported here so chat components import it nearby. -export { formatElapsed } from '../../../../utils/time'; +// Duration / timestamp formatting live in utils/time; re-exported here so chat components import them nearby. +export { formatElapsed, formatTimestamp } from '../../../../utils/time'; +import { formatTimestamp } from '../../../../utils/time'; /** 1234 → "1.2k"; keeps 1 decimal place; < 1000 returns the number as-is */ export function formatTokens(n: number): string { @@ -24,29 +25,13 @@ export function runStatusLabel(status: ReviewRun['status'], t: TFunction): strin } /** - * Format a timestamp as "HH:MM:SS" (same day) or "MM-DD HH:MM" (crossing days). - * The user mainly cares about "which run"; second granularity is enough to tell - * adjacent runs apart; runs from another day get a date marker so the history run - * list groups them at a glance. Accepts an ISO string (persisted - * ReviewRun.startedAt) or a millisecond timestamp (RunningView side passes the - * Date.getTime() of the parsed ISO) + * Format a review run's start time in the house format: same day → "HH:mm:ss", other days → + * "yyyy-mm-dd HH:mm:ss". The user mainly cares about "which run"; second granularity tells adjacent runs apart, + * and runs from another day carry the date so the history list groups them at a glance. Accepts an ISO string + * (persisted ReviewRun.startedAt) or a millisecond timestamp (RunningView passes the parsed ISO's getTime()). */ export function formatStartTime(input: string | number): string { - const d = new Date(input); - const now = new Date(); - const sameDay = - d.getFullYear() === now.getFullYear() && - d.getMonth() === now.getMonth() && - d.getDate() === now.getDate(); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - if (sameDay) { - const ss = String(d.getSeconds()).padStart(2, '0'); - return `${hh}:${mm}:${ss}`; - } - const mo = String(d.getMonth() + 1).padStart(2, '0'); - const da = String(d.getDate()).padStart(2, '0'); - return `${mo}-${da} ${hh}:${mm}`; + return formatTimestamp(input); } /** diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx index ce1baa72..4bbb2f7a 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SyncIcon, StatusChip } from '../../../common'; -import { formatRelative } from '../../../../utils/time'; +import { formatRelative, formatTimestamp } from '../../../../utils/time'; /** * Refresh button + sync status combined: a clickable chip showing the last sync relative time + sync icon @@ -28,7 +28,7 @@ export function LastSyncChip({ const title = refreshing ? t('statusBar.refreshing') : date - ? t('statusBar.lastSyncTitle', { time: date.toLocaleString() }) + ? t('statusBar.lastSyncTitle', { time: formatTimestamp(date, { full: true }) }) : t('statusBar.neverSyncedTitle'); return ( <StatusChip diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx index fa3f6ff3..6a6469d0 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx @@ -6,6 +6,7 @@ import { invoke } from '../../../../api'; import { formatBackendError, type FormattedError } from '../../../../errors'; import { Avatar } from '../../../common'; import { formatExactTime } from './comments/CommentItem'; +import { formatDate } from '../../../../utils/time'; interface CommitsPanelProps { pr: StoredPullRequest; @@ -151,7 +152,5 @@ function formatCommitTime(iso: string, t: TFunction): string { if (diffSec < 86400) return t('commitsPanel.hoursAgo', { count: Math.round(diffSec / 3600) }); if (diffSec < 86400 * 7) return t('commitsPanel.daysAgo', { count: Math.round(diffSec / 86400) }); // Older than a week shows yyyy-mm-dd, avoiding the vagueness of "X weeks ago" - const d = new Date(parsed); - const pad = (n: number): string => String(n).padStart(2, '0'); - return `${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + return formatDate(parsed); } diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx index 4153a75f..b14e6ae7 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { StoredPullRequest } from '@meebox/shared'; import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown'; +import { formatTimestamp } from '../../../../utils/time'; import { Avatar, makeBitbucketImageFor, @@ -69,11 +70,11 @@ export function PrInfoView({ pr }: PrInfoViewProps) { <h3>{t('prInfo.timeline')}</h3> <div className="pr-detail-kv"> <div className="modal-kv-key">{t('prInfo.createdAt')}</div> - <div className="modal-kv-val">{new Date(pr.createdAt).toLocaleString()}</div> + <div className="modal-kv-val">{formatTimestamp(pr.createdAt, { full: true })}</div> <div className="modal-kv-key">{t('prInfo.updatedAt')}</div> - <div className="modal-kv-val">{new Date(pr.updatedAt).toLocaleString()}</div> + <div className="modal-kv-val">{formatTimestamp(pr.updatedAt, { full: true })}</div> <div className="modal-kv-key">{t('prInfo.recentUpdate')}</div> - <div className="modal-kv-val">{new Date(pr.lastSeenAt).toLocaleString()}</div> + <div className="modal-kv-val">{formatTimestamp(pr.lastSeenAt, { full: true })}</div> </div> </section> 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 d8b94690..b4c57c5f 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 @@ -2,6 +2,7 @@ import { lazy, Suspense, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { PlatformUser, PrComment, PrCommentAnchor, StoredPullRequest } from '@meebox/shared'; import i18n from '../../../../../i18n'; +import { formatDate, formatTimestamp } from '../../../../../utils/time'; import { Avatar, makeBitbucketImageFor, @@ -382,14 +383,11 @@ export function CommentItem({ } /** - * Local time text to the second (`YYYY-MM-DD HH:mm:ss`), for the time label's hover tooltip to show the actual time point. + * Exact local time (`yyyy-mm-dd HH:mm:ss`, always with the date) for the time label's hover tooltip — the + * tooltip's whole job is precise disambiguation, so it never omits the date. */ export function formatExactTime(iso: string): string { - const ms = Date.parse(iso); - if (Number.isNaN(ms)) return iso; - const d = new Date(ms); - const pad = (n: number): string => String(n).padStart(2, '0'); - return `${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + return formatTimestamp(iso, { full: true }); } /** @@ -406,5 +404,5 @@ export function formatRelativeTime(iso: string): string { return i18n.t('commentsPanel.hoursAgo', { count: Math.round(diffSec / 3600) }); if (diffSec < 86400 * 7) return i18n.t('commentsPanel.daysAgo', { count: Math.round(diffSec / 86400) }); - return new Date(t).toLocaleDateString(); + return formatDate(t); } diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx index b264b912..a67378d8 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { type editor as MonacoEditor } from 'monaco-editor'; import type { DiffBlameLine } from '@meebox/ipc'; import { Avatar } from '../../../../../common'; +import { formatTimestamp } from '../../../../../../utils/time'; import { BLAME_COLUMN_WIDTH, formatIsoDate, @@ -192,7 +193,7 @@ function BlameRow({ // toLocaleDateString's Chinese output "2023年3月29日" is too wide and would be truncated. const dateStr = block.authorDate ? formatIsoDate(new Date(block.authorDate)) : ''; const title = `${block.author}\n${block.commit.slice(0, 12)}\n${block.summary}\n${ - block.authorDate ? new Date(block.authorDate).toLocaleString() : '' + block.authorDate ? formatTimestamp(block.authorDate, { full: true }) : '' }`; return ( <div className="blame-row" style={{ top, height }} title={title}> diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx index 2b71e359..84adbe65 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import type ReactMarkdown from 'react-markdown'; import type { PrComment } from '@meebox/shared'; import { Avatar, makeBitbucketImageFor, ConfirmModal } from '../../../../../common'; +import { formatTimestamp } from '../../../../../../utils/time'; import { CommentEditEditor } from '../../comments/CommentEditEditor'; import { CommentReplyEditor } from '../../comments/CommentReplyEditor'; import { CommentMarkdown } from '../../shared/CommentMarkdown'; @@ -347,7 +348,7 @@ function CommentAuthorRow({ size={18} /> <strong>{displayName}</strong> - <span className="muted">{new Date(at).toLocaleString()}</span> + <span className="muted">{formatTimestamp(at)}</span> </div> ); } @@ -356,7 +357,7 @@ function CommentAuthorRow({ export function renderHoverMd(comments: PrComment[]): string { return comments .map((c) => { - const head = `**${c.author.displayName}** · ${new Date(c.createdAt).toLocaleString()}`; + const head = `**${c.author.displayName}** · ${formatTimestamp(c.createdAt, { full: true })}`; const body = c.body.length > 600 ? c.body.slice(0, 600) + '…' : c.body; const replies = c.replies .map( diff --git a/apps/desktop/src/renderer/src/utils/time.ts b/apps/desktop/src/renderer/src/utils/time.ts index 99afe43c..984715ac 100644 --- a/apps/desktop/src/renderer/src/utils/time.ts +++ b/apps/desktop/src/renderer/src/utils/time.ts @@ -1,4 +1,9 @@ import type { TFunction } from 'i18next'; +import { formatTimestamp } from '@meebox/shared'; + +// House timestamp formatters live in @meebox/shared (pure + unit-tested); re-exported here so renderer code keeps +// importing them from utils/time. +export { formatTimestamp, formatDate } from '@meebox/shared'; /** * Format a millisecond duration as "Ns" / "Mm SSs": @@ -16,7 +21,7 @@ export function formatElapsed(ms: number, opts?: { compact?: boolean }): string /** * Format a point in time as relative time: "just now / N seconds ago / N minutes ago / N hours ago"; beyond 1 day - * give an absolute time, to avoid vagueness like "3 days ago". + * give an absolute time (house format), to avoid vagueness like "3 days ago". */ export function formatRelative(date: Date, t: TFunction): string { const diffSec = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); @@ -26,5 +31,5 @@ export function formatRelative(date: Date, t: TFunction): string { if (diffMin < 60) return t('statusBar.minutesAgo', { count: diffMin }); const diffHr = Math.round(diffMin / 60); if (diffHr < 24) return t('statusBar.hoursAgo', { count: diffHr }); - return date.toLocaleString(); + return formatTimestamp(date, { full: true }); } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 17ff6442..c86c2539 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,4 +12,5 @@ export * from './pr-filter.js'; export * from './product.js'; export * from './sync-progress.js'; export * from './theme.js'; +export * from './time-format.js'; export * from './tool-registry.js'; diff --git a/packages/shared/src/time-format.ts b/packages/shared/src/time-format.ts new file mode 100644 index 00000000..cf12efa2 --- /dev/null +++ b/packages/shared/src/time-format.ts @@ -0,0 +1,35 @@ +/** + * House timestamp formatting — deliberately **locale-independent**: a 24-hour clock and an ISO-ordered date + * (`yyyy-mm-dd`), so timestamps are unambiguous and sortable regardless of the UI language or the OS locale. The + * app standardizes on this instead of following the system locale or exposing a date/time format setting (see the + * timestamp design decision). Kept here — shared, pure, dependency-free — so the renderer (and any future + * consumer) format identically, and so the logic is unit-testable. All fields use the local timezone. + */ + +const pad2 = (n: number): string => String(n).padStart(2, '0'); + +/** + * Format an absolute timestamp. Same-day timestamps omit the redundant date and show only the time (`HH:mm:ss`); + * other days show the full `yyyy-mm-dd HH:mm:ss`. Pass `{ full: true }` to always include the date — e.g. a + * precise hover tooltip whose whole job is to disambiguate. Accepts an ISO string / millisecond timestamp / Date; + * an unparseable input is returned as-is (string) or `""` (non-string). + */ +export function formatTimestamp(input: string | number | Date, opts?: { full?: boolean }): string { + const d = input instanceof Date ? input : new Date(input); + if (Number.isNaN(d.getTime())) return typeof input === 'string' ? input : ''; + const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; + const now = new Date(); + const sameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + if (sameDay && !opts?.full) return time; + return `${String(d.getFullYear())}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${time}`; +} + +/** Date only in the house format (`yyyy-mm-dd`); for relative-time fallbacks where the date alone is enough. */ +export function formatDate(input: string | number | Date): string { + const d = input instanceof Date ? input : new Date(input); + if (Number.isNaN(d.getTime())) return typeof input === 'string' ? input : ''; + return `${String(d.getFullYear())}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} diff --git a/packages/shared/tests/time-format.test.ts b/packages/shared/tests/time-format.test.ts new file mode 100644 index 00000000..29b0412a --- /dev/null +++ b/packages/shared/tests/time-format.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { formatDate, formatTimestamp } from '../src/time-format.js'; + +describe('formatTimestamp', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Fixed "now" = 2026-07-07 10:00:00 local (so same-day detection is deterministic). + vi.setSystemTime(new Date(2026, 6, 7, 10, 0, 0)); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('same day → time only, 24-hour HH:mm:ss', () => { + expect(formatTimestamp(new Date(2026, 6, 7, 22, 38, 20))).toBe('22:38:20'); + }); + + it('other day → full yyyy-mm-dd HH:mm:ss', () => { + expect(formatTimestamp(new Date(2026, 6, 5, 22, 38, 20))).toBe('2026-07-05 22:38:20'); + }); + + it('{ full: true } forces the date even on the same day', () => { + expect(formatTimestamp(new Date(2026, 6, 7, 9, 5, 3), { full: true })).toBe( + '2026-07-07 09:05:03', + ); + }); + + it('zero-pads single-digit month / day / time fields', () => { + expect(formatTimestamp(new Date(2026, 0, 3, 4, 5, 6), { full: true })).toBe( + '2026-01-03 04:05:06', + ); + }); + + it('accepts a millisecond timestamp and an ISO string (same instant → same output)', () => { + const d = new Date(2026, 6, 7, 8, 9, 10); + expect(formatTimestamp(d.getTime())).toBe('08:09:10'); + // ISO round-trips to the same instant; local-field formatting yields the original local time. + expect(formatTimestamp(d.toISOString())).toBe('08:09:10'); + }); + + it('returns the raw string for an unparseable input, "" for an unparseable non-string', () => { + expect(formatTimestamp('not-a-date')).toBe('not-a-date'); + expect(formatTimestamp(Number.NaN)).toBe(''); + }); +}); + +describe('formatDate', () => { + it('formats yyyy-mm-dd (local, no time)', () => { + expect(formatDate(new Date(2026, 6, 5, 23, 0, 0))).toBe('2026-07-05'); + expect(formatDate(new Date(2026, 0, 9))).toBe('2026-01-09'); + }); + + it('returns the raw string for an unparseable input', () => { + expect(formatDate('nope')).toBe('nope'); + }); +}); From c8ac14bf66ae5195f1733653b6ebe56a0371b834 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Tue, 7 Jul 2026 11:57:56 +0800 Subject: [PATCH 16/19] docs(agents): forbid npm overrides for dependency-advisory fixes Record the maintenance rule: resolve a Dependabot / npm audit advisory only by upgrading the real direct/parent dependency, never with npm overrides. When no clean parent upgrade exists (a transitive vuln pinned by a parent with no patched release), prefer leaving it unpatched and filing a tracking issue for runtime deps worth following; dev-server-only tool-chain advisories that never reach the shipped artifact may be left with no issue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a09a92f6..eb4db4f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,8 @@ npm --prefix apps/desktop run prepare:pragent # align the embedded pr-agent r `npm install` (not `npm ci`) resolves against `package.json` and writes the lockfile back, keeping local and remote `package-lock.json` consistent; `prepare:pragent` is idempotent and aligns the local pr-agent runtime per `pragent-runtime.json` (skips if the version is unchanged, only syncing the shim). If the lockfile changes, commit it together with the change it belongs to. +**Vulnerability-alert fixes — no `overrides`**: resolve a Dependabot / `npm audit` advisory only by upgrading the actual direct or parent dependency — **never** with npm `overrides` (or any other transitive version-pin hack). Overrides mask the real dependency graph and silently break on the parent's next update. When there is no clean parent upgrade (a transitive vuln pinned by a parent that has no patched release — e.g. `monaco-editor` pinning `dompurify`, or `vitepress` pinning `vite`), **prefer to leave it unpatched** over forcing an override. File a long-term **tracking issue** for advisories worth following (typically a **runtime** dependency); **dev-server-only tool-chain** advisories that never reach the shipped artifact (e.g. the `vite`/`esbuild` dev server under the website) may simply be left with no issue. + ## Required before committing After changing code, run these four steps locally before wrapping up (this is exactly what CI does): `lint` → `typecheck` → `test` → `build`. lint is zero-tolerance (`--max-warnings=0`), and a warning will turn CI red. From 5049edcef3fe268e2ee8513a5e300980e61ebb7a Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Tue, 7 Jul 2026 14:39:24 +0800 Subject: [PATCH 17/19] feat(review): add remote @mention user search across all comment editors The @mention autocomplete only offered this PR's already-loaded participants, so users could not mention people outside the PR without knowing the exact username. Add a per-platform remote user-search fallback and make every comment editor behave identically. - shared: new `userSearch` capability + `connection.searchUsers(query, repo)` - adapters: Bitbucket `/users?permission=LICENSED_USER&filter=` (the native web mention-picker endpoint, callable by any authenticated user); GitHub repo `collaborators` with `/search/users` fallback on 401/403; GitLab `/projects/:id/users?search=` (project members) - ipc: `mentions:search` channel + best-effort controller (warn-log then degrade to local candidates on any failure, min query length 2) - renderer: debounced (250ms) remote fallback in MentionTextarea with sequence race guard + in-flight cancellation; thread the flag through comment composer, reply editor, and draft editor - unify: the inline diff comment reply editor was missing mention candidates / platform / user search entirely; wire the same props through useCommentZones -> InlineCommentZone so inline and comments-page reply share one CommentReplyEditor with identical behavior - docs: capability + comment-interactions design; record the "comment interactions identical across all surfaces" philosophy in AGENTS.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- AGENTS.md | 1 + apps/desktop/src/main/controllers/pr.ts | 29 +++++ apps/desktop/src/main/ipc.ts | 1 + .../pr/tabs/activity/ActivityPanel.tsx | 5 + .../pr/tabs/comments/CommentComposer.tsx | 7 ++ .../features/pr/tabs/comments/CommentItem.tsx | 5 + .../pr/tabs/comments/CommentReplyEditor.tsx | 7 ++ .../features/pr/tabs/diff/DiffView.tsx | 6 + .../features/pr/tabs/diff/DraftZoneList.tsx | 4 + .../pr/tabs/diff/hooks/useCommentZones.tsx | 17 ++- .../pr/tabs/diff/hooks/useDraftZones.tsx | 5 + .../inline-comments/InlineCommentZone.tsx | 26 +++- .../features/pr/tabs/drafts/DraftZone.tsx | 7 ++ .../pr/tabs/shared/MentionTextarea.tsx | 111 ++++++++++++++++-- .../features/pr/tabs/shared/mentionSearch.ts | 22 ++++ .../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/pr/comments.scss | 5 + docs/arch/01-platform/01-adapter.md | 4 +- .../01-platform/04-comment-interactions.md | 26 +++- packages/ipc/src/pr.ts | 12 ++ .../src/features/connection.ts | 24 ++++ .../platform-core/src/features/connection.ts | 16 +++ packages/platform-core/tests/features.test.ts | 3 + .../src/features/connection.ts | 44 ++++++- .../src/features/connection.ts | 27 ++++- packages/poller/tests/poller.test.ts | 4 + packages/shared/src/platform.ts | 7 ++ 30 files changed, 412 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionSearch.ts diff --git a/AGENTS.md b/AGENTS.md index eb4db4f1..e1cada0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ Release: merge `dev` into `master` → tag `v*` on `master`, which triggers [rel - **PR descriptions in English**: the PR title and body are always in **English** (an external collaboration surface, consistent with English commits); the base branch points at `dev` per the branch strategy. - **Label PRs**: after opening / updating a PR, **habitually label it** — pick the most fitting one or more from the repo's existing label set (`gh label list`: enhancement / documentation / bug / …) and apply them (`gh pr edit <n> --add-label`). If no existing label fits, create one as needed or leave it empty with a note — don't force a poor fit. - **Uniform platform display order**: code platforms (GitHub / Bitbucket / GitLab …) are displayed everywhere in the uniform order **GitHub → Bitbucket → GitLab**, and **a new platform is always appended at the end**. The reference is the `PLATFORM_META` array in [PlatformIcon.tsx](apps/desktop/src/renderer/src/components/common/PlatformIcon.tsx); the settings-page platform dropdown, the first-launch wizard grid, the user doc [docs/guide/01-code-platform.md](docs/guide/01-code-platform.md), the `PlatformKind` type, etc. all follow this order — align them together when changing the platform list to avoid mismatches. +- **Comment interactions are identical across all surfaces**: every comment-interaction behavior — emoji reactions, `@mention` (local candidates + remote repo-scoped user search), image attachments, reply / edit / delete — must behave **the same** on every comment surface: the comments/activity page (`CommentItem`), the inline diff comment zone (`InlineCommentZone`), and the inline draft editor (`DraftZone`). Enforce this by **sharing the leaf components / hooks** (`CommentReplyEditor`, `MentionTextarea`, `useReactions`, `useCommentThread`) rather than reimplementing per surface — surfaces may differ only in layout, never in interaction behavior. When adding or changing an interaction, thread the same props down **every** path (ActivityPanel→CommentItem, DiffView→useCommentZones→InlineCommentZone, DiffView→useDraftZones→DraftZone); a capability reaching only one surface is a bug, not a scope choice. Full rationale in [docs/arch/01-platform/04-comment-interactions.md](docs/arch/01-platform/04-comment-interactions.md). ## Internationalization (i18n) diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index 773c3e1b..ca3d2537 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -70,6 +70,35 @@ export const createComment: IpcController<'comments:create'> = async (_event, re 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; + +/** + * Search platform users for `@mention` autocomplete. Resolves the connection from the PR, guards on the `userSearch` + * capability + a minimum query length, and delegates to the adapter's connection.searchUsers. Read-only, no cache / + * broadcast. Returns [] when unsupported / query too short (the renderer treats it as "no remote matches"). + */ +export const searchMentions: IpcController<'mentions:search'> = async (_event, req) => { + const query = req.query.trim(); + if (query.length < MENTION_SEARCH_MIN_QUERY) return []; + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + if (!adapter.connection.capabilities().userSearch) return []; + try { + // Repo-scoped where the caller is privileged; the adapter falls back to a broader directory on auth failure (see connection.searchUsers). + return await adapter.connection.searchUsers(query, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + }); + } catch (e) { + // Mention search is a pure convenience: on any failure (permissions / network / rate limit) degrade to the local + // candidate menu rather than surfacing a loud handler error. Warn for troubleshooting, then return empty. + console.warn('mentions:search failed, degrading to local candidates:', e); + return []; + } +}; + /** * Delete a remote comment authored by yourself (with a version optimistic lock). Failures are rethrown verbatim to the renderer; on success clear the cache + broadcast. */ diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 5d61aefc..bf9945a2 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -70,6 +70,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('comments:toggleReaction', pr.toggleReaction); // Toggle a comment emoji reaction ipcMain.handle('comments:uploadAttachment', pr.uploadAttachment); // Upload a comment image attachment ipcMain.handle('comments:fetchAttachment', pr.fetchAttachment); // Fetch a comment inline image (proxied with PAT) + ipcMain.handle('mentions:search', pr.searchMentions); // Search platform users for @mention autocomplete ipcMain.handle('prs:list', pr.listPrs); // PR list (active connection only) ipcMain.handle('prs:listArchived', pr.listArchivedPrs); // Closed (archived) PR list (read-only browsing) ipcMain.handle('prs:openByUrl', pr.openPrByUrl); // Open a current-platform PR by URL (locate / fetch archive) diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx index 9baa9ea8..806aef4c 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx @@ -98,6 +98,8 @@ export function ActivityPanel({ const reactionsMode = capabilities?.commentReactions || undefined; // Image attachment upload: enables comment paste-to-upload when the platform supports it. Conservatively off by default (GitHub has no upload API, also false). const attachmentsEnabled = capabilities?.commentAttachments ?? false; + // Remote @mention user search: enables the mention editor's remote fallback (search users beyond this PR's participants) when the platform supports it. Off by default. + const userSearchEnabled = capabilities?.userSearch ?? false; // Differentiation: GitHub/Bitbucket render the activity timeline of comments+commits+decisions; GitLab (activityTimeline=false) degrades to // a pure comment view (doesn't fetch commits/decisions, keeps the "Comments" wording). Default (capabilities not yet arrived) conservatively treats as pure comments. const showTimeline = capabilities?.activityTimeline ?? false; @@ -200,6 +202,7 @@ export function ActivityPanel({ reactionsMode={reactionsMode} mentionCandidates={mentionCandidates} attachmentsEnabled={attachmentsEnabled} + userSearchEnabled={userSearchEnabled} timeline={showTimeline} readOnly={readOnly} onJumpToAnchor={onJumpToAnchor} @@ -234,6 +237,7 @@ export function ActivityPanel({ reactionsMode, mentionCandidates, attachmentsEnabled, + userSearchEnabled, showTimeline, readOnly, onViewCommit, @@ -279,6 +283,7 @@ export function ActivityPanel({ mentionCandidates={mentionCandidates} platform={pr.platform} attachmentsEnabled={attachmentsEnabled} + userSearchEnabled={userSearchEnabled} onCancel={() => onComposeClose?.()} onPosted={() => onComposeClose?.()} /> 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 f4ad6981..9996fb68 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 @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { PlatformKind, PlatformUser } from '@meebox/shared'; import { invoke } from '../../../../../api'; import { MentionTextarea } from '../shared/MentionTextarea'; +import { searchMentionUsers } from '../shared/mentionSearch'; import { uploadCommentImage } from '../shared/uploadCommentImage'; interface CommentComposerProps { @@ -13,6 +14,8 @@ interface CommentComposerProps { platform?: PlatformKind; /** Whether the platform supports image attachment upload (capabilities.commentAttachments); paste-to-upload is enabled only when true. */ attachmentsEnabled?: boolean; + /** Whether the platform supports remote user search (capabilities.userSearch); enables the mention editor's remote fallback when true. */ + userSearchEnabled?: boolean; 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; @@ -27,6 +30,7 @@ export function CommentComposer({ mentionCandidates = [], platform, attachmentsEnabled = false, + userSearchEnabled = false, onCancel, onPosted, }: CommentComposerProps) { @@ -70,6 +74,9 @@ export function CommentComposer({ onChange={setBody} candidates={mentionCandidates} platform={platform} + onRemoteSearch={ + userSearchEnabled ? (q) => searchMentionUsers(prLocalId, q) : undefined + } onKeyDown={onKeyDown} onUpload={attachmentsEnabled ? (f) => uploadCommentImage(prLocalId, f) : undefined} placeholder={t('commentComposer.placeholder')} 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 b4c57c5f..b0cf0c18 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 @@ -83,6 +83,7 @@ export function CommentItem({ reactionsMode, mentionCandidates, attachmentsEnabled = false, + userSearchEnabled = false, timeline = false, readOnly = false, onJumpToAnchor, @@ -99,6 +100,8 @@ export function CommentItem({ mentionCandidates?: PlatformUser[]; /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the reply editor to enable paste upload. */ attachmentsEnabled?: boolean; + /** Whether the platform supports remote user search (capabilities.userSearch); passed through to the reply editor for the mention remote fallback. */ + userSearchEnabled?: boolean; /** Whether in activity timeline mode (only affects top-level comment layout, see the note above) */ timeline?: boolean; /** Content read-only (decline / non-participable archived PR): hides reply / edit / delete actions, browse only. */ @@ -267,6 +270,7 @@ export function CommentItem({ mentionCandidates={mentionCandidates} platform={pr.platform} attachmentsEnabled={attachmentsEnabled} + userSearchEnabled={userSearchEnabled} onCancel={() => setReplyOpen(false)} onPosted={() => setReplyOpen(false)} /> @@ -289,6 +293,7 @@ export function CommentItem({ reactionsMode={reactionsMode} mentionCandidates={mentionCandidates} attachmentsEnabled={attachmentsEnabled} + userSearchEnabled={userSearchEnabled} readOnly={readOnly} onJumpToAnchor={onJumpToAnchor} /> 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 fd142fb4..46bd9fda 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 @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { PlatformKind, PlatformUser } from '@meebox/shared'; import { invoke } from '../../../../../api'; import { MentionTextarea } from '../shared/MentionTextarea'; +import { searchMentionUsers } from '../shared/mentionSearch'; import { uploadCommentImage } from '../shared/uploadCommentImage'; interface CommentReplyEditorProps { @@ -14,6 +15,8 @@ interface CommentReplyEditorProps { platform?: PlatformKind; /** Whether the platform supports image attachment upload; paste upload is enabled only when true. */ attachmentsEnabled?: boolean; + /** 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) */ onPosted: () => void; @@ -29,6 +32,7 @@ export function CommentReplyEditor({ mentionCandidates = [], platform, attachmentsEnabled = false, + userSearchEnabled = false, onCancel, onPosted, }: CommentReplyEditorProps) { @@ -72,6 +76,9 @@ export function CommentReplyEditor({ onChange={setBody} candidates={mentionCandidates} platform={platform} + onRemoteSearch={ + userSearchEnabled ? (q) => searchMentionUsers(prLocalId, q) : undefined + } onKeyDown={onKeyDown} onUpload={attachmentsEnabled ? (f) => uploadCommentImage(prLocalId, f) : undefined} placeholder={t('commentReplyEditor.placeholder')} 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 e7893731..87cfa2e5 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 @@ -99,6 +99,8 @@ export function DiffView({ // Inline comment emoji reaction / image attachment capabilities (same capability bits as the comments / activity tab): absent = unsupported (don't render the add-reaction button / don't enable paste upload). const reactionsMode = capabilities?.commentReactions || undefined; const attachmentsEnabled = capabilities?.commentAttachments ?? false; + // Remote @mention user search: enables the draft editor's remote fallback (search users beyond this PR's participants) when the platform supports it. + const userSearchEnabled = capabilities?.userSearch ?? false; const { t } = useTranslation(); // Draft pool: store shared across ChatPane / DiffView; this component needs it to render inline zones const drafts = useDraftsForPr(pr.localId); @@ -238,6 +240,9 @@ export function DiffView({ commentHardBreaks, reactionsMode, attachmentsEnabled, + mentionCandidates, + platform: pr.platform, + userSearchEnabled, readOnly, }); // Inline draft view zone (not rendered in the commit read-only view) @@ -253,6 +258,7 @@ export function DiffView({ attachmentsEnabled, mentionCandidates, platform: pr.platform, + userSearchEnabled, scopeKind: scope.kind, }); // Line hover '+' to create a new draft (not mounted in the commit read-only view) diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx index c8c98940..f60adc55 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx @@ -18,6 +18,7 @@ export function DraftZoneList({ attachmentsEnabled = false, mentionCandidates, platform, + userSearchEnabled = false, }: { drafts: ReviewDraft[]; prLocalId: string; @@ -29,6 +30,8 @@ export function DraftZoneList({ mentionCandidates?: PlatformUser[]; /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ platform?: PlatformKind; + /** Whether the platform supports remote user search (capabilities.userSearch); passed through to the draft editor for the mention remote fallback. */ + userSearchEnabled?: boolean; }) { const { t } = useTranslation(); const onSave = async (draftId: string, body: string): Promise<void> => { @@ -66,6 +69,7 @@ export function DraftZoneList({ hardBreaks={hardBreaks} mentionCandidates={mentionCandidates} platform={platform} + userSearchEnabled={userSearchEnabled} registerEditTrigger={registerEditTrigger} onSave={(body) => onSave(d.id, body)} onDelete={() => onDelete(d.id)} 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 53355f5e..3ced7b84 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,6 +1,6 @@ import { useEffect } from 'react'; import { editor as MonacoEditorNs, type editor as MonacoEditor } from 'monaco-editor'; -import type { PrComment } from '@meebox/shared'; +import type { PlatformKind, PlatformUser, PrComment } from '@meebox/shared'; import type { DiffChangedFile } from '@meebox/ipc'; import { CommentZone, @@ -30,6 +30,12 @@ export function useCommentZones(opts: { reactionsMode?: 'fixed' | 'free'; /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the inline reply editor to enable paste upload. */ attachmentsEnabled?: boolean; + /** `@mention` autocomplete candidates for the inline reply editor (bounded PR participants; see collectMentionCandidates). Kept identical to the comments/activity tab so inline and page behave the same. */ + mentionCandidates?: PlatformUser[]; + /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames); passed through to the inline reply editor. */ + platform?: PlatformKind; + /** Whether the platform supports remote user search (capabilities.userSearch); passed through to the inline reply editor for the mention remote fallback. */ + userSearchEnabled?: boolean; /** Content read-only (declined / non-participatable archived PR): inline comment zones hide reply / edit / delete. */ readOnly?: boolean; }): void { @@ -46,6 +52,9 @@ export function useCommentZones(opts: { commentHardBreaks, reactionsMode, attachmentsEnabled = false, + mentionCandidates, + platform, + userSearchEnabled = false, readOnly = false, } = opts; @@ -117,6 +126,9 @@ export function useCommentZones(opts: { hardBreaks={commentHardBreaks} reactionsMode={reactionsMode} attachmentsEnabled={attachmentsEnabled} + mentionCandidates={mentionCandidates} + platform={platform} + userSearchEnabled={userSearchEnabled} readOnly={readOnly} /> ), @@ -144,6 +156,9 @@ export function useCommentZones(opts: { commentHardBreaks, reactionsMode, attachmentsEnabled, + mentionCandidates, + platform, + userSearchEnabled, readOnly, ]); } diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx index 59c94126..f15d5df2 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx @@ -28,6 +28,8 @@ export function useDraftZones(opts: { mentionCandidates?: PlatformUser[]; /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ platform?: PlatformKind; + /** Whether the platform supports remote user search (capabilities.userSearch); passed through to the draft editor for the mention remote fallback. */ + userSearchEnabled?: boolean; scopeKind: 'all' | 'commit'; }): void { const { @@ -42,6 +44,7 @@ export function useDraftZones(opts: { attachmentsEnabled = false, mentionCandidates, platform, + userSearchEnabled = false, scopeKind, } = opts; @@ -94,6 +97,7 @@ export function useDraftZones(opts: { attachmentsEnabled={attachmentsEnabled} mentionCandidates={mentionCandidates} platform={platform} + userSearchEnabled={userSearchEnabled} /> ), }); @@ -111,6 +115,7 @@ export function useDraftZones(opts: { attachmentsEnabled, mentionCandidates, platform, + userSearchEnabled, scopeKind, ]); } diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx index 84adbe65..18d0019d 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type ReactMarkdown from 'react-markdown'; -import type { PrComment } from '@meebox/shared'; +import type { PlatformKind, PlatformUser, PrComment } from '@meebox/shared'; import { Avatar, makeBitbucketImageFor, ConfirmModal } from '../../../../../common'; import { formatTimestamp } from '../../../../../../utils/time'; import { CommentEditEditor } from '../../comments/CommentEditEditor'; @@ -36,6 +36,9 @@ export function CommentZone({ hardBreaks, reactionsMode, attachmentsEnabled = false, + mentionCandidates, + platform, + userSearchEnabled = false, readOnly = false, }: { comments: PrComment[]; @@ -48,6 +51,12 @@ export function CommentZone({ reactionsMode?: 'fixed' | 'free'; /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the reply editor to enable paste upload. */ attachmentsEnabled?: boolean; + /** `@mention` autocomplete candidates for the reply editor (bounded PR participants); kept identical to the comments/activity tab so inline and page reply behave the same. */ + mentionCandidates?: PlatformUser[]; + /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames); passed through to the reply editor. */ + platform?: PlatformKind; + /** Whether the platform supports remote user search (capabilities.userSearch); passed through to the reply editor for the mention remote fallback. */ + userSearchEnabled?: boolean; /** Content read-only (decline / archived PR that can't be participated in): hide the inline comment reply / edit / delete actions. */ readOnly?: boolean; }) { @@ -68,6 +77,9 @@ export function CommentZone({ hardBreaks={hardBreaks} reactionsMode={reactionsMode} attachmentsEnabled={attachmentsEnabled} + mentionCandidates={mentionCandidates} + platform={platform} + userSearchEnabled={userSearchEnabled} readOnly={readOnly} /> </div> @@ -139,6 +151,9 @@ function CommentNode({ hardBreaks, reactionsMode, attachmentsEnabled = false, + mentionCandidates, + platform, + userSearchEnabled = false, readOnly = false, }: { comment: PrComment; @@ -150,6 +165,9 @@ function CommentNode({ hardBreaks: boolean; reactionsMode?: 'fixed' | 'free'; attachmentsEnabled?: boolean; + mentionCandidates?: PlatformUser[]; + platform?: PlatformKind; + userSearchEnabled?: boolean; readOnly?: boolean; }) { const { t } = useTranslation(); @@ -257,7 +275,10 @@ function CommentNode({ prLocalId={prLocalId} // Reply target abstraction (threadId): GitLab=discussion id (required for reply); Bitbucket empty / GitHub=remoteId → fall back to remoteId. parentCommentId={comment.threadId ?? comment.remoteId} + mentionCandidates={mentionCandidates} + platform={platform} attachmentsEnabled={attachmentsEnabled} + userSearchEnabled={userSearchEnabled} onCancel={() => setReplyOpen(false)} onPosted={() => setReplyOpen(false)} /> @@ -298,6 +319,9 @@ function CommentNode({ hardBreaks={hardBreaks} reactionsMode={reactionsMode} attachmentsEnabled={attachmentsEnabled} + mentionCandidates={mentionCandidates} + platform={platform} + userSearchEnabled={userSearchEnabled} readOnly={readOnly} /> ))} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx index 0f7bad46..80a02754 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx @@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'; import type { PlatformKind, PlatformUser, ReviewDraft } from '@meebox/shared'; import { ConfirmModal, TrashIcon } from '../../../../common'; import { MentionTextarea } from '../shared/MentionTextarea'; +import { searchMentionUsers } from '../shared/mentionSearch'; import { uploadCommentImage } from '../shared/uploadCommentImage'; import { useDraftZone } from './useDraftZone'; @@ -20,6 +21,8 @@ interface DraftZoneProps { mentionCandidates?: PlatformUser[]; /** Active platform, deciding inserted mention syntax (Bitbucket quotes non-simple usernames). */ platform?: PlatformKind; + /** Whether the platform supports remote user search (capabilities.userSearch); enables the mention editor's remote fallback when true. */ + userSearchEnabled?: boolean; /** * Register the "enter edit mode" trigger fn into an external ref map. When DiffView calls the * registered fn, this component setIsEditing(true). Uses a ref-based fn instead of a props token to @@ -53,6 +56,7 @@ export function DraftZone({ hardBreaks, mentionCandidates, platform, + userSearchEnabled = false, registerEditTrigger, onSave, onDelete, @@ -156,6 +160,9 @@ export function DraftZone({ onChange={setEditingBody} candidates={mentionCandidates ?? []} platform={platform} + onRemoteSearch={ + userSearchEnabled ? (q) => searchMentionUsers(prLocalId, q) : undefined + } onKeyDown={onKeyDown} onUpload={ attachmentsEnabled ? (f) => uploadCommentImage(prLocalId, f) : undefined diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx index 333a215d..29e66143 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { formatMention, type PlatformKind, type PlatformUser } from '@meebox/shared'; import { ImageIcon } from '../../../../common'; @@ -6,6 +6,9 @@ import { ImageIcon } from '../../../../common'; /** Max number of suggestions shown in the popup (the source is already a bounded set of PR participants; truncate further to keep the list from growing too long). */ const MAX_SUGGESTIONS = 8; +/** Debounce before firing a remote user-search request while typing an `@mention` (avoids a request per keystroke). */ +const REMOTE_SEARCH_DEBOUNCE_MS = 250; + interface MentionMenu { /** Query string typed after `@` (excluding `@`). */ query: string; @@ -15,6 +18,8 @@ interface MentionMenu { items: PlatformUser[]; /** Index of the currently highlighted item. */ index: number; + /** A debounced remote search is in flight for this query (only when onRemoteSearch is wired). */ + loading?: boolean; } /** @@ -42,6 +47,7 @@ export function MentionTextarea({ onChange, candidates, platform, + onRemoteSearch, onKeyDown, onUpload, placeholder, @@ -60,6 +66,13 @@ export function MentionTextarea({ * usernames like `@"first.last"`. Omitted → GitHub-style bare `@name` (safe default; only affects insertion, not typing). */ platform?: PlatformKind; + /** + * Optional remote user-search fallback (wired only when the platform's `userSearch` capability is true). When set, + * after the local `candidates` are exhausted the component debounces ({@link REMOTE_SEARCH_DEBOUNCE_MS}) and appends + * remote matches to the menu, letting the user `@mention` people beyond this PR's participants. Failures resolve to + * `[]` upstream, so this only ever adds suggestions — manual `@name` typing keeps working regardless. + */ + onRemoteSearch?: (query: string) => Promise<PlatformUser[]>; onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; /** * Upload callback for pasted images: returns insertable markdown on success (otherwise null). When provided, enables image paste upload @@ -84,6 +97,22 @@ export function MentionTextarea({ const [menu, setMenu] = useState<MentionMenu | null>(null); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState<string | null>(null); + // Debounce timer + monotonically increasing request id for the remote search: a stale (superseded) response is + // discarded by comparing its captured id against the latest, so out-of-order arrivals never clobber the newer query. + const remoteTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + const remoteSeq = useRef(0); + + // Cancel any pending remote search: stop the debounce timer and bump the sequence so an in-flight response is ignored. + const cancelRemote = (): void => { + if (remoteTimer.current) { + clearTimeout(remoteTimer.current); + remoteTimer.current = null; + } + remoteSeq.current++; + }; + + // Clear the debounce timer on unmount (avoid a setMenu after the component is gone). + useEffect(() => () => cancelRemote(), []); // Dedupe suggestions (by name), preserving order: the caller may mix in duplicate participants / comment authors. const pool = useMemo(() => { @@ -98,17 +127,19 @@ export function MentionTextarea({ }, [candidates]); const refresh = (el: HTMLTextAreaElement): void => { - if (pool.length === 0) { + // Nothing to offer at all (no local pool and no remote fallback) → keep the menu closed. + if (pool.length === 0 && !onRemoteSearch) { setMenu(null); return; } const ctx = parseMention(el.value, el.selectionStart ?? el.value.length); if (!ctx) { + cancelRemote(); setMenu(null); return; } const q = ctx.query.toLowerCase(); - const items = pool + const localItems = pool .filter( (u) => q === '' || @@ -116,11 +147,56 @@ export function MentionTextarea({ u.displayName.toLowerCase().includes(q), ) .slice(0, MAX_SUGGESTIONS); - setMenu(items.length > 0 ? { query: ctx.query, at: ctx.at, items, index: 0 } : null); + + // Remote fallback: only for a non-empty query (an empty `@` shouldn't enumerate the whole directory). Show local + // matches immediately with a "searching" hint, then debounce a remote lookup and append its results as they land. + const wantRemote = onRemoteSearch !== undefined && ctx.query.trim().length > 0; + if (!wantRemote) { + cancelRemote(); + setMenu(localItems.length > 0 ? { query: ctx.query, at: ctx.at, items: localItems, index: 0 } : null); + return; + } + + setMenu({ query: ctx.query, at: ctx.at, items: localItems, index: 0, loading: true }); + if (remoteTimer.current) clearTimeout(remoteTimer.current); + const seq = ++remoteSeq.current; + remoteTimer.current = setTimeout(() => { + void onRemoteSearch(ctx.query) + .then((users) => { + if (seq !== remoteSeq.current) return; // superseded by a newer keystroke + setMenu((prev) => { + // Only merge if the popup still targets this same `@…` token (guard against races with fast edits). + if (!prev || prev.at !== ctx.at || prev.query !== ctx.query) return prev; + const seen = new Set(prev.items.map((u) => u.name)); + const merged = [...prev.items]; + for (const u of users) { + if (u.name && !seen.has(u.name)) { + seen.add(u.name); + merged.push(u); + } + } + const items = merged.slice(0, MAX_SUGGESTIONS); + // No local and no remote matches → close the menu rather than show an empty box. + return items.length > 0 ? { ...prev, items, loading: false } : null; + }); + }) + .catch(() => { + if (seq !== remoteSeq.current) return; + // Remote failed: drop the loading hint, keep whatever local matches we already showed. + setMenu((prev) => + prev && prev.at === ctx.at && prev.query === ctx.query + ? prev.items.length > 0 + ? { ...prev, loading: false } + : null + : prev, + ); + }); + }, REMOTE_SEARCH_DEBOUNCE_MS); }; const select = (user: PlatformUser): void => { if (!menu) return; + cancelRemote(); const end = menu.at + 1 + menu.query.length; // Platform-aligned mention syntax (Bitbucket quotes non-simple usernames); trailing space added here. const insert = `${platform ? formatMention(platform, user) : `@${user.name}`} `; @@ -140,7 +216,9 @@ export function MentionTextarea({ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => { if (e.nativeEvent.isComposing) return; - if (menu) { + // While the popup only shows a "searching…" hint (no items yet), don't intercept nav/confirm keys — let Enter etc. + // fall through to the caller (e.g. Cmd/Ctrl+Enter to send); only Escape closes the pending popup. + if (menu && menu.items.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setMenu({ ...menu, index: (menu.index + 1) % menu.items.length }); @@ -156,11 +234,12 @@ export function MentionTextarea({ select(menu.items[menu.index]!); return; } - if (e.key === 'Escape') { - e.preventDefault(); - setMenu(null); - return; - } + } + if (menu && e.key === 'Escape') { + e.preventDefault(); + cancelRemote(); + setMenu(null); + return; } onKeyDown?.(e); }; @@ -225,7 +304,10 @@ export function MentionTextarea({ }} onKeyUp={(e) => refresh(e.currentTarget)} onClick={(e) => refresh(e.currentTarget)} - onBlur={() => setMenu(null)} + onBlur={() => { + cancelRemote(); + setMenu(null); + }} onKeyDown={handleKeyDown} onPaste={onUpload ? handlePaste : undefined} placeholder={placeholder} @@ -269,7 +351,7 @@ export function MentionTextarea({ </button> </div> )} - {menu && ( + {menu && (menu.items.length > 0 || menu.loading) && ( <ul className="mention-menu" role="listbox"> {menu.items.map((u, i) => ( <li key={u.name}> @@ -292,6 +374,11 @@ export function MentionTextarea({ </button> </li> ))} + {menu.loading && ( + <li className="mention-menu-hint muted" aria-hidden="true"> + {t('mention.searching')} + </li> + )} </ul> )} </div> diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionSearch.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionSearch.ts new file mode 100644 index 00000000..0754bbb6 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/mentionSearch.ts @@ -0,0 +1,22 @@ +import type { PlatformUser } from '@meebox/shared'; +import { invoke } from '../../../../../api'; + +/** Minimum query length before hitting remote user search (mirrors the main-side guard; avoids a request per keystroke). */ +const MIN_QUERY = 2; + +/** + * Remote `@mention` user search bound to a PR: resolves matching platform users via the `mentions:search` IPC channel, + * for the mention editor's remote fallback (see MentionTextarea). Returns [] for a too-short query or any failure so + * autocomplete degrades silently to the local candidate menu and never blocks typing. + * + * Only meaningful when the active platform's `userSearch` capability is true; callers gate on it before wiring this in. + */ +export async function searchMentionUsers(prLocalId: string, query: string): Promise<PlatformUser[]> { + const q = query.trim(); + if (q.length < MIN_QUERY) return []; + try { + return await invoke('mentions:search', { localId: prLocalId, query: q }); + } catch { + 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 c441fce7..e0967820 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -578,6 +578,9 @@ "unified": "Vereinheitlicht", "whitespace": "Leerzeichen" }, + "mention": { + "searching": "Benutzer werden gesucht…" + }, "mergeVeto": { "behind": "Hinter dem Zielbranch", "blockedByDependency": "Durch einen anderen Merge Request blockiert", 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 5bb39577..605e6276 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -578,6 +578,9 @@ "unified": "Unified", "whitespace": "Whitespace" }, + "mention": { + "searching": "Searching users…" + }, "mergeVeto": { "behind": "Behind the target branch", "blockedByDependency": "Blocked by another merge request", 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 8f0dfc96..658352e6 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -564,6 +564,9 @@ "unified": "統合表示", "whitespace": "空白" }, + "mention": { + "searching": "ユーザーを検索中…" + }, "mergeVeto": { "behind": "ターゲットブランチに遅れています", "blockedByDependency": "他のマージリクエストによりブロックされています", 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 ba017cdc..d70b1112 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -564,6 +564,9 @@ "unified": "统一", "whitespace": "空白" }, + "mention": { + "searching": "正在搜索用户…" + }, "mergeVeto": { "behind": "落后于目标分支,需先更新", "blockedByDependency": "被其它合并请求阻塞", diff --git a/apps/desktop/src/renderer/src/styles/features/pr/comments.scss b/apps/desktop/src/renderer/src/styles/features/pr/comments.scss index 23db8ee7..ed21a3ff 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/comments.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/comments.scss @@ -424,6 +424,11 @@ .mention-option-display { font-size: $fs-sm; } +// "Searching users…" hint row shown while a debounced remote user search is in flight (see MentionTextarea.onRemoteSearch). +.mention-menu-hint { + padding: $space-2 $space-3; + font-size: $fs-sm; +} .mention-upload-status { font-size: $fs-sm; padding: $space-1 0; diff --git a/docs/arch/01-platform/01-adapter.md b/docs/arch/01-platform/01-adapter.md index 2596e5cf..c4f955e8 100644 --- a/docs/arch/01-platform/01-adapter.md +++ b/docs/arch/01-platform/01-adapter.md @@ -120,7 +120,7 @@ classDiagram Per-domain service methods: -- `connection`: `capabilities()` (static capability descriptor, see §2), `ping()` (version + user), `getCurrentUser()` (synchronously reads the ping cache, used for the approved check), `getCloneUrl()`. +- `connection`: `capabilities()` (static capability descriptor, see §2), `ping()` (version + user), `getCurrentUser()` (synchronously reads the ping cache, used for the approved check), `getCloneUrl()`, `searchUsers(query, repo)` (user search for `@mention` autocomplete — repo-scoped where callable by a normal reviewer, else instance-wide; gated by the `userSearch` capability). - `prs`: `listPendingPullRequests()` (reviewer's pending, cross-repo), `listPullRequestCommits()` (**newest-first**), `listPullRequestActivity()`, `setPullRequestReviewStatus()`, `mergePullRequest()`. - `comments`: `listPullRequestComments()`, `publishSummaryComment()`, `publishInlineComment()`, `replyToComment()`, `editComment()`, `deleteComment()`. - `media`: `getUserAvatar()`, `getAttachment()`. @@ -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). +`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). **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.<code>`); 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. diff --git a/docs/arch/01-platform/04-comment-interactions.md b/docs/arch/01-platform/04-comment-interactions.md index 9d6fb06a..cae6bd54 100644 --- a/docs/arch/01-platform/04-comment-interactions.md +++ b/docs/arch/01-platform/04-comment-interactions.md @@ -18,7 +18,8 @@ Each of the three is declared by a capability flag (`commentReactions` / `commen | --- | --- | --- | --- | | Reactions `commentReactions` | `'fixed'` (8) | `'free'` (7.x+) | `'free'` | | Attachments `commentAttachments` | ✗ (no public upload API) | ✓ | ✓ | -| @mention autocomplete | ✓ (no capability flag needed) | ✓ | ✓ | +| @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) | ### emoji reactions: unify the emoji character as a neutral key @@ -49,11 +50,26 @@ To avoid being clipped by the comment scroll container / interfering with other **Toggle semantics**: `toggleReaction(add)` is idempotent — on add, a duplicate add is treated as success; on remove, GitHub/GitLab must first find the id of one's own reaction and then delete it, skipping if it doesn't exist. On success it follows the existing "clear cache after write + broadcast `comments:changed` + refetch" model, maintaining no frontend optimistic state (consistent with edit / delete). -### @mention: participant candidates + client-side completion +### @mention: participant candidates + remote search fallback Notification is done by the platform server automatically for `@name` in the comment body, so it takes effect with **zero backend change**; this domain only handles the completion UX while writing. -The candidate source deliberately takes only the **participants already loaded for this PR** — comment authors (including replies recursively) + commit authors — deduped by name. This is a **bounded, zero-extra-fetch, safe** source: it doesn't enumerate everyone from the remote (a large org has thousands of people, which is slow and triggers rate limits, and it also avoids over-privileged fetching), and the candidate size roughly equals the participant count (usually < 20). After typing `@`, client-side filter by the query string and show the top few; completion is merely a convenience, and the user can still freely type any `@name` (the platform parses notifications from the text itself). When broader search is needed, a "platform user-search endpoint" layer (with query filter + pagination truncation) can be stacked on top of this, but it's off by default. +The candidate source has **two layers**, local-first: + +1. **Local participants (default, always on)**: the **participants already loaded for this PR** — comment authors (including replies recursively) + commit authors — deduped by name. A **bounded, zero-extra-fetch, safe** source: it doesn't enumerate everyone from the remote, and the candidate size roughly equals the participant count (usually < 20). After typing `@`, client-side filter by the query string and show the top few. +2. **Remote user search (fallback, gated by `userSearch`)**: once the local set is exhausted for a non-empty query, the mention editor debounces (`REMOTE_SEARCH_DEBOUNCE_MS` = 250 ms) and calls the `mentions:search` IPC channel → `connection.searchUsers(query, repo)` → a per-platform user-search endpoint, capped at 20 and appended below the local matches. This lets the user `@mention` people **outside the PR** without knowing the exact username — the gap that a bounded local set alone cannot fill. + +Per-platform endpoint choice — **prefer repo-scoped, but only where a normal reviewer can actually call it**: + +- **GitLab** — `/projects/:id/users?search=` (project members): repo-scoped and callable by any member. Ideal case. +- **GitHub** — `/repos/{o}/{r}/collaborators` filtered client-side (repo-scoped), but listing collaborators needs **push access**; on 401/403 the adapter falls back to the global `/search/users` (any authenticated user). So scope is "repo collaborators when privileged, else anyone on GitHub". +- **Bitbucket** — `/rest/api/latest/users?permission=LICENSED_USER&filter=`: **the exact endpoint Bitbucket's own web mention picker uses**. Instance-wide (Bitbucket Server's repo `permissions/users` is repo-admin-gated → 401 for reviewers, so a repo-scoped variant isn't usable by normal users); `LICENSED_USER` keeps it to real accounts and it's callable by anyone authenticated. + +Any genuine failure (permissions / network / rate limit) degrades to the local menu with **no error surfaced to the user**: the `mentions:search` controller catches it, `console.warn`s for troubleshooting, and returns `[]`. + +Both layers are a **pure convenience**: the user can still freely type any `@name` (the platform parses notifications from the text itself), and any remote-search failure silently degrades to the local menu (see the fetch-safety constraint below). The remote query respects a minimum length (2 chars, enforced both in the renderer helper and the main controller), result truncation, debounce, and in-flight cancellation (a stale response is discarded by sequence check). + +**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. ### Image attachments: platform-native upload + reuse of existing rendering @@ -75,7 +91,7 @@ During upload the input box is disabled, to avoid the insertion position driftin | `REACTION_PICKER` | `fixed`-mode candidates (shared constant) | a fixed set of 8 emoji characters | | `REACTION_EMOJIS` | `free`-mode candidates + search source + char ↔ code mapping source (shared curated set) | `{ emoji, code, keywords }[]` (~150 entries) | -**Capability flags**: `commentReactions: false | 'fixed' | 'free'`; `commentAttachments` (boolean). +**Capability flags**: `commentReactions: false | 'fixed' | 'free'`; `commentAttachments` (boolean); `userSearch` (boolean — remote `@mention` user search). **Service interfaces** (method name + semantics, no pseudo-signatures): @@ -94,5 +110,5 @@ During upload the input box is disabled, to avoid the insertion position driftin - **Adding a new platform**: implement `toggleReaction` / `uploadAttachment`, declare the corresponding capability flags, and provide this platform's "native-name ↔ emoji" mapping; leave unsupported items at their default (reaction throws / upload returns null), and setting the capability flag false hides the whole block. - **Bitbucket's reaction shape has been verified empirically**: `properties.reactions[].emoticon` gives `shortcut` + `url` (a twemoji SVG whose filename is the Unicode code point, e.g. `1f440.svg`), with no `value` and no `count` field (the count is taken from `users.length`). Display prefers **decoding the emoji from the url code point** (works for any emoji), falling back to the shortcut-name mapping. Emoticon shortcut naming is irregular (e.g. `smile` / `laughing`), so writes (toggle) use the char → shortcode table built from `REACTION_EMOJIS`; when adding a new reaction kind, append a row in that curated set and ensure the shortcode is one the real instance accepts. - **Reads are always best-effort**: a follow-up query failure of reactions / awards must be caught as "no reaction", and must never bubble up to interrupt the comment list load. -- **@mention does not expand to enumerating everyone**: candidates default to this PR's participants; if platform user search is introduced, it must carry a query filter + result truncation + debounce + in-flight cancellation, to avoid full-list fetching and rate limiting (see the constraint on fetch safety). +- **@mention does not expand to enumerating everyone**: candidates default to this PR's participants; the `userSearch` remote fallback is query-scoped and carries a minimum query length + result truncation (20) + debounce (250 ms) + in-flight cancellation, so it never full-list-fetches and stays clear of rate limits (GitHub `/search/users` is ~30/min — the debounce keeps well under it). - **Attachment rendering depends on the existing proxy**: upload only produces markdown, and the authenticated fetch and display of embedded images reuse the existing comment-image proxy; if a new platform adopts a new URL shape, the attachment proxy must be updated to recognize it accordingly. diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 33c63896..5c64cc53 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -2,6 +2,7 @@ import type { AskVerdict, FindingClosure, LocalPrStatus, + PlatformUser, PollResult, PrActivityEvent, PrComment, @@ -87,6 +88,17 @@ export interface PrChannels { request: { localId: string; fileName: string; contentType: string; bytes: ArrayBuffer }; response: { markdown: string } | null; }; + /** + * Search platform users for `@mention` autocomplete (exposed only where the `userSearch` capability is true). + * The mention editor calls this as a debounced fallback once the bounded local candidate set (this PR's participants) + * is exhausted, so the user can mention people outside the PR without knowing the exact username. The connection is + * resolved from `localId` (the PR's platform / repo). Returns a bounded, platform-relevance-ordered list; an empty / + * too-short query or a search failure resolves to `[]` (the editor silently falls back to the local menu). + */ + 'mentions:search': { + request: { localId: string; query: string }; + response: PlatformUser[]; + }; 'prs:list': { request: void; response: StoredPullRequest[] }; /** List archived (retired) PRs: read from cold storage, for the "closed" view to browse (read-only). */ 'prs:listArchived': { request: void; response: StoredPullRequest[] }; diff --git a/packages/platform-bitbucket-server/src/features/connection.ts b/packages/platform-bitbucket-server/src/features/connection.ts index 2b803b1a..dcfcaa31 100644 --- a/packages/platform-bitbucket-server/src/features/connection.ts +++ b/packages/platform-bitbucket-server/src/features/connection.ts @@ -3,6 +3,7 @@ import { errorCodeMessage, type PingResult, type PlatformCapabilities, + type PlatformUser, type RepoRef, } from '@meebox/shared'; import { BaseConnection, type ConnectionContext } from '@meebox/platform-core'; @@ -50,6 +51,8 @@ export class BitbucketServerConnection extends BaseConnection { activityTimeline: true, // properties.commentCount only counts top-level comments, and updatedDate does not change with comments → no "includes replies" signal, so the poller falls back to scanning pending PRs every round. commentCountIncludesReplies: false, + // Bitbucket exposes the licensed-user search (the same endpoint the web mention picker uses), so the mention editor can search users beyond this PR's participants. + userSearch: true, }; } @@ -104,6 +107,27 @@ export class BitbucketServerConnection extends BaseConnection { return this.client.getCloneUrl(repo, this.getCurrentUser()?.name); } + /** + * Search users for `@mention` autocomplete via the same endpoint Bitbucket's own web mention picker uses: + * `/rest/api/latest/users?permission=LICENSED_USER&filter=<q>` (`filter` matches username / display name / email). + * `permission=LICENSED_USER` restricts to real licensed accounts and — unlike the repo `permissions/users` endpoint, + * which is repo-admin-gated (401 for a normal reviewer) — this is callable by any authenticated user, so it works for + * everyone. Scope is instance-wide (Bitbucket Server has no non-admin repo-scoped user endpoint; the native picker is + * instance-wide too). Inactive accounts dropped, capped at 20. Errors propagate (the controller degrades to the local menu). + */ + async searchUsers(query: string, _repo: RepoRef): Promise<PlatformUser[]> { + const q = query.trim(); + if (!q) return []; + const page = await this.client.get<{ values: BitbucketUser[] }>('/rest/api/latest/users', { + permission: 'LICENSED_USER', + filter: q, + limit: '20', + }); + return page.values + .filter((u) => u.active !== false) + .map((u) => ({ name: u.name, displayName: u.displayName, slug: u.slug })); + } + /** * Compare version numbers: segment-by-segment numeric comparison of `actual` against the minimum requirement, returning positive/zero/negative for ≥ / = / <. * diff --git a/packages/platform-core/src/features/connection.ts b/packages/platform-core/src/features/connection.ts index b49fde5d..a7a56057 100644 --- a/packages/platform-core/src/features/connection.ts +++ b/packages/platform-core/src/features/connection.ts @@ -43,6 +43,17 @@ export interface PlatformConnection { * Return the git clone URL (with PAT embedded as user:PAT or ssh scp-like form). */ getCloneUrl(repo: RepoRef): Promise<string>; + + /** + * Search users **with access to the given repo** by a free-text query (for `@mention` autocomplete), returning a + * bounded list ordered by platform relevance. Repo-scoped (not a global directory search): each platform hits its + * repo-permission endpoint (Bitbucket repo `permissions/users`, GitHub `collaborators`, GitLab project `users`), so + * suggestions are people who can actually act on the PR. Backs the mention editor's remote fallback; only meaningful + * when the `userSearch` capability is true. Implementations cap the result count; a "no match" resolves to an empty + * array. (Some endpoints require elevated permission — e.g. Bitbucket repo-admin; on such failure the caller degrades + * to the local candidate menu, see docs/arch/01-platform/04-comment-interactions.md.) + */ + searchUsers(query: string, repo: RepoRef): Promise<PlatformUser[]>; } /** @@ -79,4 +90,9 @@ export abstract class BaseConnection extends PlatformDomainService implements Pl * Implemented by platform subclasses: construct a directly cloneable git URL from a repo reference. */ abstract getCloneUrl(repo: RepoRef): Promise<string>; + + /** + * Implemented by platform subclasses: query the platform's repo-permission endpoint for `@mention` autocomplete. + */ + abstract searchUsers(query: string, repo: RepoRef): Promise<PlatformUser[]>; } diff --git a/packages/platform-core/tests/features.test.ts b/packages/platform-core/tests/features.test.ts index b5da4cc2..e1d3e163 100644 --- a/packages/platform-core/tests/features.test.ts +++ b/packages/platform-core/tests/features.test.ts @@ -23,6 +23,9 @@ class FakeConnection extends BaseConnection { async getCloneUrl() { return 'https://example/repo.git'; } + async searchUsers() { + return []; + } } describe('MutableConnectionContext + BaseConnection', () => { diff --git a/packages/platform-github/src/features/connection.ts b/packages/platform-github/src/features/connection.ts index 188ce117..d00e22f1 100644 --- a/packages/platform-github/src/features/connection.ts +++ b/packages/platform-github/src/features/connection.ts @@ -1,6 +1,6 @@ -import type { PingResult, PlatformCapabilities, RepoRef } from '@meebox/shared'; +import type { PingResult, PlatformCapabilities, PlatformUser, RepoRef } from '@meebox/shared'; import { BaseConnection, type ConnectionContext } from '@meebox/platform-core'; -import type { GitHubClient } from '../client.js'; +import { GitHubClientError, type GitHubClient } from '../client.js'; import type { GhUser } from '../types.js'; /** GitHub connection domain: capability declaration, ping (including GHE version), PAT/SSH clone URL. */ @@ -39,6 +39,8 @@ export class GitHubConnection extends BaseConnection { activityTimeline: true, // comments + review_comments include inline replies → count changes reliably reflect replies, the poller only scans when count/update time changes. commentCountIncludesReplies: true, + // GitHub exposes the repo collaborators list, so the mention editor can search repo-permitted users beyond this PR's participants. + userSearch: true, }; } @@ -65,4 +67,42 @@ export class GitHubConnection extends BaseConnection { async getCloneUrl(repo: RepoRef): Promise<string> { return this.client.getCloneUrl(repo, this.getCurrentUser()?.name); } + + /** + * Search users for `@mention` autocomplete, **repo-scoped when the caller is privileged enough**. GitHub has no + * repo-scoped user *search* endpoint, so we first list collaborators (`/repos/{owner}/{repo}/collaborators`, the people + * with repo access) and filter client-side by login / name substring — but that endpoint needs **push access**, so a + * read-only reviewer gets 403. On that auth failure we fall back to the global `/search/users` (callable by any + * authenticated user), so mention search still works for everyone; only the scoping widens to "anyone on GitHub". + * Capped at 20. Non-auth errors propagate (the controller degrades them to the local menu). + */ + async searchUsers(query: string, repo: RepoRef): Promise<PlatformUser[]> { + const raw = query.trim(); + if (!raw) return []; + const toUser = (u: GhUser): PlatformUser => ({ + name: u.login, + displayName: u.name ?? u.login, + slug: u.login, + avatarUrl: u.avatar_url, + }); + try { + const q = raw.toLowerCase(); + const list = await this.client.get<GhUser[]>( + `/repos/${repo.projectKey}/${repo.repoSlug}/collaborators`, + { per_page: '100' }, + ); + return list + .filter((u) => u.login.toLowerCase().includes(q) || (u.name ?? '').toLowerCase().includes(q)) + .slice(0, 20) + .map(toUser); + } catch (e) { + if (!(e instanceof GitHubClientError) || (e.status !== 401 && e.status !== 403)) throw e; + // No push access → fall back to the global user search (any authenticated user can call it). + const res = await this.client.get<{ items: GhUser[] }>('/search/users', { + q: raw, + per_page: '20', + }); + return res.items.map(toUser); + } + } } diff --git a/packages/platform-gitlab/src/features/connection.ts b/packages/platform-gitlab/src/features/connection.ts index 92463f41..419c3fcd 100644 --- a/packages/platform-gitlab/src/features/connection.ts +++ b/packages/platform-gitlab/src/features/connection.ts @@ -1,7 +1,13 @@ -import type { PingResult, PlatformCapabilities, RepoRef, ReviewerStatus } from '@meebox/shared'; +import type { + PingResult, + PlatformCapabilities, + PlatformUser, + RepoRef, + ReviewerStatus, +} from '@meebox/shared'; import { BaseConnection, type ConnectionContext } from '@meebox/platform-core'; import type { GitLabClient } from '../client.js'; -import { mapUser } from '../utils.js'; +import { mapUser, projectId } from '../utils.js'; import type { GlMetadata, GlUser, GlVersion } from '../types.js'; /** GitLab connection domain: capability declaration (degrade approval by edition), ping (with edition detection), PAT/SSH clone URL. */ @@ -46,6 +52,8 @@ export class GitLabConnection extends BaseConnection { activityTimeline: false, // user_notes_count includes replies (replies are also notes) → count changes reliably reflect replies, poller only scans when the count/update time changes. commentCountIncludesReplies: true, + // GitLab exposes /projects/:id/users?search=, so the mention editor can search project members beyond this PR's participants. + userSearch: true, }; } @@ -82,4 +90,19 @@ export class GitLabConnection extends BaseConnection { async getCloneUrl(repo: RepoRef): Promise<string> { return this.client.getCloneUrl(repo, this.getCurrentUser()?.name); } + + /** + * Search **project members** for `@mention` autocomplete via `/projects/:id/users?search=` (matches username / name; + * the members-among-the-project set, i.e. users with access to the repo). Capped at 20 results; each is mapped to the + * neutral PlatformUser via {@link mapUser}. Callable by any project member (no elevated permission needed). + */ + async searchUsers(query: string, repo: RepoRef): Promise<PlatformUser[]> { + const q = query.trim(); + if (!q) return []; + const users = await this.client.get<GlUser[]>(`/projects/${projectId(repo)}/users`, { + search: q, + per_page: '20', + }); + return users.map(mapUser); + } } diff --git a/packages/poller/tests/poller.test.ts b/packages/poller/tests/poller.test.ts index 49655bea..7d3d018f 100644 --- a/packages/poller/tests/poller.test.ts +++ b/packages/poller/tests/poller.test.ts @@ -92,12 +92,14 @@ class FakeAdapter implements PlatformAdapter { reviewGrouping: false, activityTimeline: true, commentCountIncludesReplies: this.replyAware, + userSearch: false, }), ping: async () => { if (this.failPing) throw new Error('ping fail'); return { ok: true, serverVersion: 'fake' }; }, getCloneUrl: async () => 'https://fake.example.com/repo.git', + searchUsers: async () => [], }; this.prs = { listPendingPullRequests: async (): Promise<PullRequest[]> => { @@ -313,10 +315,12 @@ describe('Poller.tick', () => { reviewGrouping: false, activityTimeline: true, commentCountIncludesReplies: false, + userSearch: false, }), ping: async () => ({ ok: true }), getCurrentUser: () => null, getCloneUrl: async () => 'https://stub', + searchUsers: async () => [], }; const slowPulls: PullRequestService = { getSinglePullRequest: () => Promise.reject(new Error('unused')), diff --git a/packages/shared/src/platform.ts b/packages/shared/src/platform.ts index 2602c337..b91c6674 100644 --- a/packages/shared/src/platform.ts +++ b/packages/shared/src/platform.ts @@ -664,6 +664,13 @@ export interface PlatformCapabilities { * no free "includes replies" signal at all; the poller fetches a comment scan as a fallback every round for **pending PRs**, otherwise it would miss "reply"-type notifications. */ commentCountIncludesReplies: boolean; + /** + * Whether the platform exposes a remote user-search endpoint for `@mention` autocomplete (Bitbucket `/users?filter=`, + * GitHub `/search/users`, GitLab `/users?search=`). When true the mention editor, after exhausting the bounded local + * candidate set (this PR's participants), falls back to a debounced remote lookup so the user can `@mention` people + * outside the PR without knowing the exact username. When false the editor stays local-only (manual `@name` still works). + */ + userSearch: boolean; } /** From cb09746880b928aa4c4974d8b28fcd6f3bb76873 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Tue, 7 Jul 2026 15:22:50 +0800 Subject: [PATCH 18/19] docs(changelog): note remote @mention user search under Unreleased Add an Added entry (EN + zh-CN) for the new remote @mention user search that lets you mention users beyond the PR's participants across all comment editors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 4 ++++ CHANGELOG.zh-CN.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46fd9193..2ce76249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and the versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### ✨ Added + +- `@mention` autocomplete can now find users beyond this PR's participants: after the local suggestions, it searches matching users on the code platform (GitHub / Bitbucket / GitLab), so you can mention someone without knowing their exact username. It works consistently in every comment editor — the activity composer, replies (including inline diff comment replies), and review drafts. + ### ♻️ Changed - Updated the embedded review engine (pr-agent) to 0.39.0, picking up upstream model-provider routing improvements and fixes. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 7b4ca9f0..6e1637b8 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -7,6 +7,10 @@ ## [Unreleased] +### ✨ 新增 + +- `@` 提及的自动补全现可检索 PR 参与者以外的用户:在本地候选之后,会到代码平台(GitHub / Bitbucket / GitLab)检索匹配的用户,让你无需知道对方的准确用户名即可提及。在所有评论编辑器中行为一致——活动输入框、回复(含行内差异评论的回复)以及评审草稿。 + ### ♻️ 变更 - 将内置评审引擎(pr-agent)升级至 0.39.0,纳入上游的模型服务商路由改进与修复。 From 7a9fbbdf37bcf76b279966dbd06b1db5a2a62ca7 Mon Sep 17 00:00:00 2001 From: Hamhire Hu <me@huhamhire.com> Date: Tue, 7 Jul 2026 16:33:50 +0800 Subject: [PATCH 19/19] chore(release): 0.11.0 Bump apps/desktop to 0.11.0 (sync package-lock.json) and cut the [0.11.0] CHANGELOG section from Unreleased in both locales (EN canonical + zh-CN mirror), with release highlights. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 12 ++++++++++-- CHANGELOG.zh-CN.md | 12 ++++++++++-- apps/desktop/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce76249..43ad06fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ 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.0] - 2026-07-07 + +> Highlights of this release: +> +> - **Richer `@mention`**: autocomplete now searches users beyond the PR's participants on the code platform (GitHub / Bitbucket / GitLab), so you can mention anyone without knowing their exact username — and mentioning behaves consistently across every comment editor, including inline diff comments and review drafts. +> - **Review engine updated**: the embedded pr-agent moves to 0.39.0, picking up upstream model-provider routing improvements and fixes. +> - **Consistent timestamps**: displayed times now use a fixed, locale-independent 24-hour `yyyy-mm-dd HH:mm:ss` format. +> +> The rest of the release is a batch of review-experience fixes (security-result wording, single-line file diffs, inline-comment width, token-rotation auth, and more). ### ✨ Added @@ -457,7 +465,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.10.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 [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 6e1637b8..13ae6ca1 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -5,7 +5,15 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 -## [Unreleased] +## [0.11.0] - 2026-07-07 + +> 本次发布要点: +> +> - **更强的 `@` 提及**:自动补全现会到代码平台(GitHub / Bitbucket / GitLab)检索 PR 参与者以外的用户,让你无需知道对方的准确用户名即可提及;且在所有评论编辑器(含行内差异评论与评审草稿)中行为一致。 +> - **评审引擎升级**:内置 pr-agent 升级至 0.39.0,纳入上游的模型服务商路由改进与修复。 +> - **统一的时间显示**:界面时间戳统一为与地区无关的 24 小时制 `yyyy-mm-dd HH:mm:ss` 格式。 +> +> 其余为一批评审体验修复(安全结论措辞、单行文件差异、行内评论宽度、令牌轮换鉴权等)。 ### ✨ 新增 @@ -457,7 +465,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 -[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.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 [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b93da4c3..ac2f8da0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.11.0-dev", + "version": "0.11.0", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/package-lock.json b/package-lock.json index a2636a52..6e536a32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.11.0-dev", + "version": "0.11.0", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1",