Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/).

### ✨ Added

- Mentions now render as a pill instead of blending into the surrounding text, so it is obvious at a glance when someone is named — on the activity page, in the inline diff comments, in drafts, and in the PR description alike.
- Proxy settings now take a list of **direct connections**: hosts that bypass the proxy and connect straight out, so an internal code platform, its git remote, or a self-hosted model stays reachable while everything else still goes through the proxy. Uses the familiar `NO_PROXY` syntax (a domain covers its subdomains), and applies to every outbound path at once — REST, git and the LLM call.
- A review that fails because the model is unavailable now says so and tells you what to do — with a local CLI provider (claude / codex) the model comes from that CLI's own configuration, so it has to be changed there.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### ✨ 新增

- @提及 改为胶囊标签展示,不再淹没在正文里,一眼即可看出点到了谁——活动页、内联 diff 评论、草稿与 PR 描述一致生效。
- 代理设置新增**直连地址**列表:列出的地址跳过代理直接连接,内网代码平台、它的 git 远端或自建模型服务因此保持可达,其余流量照常走代理。沿用通行的 `NO_PROXY` 写法(填域名同时覆盖子域),并对所有出站路径一并生效——REST、git 与 LLM 调用。
- 因模型不可用而失败的评审现在会明确说明,并给出处理方式——使用本地 CLI 供应商(claude / codex)时,模型来自该 CLI 自身的配置,需要在那里更换。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { remarkMentions } from '../../../../lib/remark-mention';
import { formatTimestamp } from '../../../../utils/time';
import {
Avatar,
Expand Down Expand Up @@ -48,7 +49,7 @@ export function PrInfoView({ pr }: PrInfoViewProps) {
{/* Bitbucket remote uses \r\n line endings; when remark parses, CR and LF each count as a line break → a single
newline gets treated as a paragraph separator, adding blank space between each list item. Normalize to \n */}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkMentions]}
rehypePlugins={REMOTE_REHYPE_PLUGINS}
components={mdComponents}
urlTransform={transformBitbucketUrl}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import type { PlatformKind, PlatformUser, ReviewDraft } from '@meebox/shared';
import { ConfirmModal, TrashIcon } from '../../../../common';
import { remarkMentions } from '../../../../../lib/remark-mention';
import { MentionTextarea } from '../shared/MentionTextarea';
import { searchMentionUsers } from '../shared/mentionSearch';
import { uploadCommentImage } from '../shared/uploadCommentImage';
Expand Down Expand Up @@ -227,9 +228,17 @@ export function DraftZone({
</div>
</div>
) : (
// Mentions render as pills here too: a draft is a comment the user is about to post, and the same body must
// not read differently before and after publishing (see docs/arch/01-platform/04-comment-interactions.md).
<div className="draft-zone-body markdown">
{draft.body.trim() ? (
<ReactMarkdown remarkPlugins={hardBreaks ? [remarkGfm, remarkBreaks] : [remarkGfm]}>
<ReactMarkdown
remarkPlugins={
hardBreaks
? [remarkGfm, remarkBreaks, remarkMentions]
: [remarkGfm, remarkMentions]
}
>
{draft.body}
</ReactMarkdown>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { invoke } from '../../../../../api';
import { formatBackendError } from '../../../../../errors';
import { useDraftsForPr } from '../../../../../stores/drafts-store';
import { ConfirmModal } from '../../../../common';
import { remarkMentions } from '../../../../../lib/remark-mention';

// posted no longer exists (successful publish deletes the local draft), filters keep only publishable / all / rejected
type Filter = 'all' | 'publishable' | 'rejected';
Expand Down Expand Up @@ -250,7 +251,11 @@ export function DraftsPanel({ pr, onJumpToAnchor, capabilities, readOnly = false
<div className="drafts-panel-item-body markdown">
{d.body.trim() ? (
<ReactMarkdown
remarkPlugins={hardBreaks ? [remarkGfm, remarkBreaks] : [remarkGfm]}
remarkPlugins={
hardBreaks
? [remarkGfm, remarkBreaks, remarkMentions]
: [remarkGfm, remarkMentions]
}
>
{d.body}
</ReactMarkdown>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { REMOTE_REHYPE_PLUGINS } from '../../../../../lib/markdown';
import { remarkEmojiShortcodes } from '../../../../../lib/remark-emoji';
import { remarkMentions } from '../../../../../lib/remark-mention';
import { transformBitbucketUrl } from '../../../../common';

/**
Expand All @@ -29,8 +30,8 @@ export function CommentMarkdown({
<ReactMarkdown
remarkPlugins={
hardBreaks
? [remarkGfm, remarkBreaks, remarkEmojiShortcodes]
: [remarkGfm, remarkEmojiShortcodes]
? [remarkGfm, remarkBreaks, remarkEmojiShortcodes, remarkMentions]
: [remarkGfm, remarkEmojiShortcodes, remarkMentions]
}
rehypePlugins={REMOTE_REHYPE_PLUGINS}
components={components}
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { Options } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { MENTION_CLASS } from './remark-mention';

function extend<T>(list: readonly T[] | null | undefined, extra: readonly T[]): T[] {
return Array.from(new Set<T>([...(list ?? []), ...extra]));
Expand Down Expand Up @@ -44,6 +45,11 @@ const schema = {
td: extend(defaultSchema.attributes?.td, ['align']),
th: extend(defaultSchema.attributes?.th, ['align']),
a: extend(defaultSchema.attributes?.a, ['rel', 'target']),
// The @mention pill (remark-mention) renders as <span class="comment-mention">, and sanitize would otherwise strip
// the class, leaving the mention indistinguishable from prose. Allowed as a **value-restricted** attribute
// (`[name, ...allowed values]`), not as free-form className: the body is user-generated, so permitting arbitrary
// classes on a span would let a comment borrow any style in the app.
span: extend(defaultSchema.attributes?.span, [['className', MENTION_CLASS]]),
'*': extend(defaultSchema.attributes?.['*'], ['align']),
},
};
Expand Down
70 changes: 70 additions & 0 deletions apps/desktop/src/renderer/src/lib/remark-mention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { findMentions } from '@meebox/shared';

/** Minimal shape of an mdast node (only the fields this plugin uses, avoiding an mdast type dependency). */
interface MdNode {
type: string;
value?: string;
children?: MdNode[];
data?: { hName?: string; hProperties?: Record<string, unknown> };
}

/** The class the rendered pill carries; must stay in sync with the sanitize allowlist in lib/markdown.ts. */
export const MENTION_CLASS = 'comment-mention';

/** Split one text value into text / mention nodes, or null when it holds no mention (the common case — keep the node). */
function splitMentions(text: string): MdNode[] | null {
const found = findMentions(text);
if (found.length === 0) return null;
const out: MdNode[] = [];
let last = 0;
for (const { start, end, name } of found) {
if (start > last) out.push({ type: 'text', value: text.slice(last, start) });
out.push({
type: 'mention',
// A node type mdast does not know: mdast-util-to-hast's unknown handler builds an element from the children and
// applies hName / hProperties, which is the documented way to emit custom markup from a remark plugin.
data: { hName: 'span', hProperties: { className: MENTION_CLASS } },
// The pill shows `@name`, not the raw token: Bitbucket's quoted form (`@"first.last"`) carries quotes that are
// platform syntax, not part of anyone's name, and showing them inside the pill is noise. This is the one place
// the rendered text intentionally differs from the source — everything else is styling only.
children: [{ type: 'text', value: `@${name}` }],
});
last = end;
}
if (last < text.length) out.push({ type: 'text', value: text.slice(last) });
return out;
}

/**
* remark plugin: render `@mention` tokens in a comment body as a pill instead of leaving them as running text, so a
* mention reads as "a person" at a glance rather than as prose that happens to start with `@`.
*
* Only mdast `text` nodes are rewritten, so code spans and code blocks are untouched for free — their content never
* lives in text nodes, meaning an `@Override` in a snippet or an email in a fenced block stays literal. Which runs
* count as mentions is decided by `findMentions` in shared, the reading counterpart of the `formatMention` used by the
* editors, so the two directions cannot disagree about the syntax (notably Bitbucket's quoted `@"first.last"`).
*/
export function remarkMentions() {
return (tree: MdNode): void => {
const walk = (node: MdNode): void => {
if (!node.children) return;
const out: MdNode[] = [];
let changed = false;
for (const child of node.children) {
if (child.type === 'text' && child.value?.includes('@')) {
const parts = splitMentions(child.value);
if (parts) {
out.push(...parts);
changed = true;
continue;
}
} else {
walk(child);
}
out.push(child);
}
if (changed) node.children = out;
};
walk(tree);
};
}
17 changes: 17 additions & 0 deletions apps/desktop/src/renderer/src/styles/common/markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,20 @@
margin-right: $space-2;
}
}

// @mention pill (remark-mention → span.comment-mention). Rendered as one unit so a mention reads as "a person" rather
// than as prose that happens to start with `@`. Scoped to the whole file rather than nested inside `.markdown`,
// because the class is emitted by the markdown pipeline wherever a comment body is rendered — including the draft
// preview, whose container carries `.markdown`, and any future surface that may not.
.comment-mention {
display: inline-block;
padding: 0 5px;
border-radius: $radius-pill;
background: $color-accent-bg-fade;
color: $color-accent;
font-weight: 500;
// Keep line height from growing where a mention sits in a dense comment: the pill borrows the line box it is in.
line-height: 1.4;
// A long username must wrap with the text rather than force the comment to scroll sideways.
word-break: break-word;
}
12 changes: 12 additions & 0 deletions docs/arch/01-platform/04-comment-interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ Both layers are a **pure convenience**: the user can still freely type any `@nam

**Consistency across surfaces (design philosophy)**: every comment-interaction behavior — reactions, `@mention` (local + remote), attachments, reply / edit / delete — must be **identical on all comment surfaces**: the comments/activity page (`CommentItem`), the inline diff comment zone (`InlineCommentZone`), and the inline draft editor (`DraftZone`). This is enforced by **sharing the leaf components / hooks** (`CommentReplyEditor`, `MentionTextarea`, `useReactions`, `useCommentThread`) rather than reimplementing per surface, so a surface can only differ in layout, never in interaction behavior. When adding or changing an interaction, wire it into **all** surfaces (thread the same props down each path) — a capability reaching only one surface is a bug, not a scope choice.

### @mention rendering: a pill, not running text

A mention is a reference to a person, and left as plain text it disappears into the sentence — exactly when the reader is scanning a thread for whether it concerns them. So a mention renders as a **pill** (`remark-mention` → `span.comment-mention`), applied wherever author-written prose that can name someone is rendered: the activity page, the inline comment zone, both draft surfaces, and the PR description. Drafts are included because a draft is a comment about to be posted, and the same body must not read differently before and after publishing; the PR description is included because it is the same kind of authored text, and a mention that is a pill in one place and plain text in another reads as a bug. Agent-facing markdown (chat, finding cards, rule previews) is **not** included — nothing there addresses a person.

Three things worth knowing about the implementation:

- **Parsing is the counterpart of writing, and lives beside it.** `findMentions` sits next to `formatMention` in `shared/mention.ts`, so the syntax — notably Bitbucket's quoted `@"first.last"`, which the server requires for a username containing a dot — is defined once instead of once per direction.
- **It is syntactic, not resolved.** There is no authoritative local list of who exists on the remote (a mention may name someone outside this PR's participants), so anything shaped like a mention is styled. That is the right side of the trade only because a false positive costs a tinted background and nothing else: the text is not altered and nothing becomes clickable. Boundary rules exclude the common false positives — an email address (no leading boundary before `@`), a scoped package (`@scope/pkg`), and trailing sentence punctuation. Code spans and fences are excluded for free, since only mdast `text` nodes are rewritten.
- **The class has to be allowlisted for sanitize, by value.** Comment bodies pass through `rehype-sanitize`, which strips `class` from a `span` by default; the pill class is allowed as a **value-restricted** attribute rather than as free-form `className`, so a comment cannot borrow arbitrary app styles by writing raw HTML.

The pill is also the one place the rendered text intentionally differs from the source: it shows `@first.last`, dropping Bitbucket's quotes, which are platform syntax rather than part of anyone's name.

### File-level comments: whole-file anchor + capability degradation

A comment can anchor to a **whole file** (not a specific line) where the platform supports it (`fileLevelComments`). This is modeled by a `PrCommentAnchor` **without a `line`** (path + side only): `anchor == null` → PR summary; `anchor` with a line → inline; `anchor` without a line → file-level. The comment `kind` (`'summary' | 'inline' | 'file'`) mirrors this.
Expand Down
50 changes: 50 additions & 0 deletions packages/shared/src/mention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,53 @@ export function formatMention(platform: PlatformKind, user: Pick<PlatformUser, '
}
return `@${id}`;
}

/** One mention found in a body: `[start, end)` are indices into the input, `name` excludes the `@` and any quotes. */
export interface FoundMention {
start: number;
end: number;
name: string;
}

/**
* Mention token, the reading counterpart of {@link formatMention} — both forms it can write must parse back here, which
* is why the two live together: the quoted Bitbucket form is defined once, not once per direction.
*
* The leading boundary is deliberately narrow (start of input, whitespace, or an opening bracket) because `@` is common
* in prose that is not a mention; requiring the boundary is what keeps `user@example.com` from matching its domain.
*/
const MENTION = /(^|[\s([{])@(?:"([^"\n]{1,64})"|([A-Za-z0-9_][A-Za-z0-9_.-]{0,63}))/g;

/**
* Locate `@mention` tokens in a plain-text run, for rendering them distinctly from surrounding prose.
*
* **Syntactic, not resolved**: there is no authoritative local list of who exists on the remote (a mention may name
* someone outside this PR's participants), so anything shaped like a mention is reported. Callers should therefore use
* this for presentation only — a false positive that merely restyles a word is cheap, one that turned text into a link
* or a notification would not be. The boundary rules exclude the common false positives anyway:
*
* - an email address (`user@example.com`) — the `@` has no leading boundary;
* - a scoped package (`@scope/pkg`) — a `/` immediately after the name disqualifies it;
* - a trailing `.` or `-` is treated as punctuation and left out of the name, so a mention ending a sentence is clean.
*
* Code spans are not a concern here: callers run this over text runs, and markdown code never reaches them.
*/
export function findMentions(text: string): FoundMention[] {
const out: FoundMention[] = [];
MENTION.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = MENTION.exec(text)) !== null) {
const lead = m[1] ?? '';
const quoted = m[2];
// A trailing `.` / `-` belongs to the surrounding prose, not to the username.
const name = quoted ?? (m[3] ?? '').replace(/[.-]+$/, '');
if (!name) continue;
const start = m.index + lead.length;
// `@` + name, plus the two quotes when quoted.
const end = start + name.length + (quoted ? 3 : 1);
MENTION.lastIndex = end;
if (text[end] === '/') continue; // scoped package, not a mention
out.push({ start, end, name });
}
return out;
}
Loading
Loading