Skip to content

Commit be646ed

Browse files
feat(console): tell the comment author, in one sentence, which mentioned users will not be notified; draw picked mentions as chips (#28261)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoPzeJHeDfhpMWRZ877JUwQh)_ Fixes #28218 Refs #27703 Clause-②: yes (widening: `@object-ui/data-objectstack` `ObjectStackAdapter.create` gains an optional third parameter `opts.onMentionsWithheld`, and `.update`'s existing `opts` gains the same optional member; both still resolve the record, and no export, key or route is added) Metadata-protocol: none ## 维护者速读 - 改了什么:在记录讨论里 @ 了一个看不到这条记录的同事时,评论照常发出,同时弹出一句提示:「Grace Hopper、Linus 不会收到这条评论的通知。」发评论、回复、编辑时新加 @ 三处都会提示;不说原因,没有被拦下的人就不提示。输入框下方把已 @ 的人显示为标签,预览里的 @姓名 也显示为标签。 - 为什么改:按 #27703 裁决 A(「27703 同意」),服务端已在 #28174 把被拦下的人的 id 回给作者本人的写入答复(`withheldMentions`),但控制台一直不读,作者以为对方已被通知。 - 风险与回滚:数据适配器的 `create` / `update` 多了一个可选的逐次回调,返回值不变,其他调用方不受影响;回调抛错不会让已保存的评论被当成失败。回滚即 revert 本 PR,服务端不受影响。 - 先看:`ui/packages/app-shell/src/views/RecordDetailView.tsx`(`tellMentionsWithheld` 与三个评论写回调);`ui/packages/data-objectstack/src/index.ts`(`notifyWithheldMentions`);`ui/packages/plugin-detail/src/RichTextCommentInput.tsx`(标签行与预览)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx packages/data-objectstack/src/withheldMentions-28218.test.ts packages/plugin-detail/src/__tests__/RichTextCommentInput.mentionChips-28218.test.tsx`,预期 3 个文件、14 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes **The answer reaches the caller** (`ui/packages/data-objectstack/src/index.ts`). Measured first: `ObjectStackAdapter.create` / `.update` resolved `result.record` and dropped every other member of the write answer, so `withheldMentions` never reached the record page. Both methods now take an optional per-call listener, `opts.onMentionsWithheld(ids)` (on `update` beside `ifMatch`, which still reaches the client alone). A module helper, `notifyWithheldMentions`, reads the answer structurally, keeps the string ids, and calls the listener once after the write succeeded, only when at least one id is named. A runtime that predates the key, or a write that withheld nobody, calls nothing. A throwing listener is isolated like the subscriber fan-outs, so a stored comment is never reported as failed. What every caller resolves is unchanged. A per-call listener was chosen over the global `onWriteWarning` channel because the sentence needs the picker's names, which only the writing page holds. **One sentence, no reason** (`ui/packages/app-shell/src/views/RecordDetailView.tsx`). `tellMentionsWithheld` maps the ids to names through the mention picker's suggestions (the list the write's `mentions` were extracted from) and raises one `toast.warning`: `detail.mentionsWithheld`, "NAMES will not be notified of this comment.", the names joined with the pack's `validation.formInvalidJoiner`, the list punctuation the record feed already uses. The three `sys_comment` writes pass it: `handleAddComment`, `handleAddReply` and `handleEditComment`. Per the ruling the sentence gives no reason (one bit per named user), and nothing probes a colleague's access before the write (B was not taken). **Mention chips** (`ui/packages/plugin-detail/src/RichTextCommentInput.tsx`). Under the editor, the users the text mentions are a list of chips labelled `detail.mentionedPeople`, resolved with the same `extractMentions` the write uses, so the row shows exactly whom the comment's `mentions` will carry, multi-word names whole. In the preview, each picked `@NAME` is one chip (longest name first, not followed by a letter or digit, as `extractMentions` matches); any other `@word` keeps the plain highlight it had. The preview escapes the text before any markup, chips included. **Copy.** `detail.mentionsWithheld` and `detail.mentionedPeople` in all ten packs. The sentence is phrased so it needs no plural forms in any pack. `detail.mentionedPeople` is also in `DETAIL_DEFAULT_TRANSLATIONS` (the provider-less mirror of the `en` pack). **File surface.** Within the order's list: `RecordDetailView.tsx` (the three callbacks and one helper), `RichTextCommentInput.tsx`, `data-objectstack/src/index.ts`, the ten packs, tests and the root changeset. Outside it, both small: `plugin-detail/src/useDetailTranslation.ts` (one row, the chip row's label, which the defaults-mirror test requires beside the pack) and `ui/packages/data-objectstack/README.md` (the method list names the new option, per `ui/AGENTS.md` #2). No `packages/spec/**` and no server change. **Serial constraints.** Neither #28116 nor #27713 has landed (both open at the time of writing); this branch is on `main` `86cf7cef`. The edits in `RecordDetailView.tsx` are the helper and three call sites, in hunks neither PR touches as far as a read of their descriptions shows; whichever lands first, this branch merges `origin/main` and resolves. ## Tests New: - `ui/packages/data-objectstack/src/withheldMentions-28218.test.ts` (4): `create` hands the ids to the listener once and still resolves the plain record, and the listener is never forwarded to the client; `update` hands them on and `ifMatch` still reaches the client alone (no options bag without it); CONTROL: an answer without the key, with an empty list, or with no string ids calls nothing; a write without the listener resolves as before, and a throwing listener does not reject a stored write. - `ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx` (5), through the real `RecordDetailView`, its composer, reply input and edit form, under a real `I18nProvider` with the `en` pack read by key: a comment names the withheld users by their picker names (a multi-word name included) and is posted; only the withheld users are named; CONTROL: a comment whose mentions all reach their users says nothing; a reply names them; an edit that adds a mention names them. - `ui/packages/plugin-detail/src/__tests__/RichTextCommentInput.mentionChips-28218.test.tsx` (5): the chip row names each picked user once, multi-word names whole; it follows the text and is absent with no mention; a prefix of a longer name or an `@word` naming nobody is no chip; the preview draws a picked `@NAME` as one chip and keeps other `@word`s plain; the preview escapes the text. Updated: three assertions that pinned the `sys_comment` create's exact arguments now include the per-call listener (`commentWriteFailure-10899` ×2, `feedRecordScope`). Their behaviour is unchanged. **Ablations** (each through `scripts/ablation-replace.mjs`, mutation proved on disk, restored to the HEAD blob): - `RecordDetailView.tsx`, the comment write without `onMentionsWithheld` → `RecordDetailView.withheldMentions-28218` 2 failed / 3 passed (the two comment cases); restored, blob `6cf08c0a` = HEAD. - `data-objectstack/src/index.ts`, `notifyWithheldMentions` not calling the listener → `withheldMentions-28218` 3 failed / 1 passed (the control stays green, as it should); restored, blob `20fba8d2` = HEAD. ## Acceptance notes - **Live, on a stock stack** (`pnpm dev -- --fresh`, showcase, at this branch's build, 2026-10-30T20:11Z). As the seeded admin, a user created through `POST /api/v1/auth/admin/create-user` with no membership (`membershipCreated: false`) cannot read the org's records. `POST /api/v1/data/sys_comment` on a `showcase_project` record mentioning that user and `usr_showcase_auditor_demo` answered `withheldMentions` with both ids; a comment with no mentions answered no such key (`id`, `object`, `record` only); a `PATCH` adding the user to a comment that had no mention answered `withheldMentions` with that id. Through the real `ObjectStackAdapter` (built `dist`) and `@objectstack/client` against the same server: `create` with the listener heard `[that id]` and resolved the record without the key; an `update` that added no mention heard nothing. The server was stopped after the measure. - The mention chips are drawn in the composer and its preview, as the ruling names. A posted comment's body in the feed is rendered by the timeline, not by this file, and is unchanged here. - The sentence is a `toast.warning` after a successful write: the comment is posted, so it is not an error. ## Verification <details><summary>Local runs</summary> Head `fcdb4c48`, on `main` `86cf7cef`. - `pnpm exec turbo run build` (whole repo, under the verify lock) → `Tasks: 116 successful, 116 total`, VERDICT command-exit 0. - `type-check` of `@object-ui/data-objectstack`, `@object-ui/plugin-detail`, `@object-ui/app-shell` (each `tsc --noEmit`, plus `tsconfig.test.json` where the script runs it) → exit 0 each. - Tests (the app-shell and console files are those under `ui/` that name `sys_comment`, `mentionSuggestions`, `RichTextCommentInput` or `extractMentions`): - Under the verify lock: `pnpm --dir ui exec vitest run packages/data-objectstack/ packages/plugin-detail/` plus those app-shell and console test files, the new record-page file and `defaults-maps-mirror-en-pack` → `Test Files 473 passed | 1 skipped (474)`, `Tests 4547 passed | 18 skipped (4565)`, VERDICT command-exit 0. - `pnpm --dir ui exec vitest run packages/i18n/ packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` → `Test Files 93 passed (93)`, exit 0. - eslint on the 19 changed `ts` / `tsx` files under `ui/` → 0 errors. `pnpm --dir ui run check:new-line-citations` → 0 new citations, exit 0. - Main has moved 6 commits past `86cf7cef`; neither #28116 nor #27713 is among them, and `git merge-tree` with `origin/main` is clean (the ten packs are shared paths, in other hunks). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `fcdb4c48` → 35 commands, each run with stdin from /dev/null and its exit code captured before any pipe; `--ran` reconcile: `35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. Two more beyond the derivation, the Type Check UI i18n legs. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --dir ui run check:i18n-keys :: exit 0 pnpm --dir ui run check:i18n-drift :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvjxjPWDhWVKzkEU2x)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 916531a commit be646ed

21 files changed

Lines changed: 668 additions & 20 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/plugin-detail': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
feat(console): a comment author is told, in one sentence, which mentioned users will not be notified
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
When a record comment @mentions someone the server's mention gate refuses, the server saves the comment with every mention, withholds that person's notification, and answers their ids to the author's own write as `withheldMentions`. The Console said nothing, so the author believed the colleague had been told. Now:
14+
15+
- **One sentence, no reason.** Posting a comment, posting a reply, or saving an edit that adds a mention raises one warning naming the users who will not be notified, by the names the mention picker offered ("Grace Hopper, Linus will not be notified of this comment."). It gives no reason. A write that withheld nobody says nothing. The copy is `detail.mentionsWithheld` in all ten packs.
16+
- **The answer reaches the caller.** `ObjectStackAdapter.create` and `.update` take an optional per-call `onMentionsWithheld(ids)` listener (on `update`, beside `ifMatch`). It is called once, after the write succeeded, only when the answer names at least one id; a throwing listener does not fail the write. Both methods still resolve the record, so no other caller sees a change.
17+
- **Mention chips.** The comment composer draws the users the text mentions as a row of chips under the editor (labelled `detail.mentionedPeople`), resolved the way the write resolves them, and the preview draws each picked `@name` as one chip, multi-word names whole.

‎ui/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ describe('a failed comment write is never rendered as sent (objectui#10899)', ()
177177
expect(dataSource.create).toHaveBeenCalledWith(
178178
'sys_comment',
179179
expect.objectContaining({ thread_id: `${OBJECT_NAME}:${RECORD_ID}`, body: 'Called the customer, follow up Friday' }),
180+
// The per-call listener for the answer's `withheldMentions` (objectstack#28218).
181+
{ onMentionsWithheld: expect.any(Function) },
180182
);
181183
// The panel still says there is nothing — the comment is not "sent".
182184
expect(feedRow('Called the customer, follow up Friday')).toBeNull();
@@ -243,6 +245,7 @@ describe('a failed comment write is never rendered as sent (objectui#10899)', ()
243245
parent_id: 'c1',
244246
body: 'Countersigned copy attached',
245247
}),
248+
{ onMentionsWithheld: expect.any(Function) },
246249
);
247250
// The reply is not "sent": no reply row, and the parent shows no reply count.
248251
expect(feedRow('Countersigned copy attached')).toBeNull();

‎ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,8 @@ describe('RecordDetailView — optimistic comments ride with their own record (#
450450
expect(dataSource.create).toHaveBeenCalledWith(
451451
'sys_comment',
452452
expect.objectContaining({ thread_id: `${OBJECT_NAME}:${REC_B}`, body: 'written on B' }),
453+
// The per-call listener for the answer's `withheldMentions` (objectstack#28218).
454+
{ onMentionsWithheld: expect.any(Function) },
453455
);
454456

455457
rerender(tree(dataSource, REC_A, [AUTHORED_PAGE_WITH_DISCUSSION]));

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3049,6 +3049,28 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
30493049
[t],
30503050
);
30513051

3052+
/**
3053+
* Say in one sentence who a comment write's mentions will not reach (ruling A
3054+
* on objectstack#27703). The server withholds the notification of a mentioned
3055+
* user the mention gate refuses and answers their ids to the author's own
3056+
* write as `withheldMentions`; without this the author believes they were
3057+
* told. The ids map to names through the picker's suggestions, the list the
3058+
* write's own `mentions` were extracted from. No reason is given: the
3059+
* disclosure is one bit per user the author named.
3060+
*/
3061+
const tellMentionsWithheld = useCallback(
3062+
(ids: string[], suggestions: ReadonlyArray<{ id: string; label: string }>) => {
3063+
const names = ids.map(id => suggestions.find(s => s.id === id)?.label || id);
3064+
toast.warning(
3065+
t('detail.mentionsWithheld', {
3066+
defaultValue: '{{names}} will not be notified of this comment.',
3067+
names: names.join(t('validation.formInvalidJoiner', { defaultValue: ', ' })),
3068+
}),
3069+
);
3070+
},
3071+
[t],
3072+
);
3073+
30523074
const handleAddComment = useCallback(
30533075
async (text: string) => {
30543076
if (!dataSource || !feedRecordKey) {
@@ -3078,7 +3100,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
30783100
body: text,
30793101
mentions: JSON.stringify(mentionIds),
30803102
created_at: newItem.createdAt,
3081-
});
3103+
}, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) });
30823104
} catch (err) {
30833105
throw commentWriteFailed(err);
30843106
}
@@ -3088,7 +3110,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
30883110
[key]: [...(prev[key] ?? EMPTY_FEED), newItem],
30893111
}));
30903112
},
3091-
[currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed],
3113+
[currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed, tellMentionsWithheld],
30923114
);
30933115

30943116
const handleAddReply = useCallback(
@@ -3119,7 +3141,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
31193141
mentions: JSON.stringify(mentionIds),
31203142
created_at: newItem.createdAt,
31213143
parent_id: parentId,
3122-
});
3144+
}, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) });
31233145
} catch (err) {
31243146
throw commentWriteFailed(err);
31253147
}
@@ -3139,7 +3161,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
31393161
};
31403162
});
31413163
},
3142-
[currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed],
3164+
[currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed, tellMentionsWithheld],
31433165
);
31443166

31453167
/**
@@ -3187,7 +3209,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
31873209
stored = await dataSource.update(COMMENT_OBJECT, String(itemId), {
31883210
body: text,
31893211
mentions: JSON.stringify(extractMentions(text, mentionSuggestions)),
3190-
});
3212+
}, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) });
31913213
} catch (err) {
31923214
throw editFailed(err);
31933215
}
@@ -3199,7 +3221,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
31993221
),
32003222
}));
32013223
},
3202-
[dataSource, feedRecordKey, feedItemsByRecord, mentionSuggestions, t],
3224+
[dataSource, feedRecordKey, feedItemsByRecord, mentionSuggestions, t, tellMentionsWithheld],
32033225
);
32043226

32053227
/**

0 commit comments

Comments
 (0)