Skip to content

feat(app-shell,plugin-detail): the author of a record comment can edit and delete it from the record feed (objectui#12268) - #12269

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-12268-feed-comment-edit-delete
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-12268-feed-comment-edit-delete

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #12268
Clause-②: yes

The record feed now offers the author of a comment Edit and Delete on their own comment and reply. Nobody else is offered either control on it. Triage 6127815908 ruled fork A: sys_comment is managedBy: 'platform', which declares the generic Edit / Delete affordances, so the feed honours that declaration and no spec key is added. The Clause-②: yes above is the package surface (listed under "Published surface"), not the spec.

What changes

  • One owner rule. isCommentAuthor(authorId, viewerId) is new in @object-ui/collaboration. CommentThread now reads it instead of its inline comparison, and the record page asks it for every sys_comment row it maps. Two absent or empty ids are no match (the inline comparison read two empty ids as ownership).
  • The host decides authorship, the feed never derives it. RecordDetailView marks each comment row with the new optional FeedItem.authoredByViewer, through that rule, and hands onEditComment / onDeleteComment to the feed through DiscussionContext. Each handler is handed over under its own grant on sys_comment, through two new rows of the affordance-to-grant map: feedCommentEdit (update) and feedCommentDelete (delete).
  • The feed. RecordActivityTimeline, and the reply list ThreadedReplies, offer Edit and Delete on a comment row only when the host marked it the viewer's AND handed over that control's handler. record:activity and record:discussion (through RecordChatterPanel) forward both handlers off the context. A row that offers neither renders the same body paragraph as before.
  • Edit opens the text in place (Save and Cancel; Escape cancels, Ctrl/Cmd+Enter saves). The page writes body, mentions (re-derived from the new text with the composer's extractMentions), is_edited: true and edited_at, and changes the row only after the server answers. A refusal raises a localized error toast; the editor keeps the draft and the row keeps its text.
  • Delete asks first through the page's own confirmHandler, which opens the same ActionConfirmDialog the record's own Delete uses, painted destructive (the mechanism objectui#12248 used for attachments). Cancel sends nothing. A refusal raises a localized error and the row stays. A deleted reply takes one off its parent's replyCount.
  • Edited mark. The read maps is_edited onto the existing FeedItem.edited, so a stored edit reads as edited after a reload. A reply now shows the mark too.
  • Five detail.* keys in all ten packs; docs in content/docs/plugins/plugin-detail.mdx; changeset .changeset/12268-feed-comment-edit-delete.md (minor for every touched published package; no major).

Measurements (the dispatch's mechanism assumptions)

  1. The server stamps neither is_edited nor edited_at. On objectstack origin/main c5b43b60, git grep -c over the whole tree finds is_edited and edited_at only in packages/plugins/plugin-audit/src/objects/sys-comment.object.ts (the field declarations) and four generated translation files. comment-access-hooks.ts has zero hits for either; control: the string "authors govern their own words" hits that file once. Its beforeUpdate authorizes (the author, or a user who can edit the parent record) and writes nothing. So the ruling's other branch applies: the client that makes the edit records it, and the next read shows the mark from the row itself.
  2. The feed item did not carry the author id. FeedItem has actor (a display name) and no id, and the page's sys_comment read dropped author_id. The route below never sends an id to the feed: the host answers ownership per row.
  3. The route for one owner rule. Both suggested routes were measured. (a) A predicate in a package both depend on: plugin-detail and collaboration share only @object-ui/types and @object-ui/i18n, neither of which carries behaviour, so (a) costs a new dependency edge or a logic export from the types package. (b) The host decides per row: app-shell already imports @object-ui/collaboration (presence), so RecordDetailView reads the exported rule with no new module edge, and the feed reads one boolean. Chosen: (b). It adds one export to collaboration and one optional data member to FeedItem, and the feed gains no dependency. CommentThread reads the same export, so there is still exactly one rule.
  4. Published surface: below, read off the built declarations.
  5. The confirm: RecordDetailView already mounts ActionConfirmDialog behind its promise-based confirmHandler (the record Delete's, objectui#11001). The comment delete calls that handler: no new confirm component and no second dialog mount.

Where the measured route left the suggested one: the sys_comment text field is body, not content. And the feed's sys_comment read does not re-run on the invalidation bus (its effect keys on the record, the reaction store and the user), so, as handleAddComment / handleAddReply already do, the page writes the server-confirmed result into its own per-record feed slice once the write answers. No remount and no key= bump (AGENTS.md rule 8).

Published surface

Generic arguments are spelled in words (PROMISE of void) because GitHub strips angle-bracket fragments from PR bodies.

  • @object-ui/collaboration (entry dist/index.d.ts): new function isCommentAuthor(authorId: string | null | undefined, viewerId: string | null | undefined): boolean.
  • @object-ui/types, FeedItem: new member authoredByViewer?: boolean.
  • @object-ui/react, DiscussionContextValue (and so DiscussionContextProviderProps): new members onEditComment?: (itemId: string | number, text: string) => void | PROMISE of void and onDeleteComment?: (itemId: string | number) => void | PROMISE of void.
  • @object-ui/plugin-detail: the same two members, with the same types, on RecordActivityTimelineProps, RecordChatterPanelProps and ThreadedRepliesProps. CommentBody / CommentBodyProps in the new CommentAuthorControls module are not re-exported from the entry, and the package's exports map names . only.
  • @object-ui/core, AFFORDANCE_GRANTS: new rows feedCommentEdit: { crud: 'edit', grant: 'update' } and feedCommentDelete: { crud: 'delete', grant: 'delete' }, so ConsoleAffordance gains those two names. No exhaustive map over ConsoleAffordance exists outside core's own mapped type.
  • @object-ui/i18n: detail.editComment, detail.deleteComment, detail.deleteCommentConfirm, detail.commentEditFailed, detail.commentDeleteFailed, in all ten packs.

Every member is new and optional; no existing member's type changed.

Tests (round 0 at head 50c82f9; patch round 1 at 258bfee below)

New pins:

  • packages/plugin-detail/src/__tests__/RecordActivityTimeline.authorControls-12268.test.tsx (13): controls only on a comment row the host marked the viewer's and only with the handler; edit, refusal, empty/unchanged draft, Cancel/Escape; delete; edited mark on a row and a reply; a reply's own controls; both feed blocks forward the context's handlers.
  • packages/app-shell/src/views/RecordDetailView.feedCommentEditDelete-12268.test.tsx (19), through the real RecordDetailView under a real I18nProvider and MePermissionsProvider, for both record:activity (HotCRM's case-page shape) and record:discussion: the author sees both controls on their own comment and neither on another's; the page asks isCommentAuthor per row; another member sees them on their own comments and not on the author's; a stored is_edited reads as edited; the edit writes body / is_edited / edited_at / mentions and then reads as edited; a refused edit raises the error and keeps the text; delete through the real ActionConfirmDialog (Cancel sends nothing, Delete deletes); a refused delete raises the error and keeps the row; each control under its own grant.
  • packages/collaboration/src/__tests__/comment-authorship-12268.test.tsx (4): the rule's truth table, and CommentThread calls it.
  • Updated: packages/plugin-form/src/affordanceGrantMap-12082.test.tsx (the two rows in EXPECTED_GRANT, and WRITE_SITES entries for handleEditComment / handleDeleteComment).

Runs, all through the verify lock, repo-root form, --maxWorkers=2:

  • packages/plugin-detail/ + packages/collaboration/ + the census file: Test Files 271 passed | 1 skipped (272), Tests 2679 passed | 14 skipped (2693).
  • packages/react/ + packages/i18n/: Test Files 193 passed (193), Tests 2732 passed | 13 skipped (2745).
  • 151 grep-selected files elsewhere (every test file naming RecordDetailView, DiscussionContext, RecordActivityTimeline, RecordChatterPanel, ThreadedReplies, DETAIL_DEFAULT_TRANSLATIONS, resolveAffordance or AFFORDANCE_GRANTS, plus types' zod-mirror-parity and timeline-item-element-6356 and app-shell's countLabels.ruAr-10242): 76 passed (76) / 1089 passed, and 75 passed (75) / 793 passed.
  • Declared narrowing: the rest of app-shell's test files and of core / types are CI's. The grep selection is blind to a test that reaches RecordDetailView without naming it (for example through console routes).

Type-check (script type-check, name echoed per package): types, react, core, collaboration, plugin-detail, app-shell, plugin-form, i18n joined with &&, exit 0. Downstream (consumer direction) of the widened symbols, selected by grepping the symbols: fields, plugin-calendar, plugin-gantt, plugin-grid, plugin-kanban, plugin-list, plugin-timeline, plugin-view and apps/console, all Done. Reverse check: handing onEditComment a (id: boolean, text: number) function in RecordDetailView turned pnpm --filter @object-ui/app-shell type-check red with TS2322 against (itemId: string | number, text: string) => void | PROMISE of void, so app-shell reads the rebuilt type; restored to the HEAD blob.

Ablations, each through objectstack's scripts/ablation-replace.mjs (anchor hit exactly once, blob changed on disk, restore proven blob == HEAD and git diff HEAD empty). The subjects resolve to src through the vitest alias map, so no build sits between legs:

Leg Mutation Pins
A1b the page compares c.author_id === currentUser.id inline (a second owner rule) host: 2 failed / 17 passed (the "asks the one rule" case, both blocks)
A1 the page marks no row authoredByViewer host: 16 failed / 3 passed
A2 the read drops the is_edited mapping host: 2 failed / 17 passed
A3 delete skips confirmHandler host: 4 failed / 15 passed
A4 the edit drops is_edited: true host: 2 failed / 17 passed
A5 CommentThread compares inline collaboration: 2 failed / 2 passed
A6 the timeline ignores authoredByViewer timeline + host: 9 failed / 23 passed

The first A4 attempt was refused by the tool itself (the replacement text was already present once), so it measured nothing; it was re-run in --delete mode, the row above.

Patch round 1 (seat REWORK 6129134468):

  • Patch round 1, head 258bfee: RecordActivityTimeline.authorControls-12268.test.tsx +3 (with threading on, a reply whose parent the feed does not hold is a root row between the rows it sits between in time; CONTROL: with its parent held the same reply nests behind the toggle; the viewer's orphaned reply keeps its Edit and Delete). RecordDetailView.feedCommentEditDelete-12268.test.tsx +2, one per block (record:activity, record:discussion): Ada deletes her comment carrying Grace's reply through the real confirm; Grace's reply stays as a row of its own with no toggle and no controls for Ada, and stays on a fresh mount whose sys_comment read returns the stored rows, the orphan among them (the fake server, like the real one, keeps replies on delete).
  • Ablation A7 (objectstack scripts/ablation-replace.mjs, rootItems back to !i.parentId): Tests 4 failed | 33 passed (37) across the two files (both host cases, two timeline cases); restore blob == HEAD (6728eef7a2a4), git diff HEAD empty.
  • Runs at 258bfee, through the verify lock: plugin-detail's whole suite in two halves, Test Files 132 passed (132) / Tests 955 passed | 6 skipped (961) and Test Files 131 passed | 1 skipped (132) / Tests 1595 passed | 8 skipped (1603); the same 151 grep-selected files as before, 76 passed (76) / 1089 passed and 75 passed (75) / 795 passed; pnpm --filter @object-ui/plugin-detail type-check && pnpm --filter @object-ui/app-shell type-check exit 0; eslint on the 3 touched files 0 errors, warnings unchanged from 50c82f9; check:eager-closure exit 0 (3131.2 KB, headroom 73.3 KB, console rebuilt at 258bfee).

Gates, at 50c82f9 (exit 0 each): check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check:changeset-claims, check:pending-changeset-literals, check:new-line-citations (0 new), check:control-bytes, check:i18n-keys, check:i18n-drift, check:i18n-dead-keys, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:test-path-roots, check:readme-exports, check:unreferenced-sources, check:handler-key-reads, check:eager-closure, check:eager-locale-catalogues, check:doc-snippets, check:doc-fences, check:doc-json, check:doc-example-ids, check:doc-types, check:self-import, check-doc-links, check-doc-expression-carriage. The eager-closure gates read a console bundle built at 029d951; the one later commit removes an export keyword from a module-private function.

eslint on the 28 changed lintable files, --format json: 0 errors. On every one of the 23 modified files the warning count at the base equals the count at head. The two new source files have 0 warnings; the new tests carry as any warnings like their neighbours. eslint.config.js enables no type-aware linting (no parserOptions.project or projectService), so this diff cannot move a verdict on an untouched file.

Acceptance notes

  • No server stamp. plugin-audit stamps neither is_edited nor edited_at, so an edit through the data API (not this console) leaves the comment reading unedited, and any client may send is_edited: false. A beforeUpdate stamp in objectstack (set both when body changes) would make the mark trustworthy; once it exists, this client stamp should go (the ruling's first branch). Inferred from source, not measured through a public door, so not filed. Carrier: the next objectstack change to comment-access-hooks.ts.
  • The boundary of the owner rule. The server also lets a parent-record editor update or delete any comment. The feed offers the controls to the author only, as ruled.
  • Deleting a commented-on comment. The server deletes only that row and keeps its replies, whose parent_id then names no row. With threading on, the feed now nests a reply only when it holds the reply's parent; a reply whose parent the feed does not hold is a root row in its place (rootItems in RecordActivityTimeline), so another member's reply stays on the page, at once and after the feed is read again. The same rule shows a reply whose parent lies before the loaded page as a row of its own until "Load more" brings the parent in; before, such a reply was hidden. The client cascades nothing and adds no rule about which comments may be deleted: that stays the server's call.
  • Mentions on edit. The mention notification fires on insert only (plugin-audit), so a mention added by an edit notifies nobody; mentions is still rewritten so the row's list matches its text.
  • The stale comment the seat noted on objectui#12249 (the old platform-objects path and reactions in the sys_comment read's field list) sits in lines this change edits; it now names sys_comment in @objectstack/plugin-audit, lists is_edited / edited_at, and scopes reactions to the column store.
  • canStoreReaction's docblock in RecordActivityTimeline.tsx still says reactions are stored in sys_comment.reactions; untouched here. Carrier: the next change to the reaction path.
  • Deviation: one 16-second verbose listing run of the three new pin files ran outside the verify lock.

Generated by Claude Code

…t and delete it from the record feed (objectui#12268)

The record feed offers the author's Edit and Delete on their own comment
and reply. Whose comment it is comes from one rule, `isCommentAuthor` in
@object-ui/collaboration, which CommentThread now reads too; the record
page asks it for each sys_comment row it maps (FeedItem.authoredByViewer)
and hands the handlers over through DiscussionContext under new
affordance rows (feedCommentEdit, feedCommentDelete). Delete asks through
the page's own ActionConfirmDialog. The server's rule stays the
authority: a refused write raises an error and the row keeps its text.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…d Delete on their own record comment (objectui#12268)

Three pins: the timeline offers the controls only on a comment row the
host marked the viewer's and only with the host's handler; the real
record page asks isCommentAuthor per row, edits with the comment marked
edited, deletes through its own confirm, surfaces refusals, and hands
each control over under its own grant, through both record:activity and
record:discussion; CommentThread reads the same exported rule.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…eset (objectui#12268)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…ivate (objectui#12268)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3131.2 KB 3204.6 KB
Main entry chunk (gzip) 73.5 KB 350 KB
Entry file index-CpWk5dDJ.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 588.51KB 141.71KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 244.49KB 68.27KB
fields (index.js) 280.00KB 71.39KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.78KB 38.84KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 254.39KB 66.98KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.36KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 255.09KB 70.91KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.35KB 31.54KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 45.65KB 12.81KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

…row of its own, so deleting a comment hides no replies (objectui#12268)

With threading on, the timeline dropped every item with a parentId from
its roots and nested replies only under parents it held. Once an author
deleted a comment, every reply to it (another member's included) left
the page, at once and after a re-read. A reply now nests only when the
feed holds its parent and is otherwise a root row in its place. Who may
delete stays the server's call; the client cascades nothing.

Pinned through the real record page on record:activity and
record:discussion, and in the timeline.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3131.2 KB 3204.6 KB
Main entry chunk (gzip) 73.5 KB 350 KB
Entry file index-BJ2UQCtr.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 588.51KB 141.71KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 244.49KB 68.27KB
fields (index.js) 280.00KB 71.39KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.78KB 38.84KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 254.49KB 67.01KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.36KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 255.09KB 70.91KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.35KB 31.54KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 45.65KB 12.81KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 258bfee2fbc847121cac394859679dc0dbe34757
Local-runs: none

Inputs read: card #12268 (body and all five comments: triage 6127815908, claim 6127978495, dev reports 6129076734 and 6129598042, seat REWORK 6129134468), PR #12269 (body, 30-file list, net diff against main 5dcb101), and the 33 check-runs on the head. The public surface is judged from each touched package's entry source (packages/*/src/index.ts[x]) and its package.json exports map at the head, not from built declarations. Reviewed as an isolated subagent of the seat named below; the seat's own REWORK conclusions were not taken as inputs to this verdict.

① Derived judgments

Every public-surface and accept-set change the diff implies, with the entry that carries it:

  • @object-ui/collaboration (exports names . only; entry src/index.ts): new export isCommentAuthor(authorId, viewerId): boolean — right. It is the one owner rule: CommentThread reads it at its one isOwner site (the head file holds no other author-id comparison; the remaining currentUser.id read is the reaction-active style), and RecordDetailView asks it per sys_comment row. Its narrowing of the degenerate case (two empty or absent ids no longer read as ownership) is a behaviour correction inside an accepted input, declared in the changeset; no type or accept-set narrows.
  • @object-ui/types (entry re-exports FeedItem from ./views.js): new optional member FeedItem.authoredByViewer — right. Additive, host-set, never derived by the feed (offersAuthorControls in the non-exported CommentAuthorControls module reads only type === 'comment' && authoredByViewer === true).
  • @object-ui/react (entry → ./context/index.js → DiscussionContext.js): new optional members onEditComment(itemId: string | number, text) and onDeleteComment(itemId: string | number) on DiscussionContextValue (and so on the provider's props); the provider's memo deps gained both — right. Additive; typed on the feed's own item id so both renderers forward them without a cast.
  • @object-ui/plugin-detail (exports names . only; entry src/index.tsx): the same two optional members on RecordActivityTimelineProps, RecordChatterPanelProps and ThreadedRepliesProps; record:activity and record:discussion forward them off the context — right. CommentBody / CommentBodyProps are not re-exported from the entry, as the body claims. Accept-set of the authorable blocks: unchanged — no RecordActivityProps key is added, which is what triage 6127815908 ruled (fork A) and the claim fenced.
  • @object-ui/plugin-detail, behaviour of a published component: with enableThreading, rootItems now nests a reply only when items holds its parent (a Set of ids, the same identity repliesByParent keys on); an orphaned reply (parent deleted, or before the loaded page) is a root row in its place — right, and it is the fix the REWORK asked for. The client cascades nothing and adds no delete rule. Declared in the changeset and in the enableThreading docs row. Side effect on a reply whose parent lies before the loaded page: a previously hidden row becomes visible; declared, and covered by the same timeline pin (the mechanism is "parent not held", whichever cause).
  • @object-ui/core (entry → ./utils/affordanceGrants.js): two rows in AFFORDANCE_GRANTS, feedCommentEdit: { crud: 'edit', grant: 'update' } and feedCommentDelete: { crud: 'delete', grant: 'delete' }, so ConsoleAffordance (keyof typeof AFFORDANCE_GRANTS) gains two names and resolveAffordance accepts them — right. The write census (affordanceGrantMap-12082.test.tsx, EXPECTED_GRANT and WRITE_SITES) names both new write sites, so the census stays exhaustive; Type Check green on the head answers any in-repo exhaustive map.
  • @object-ui/i18n (entry exports en; ./locales is the published door to all ten): five new detail.* keys in all ten packs — right; Lint and Repo Gates (the i18n key and drift gates) are green on the head. plugin-detail's DETAIL_DEFAULT_TRANSLATIONS gains only the two keys that package renders; the three toasts and the confirm are the host's.
  • @object-ui/app-shell: no public type changes; RecordDetailView gains handleEditComment / handleDeleteComment, each handed to the feed under its own grant row on sys_comment (resolveFeedWriteGates → feedGates.commentEdit / commentDelete) — right against the ruling's fences: the server stays the authority (a refusal raises a localized toast and the row is untouched; the row changes only after the write answers), Delete goes through the page's own confirmHandler / ActionConfirmDialog with destructive: true, and author_id is compared in one id space (the sys_comment read sends no $expand, and author_id is written as currentUser.id on create). mentions is rewritten on edit in the same JSON-string shape handleAddComment / handleAddReply write. One observation, not a surface matter: handleEditComment resolves silently (no write, editor closes) for an id that is not a comment row in the slice; that path is reachable only past the timeline's own gate and mirrors the reaction handler's refuse-without-write.
  • No new dependency edge: no package.json is in the file list; app-shell already imported @object-ui/collaboration, and plugin-detail reads only @object-ui/types.
  • Docs: content/docs/plugins/plugin-detail.mdx (the FeedItem block, three prop tables, the enableThreading row and the "needs no input" paragraph) matches the diff sentence by sentence — right. No governed path is in the file list; Governed Surface Queue Guard is green.
  • Pins against the ruling's three (author edits and deletes own comment; another user sees neither; an edited comment reads as edited) and the REWORK's fourth (a delete hides nobody's reply, at once and after a re-read) are present through the real RecordDetailView for both record:activity and record:discussion, plus the timeline and collaboration pins; Test and all twelve shards are green on the head.

Gate verdicts on 258bfee2: every check-run that ran concluded success (Test, shards 1–12, Type Check, Lint, Repo Gates, Build & E2E, Build Docs, Test (dist pins), Spec Main Shape Gate, Governed Surface Queue Guard, Line Citation Gate, Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Overwrite Report, Changeset Claim Re-read, Bundle Analysis, Live E2E (informational), label); Test (coverage) and dependabot are skipped, not failures. Eager closure 3131.2 KB of 3204.6 KB.

② Semver level

The changeset .changeset/12268-feed-comment-edit-delete.md names the seven packages whose published source the diff changes (app-shell, plugin-detail, collaboration, react, types, core, i18n), each at minor; @object-ui/plugin-form is touched only in a test file and publishes nothing, so its omission is right. Every surface change in ① is additive (new optional members, a new export, two map rows, new keys), so the published surface widens and no accept-set narrows: minor is the level the diff publishes, and objectui AGENTS.md §9 forbids major in any changeset — none is declared. The PR body's second line reads Clause-②: yes with no arm, which matches a widening of the package surface (and reads the package entries, not the spec: no spec key is added, so triage's Clause-②: no on the spec and the claim's yes on the packages are both true of this diff). Not breaking, so no migration text and no ADR-0087 disposition is owed. The changeset body's sentences (Edit, Delete, replies outliving their comment on screen, per-control grants, the six published-surface bullets) each match the diff.

③ Boundary flags

open_questions is empty in both dev reports (6129076734, 6129598042). Every declared deviation and out-of-scope finding, answered:

  1. File surface wider than the claim (core, plugin-form census, types, collaboration rule, four plugin-detail files, ten packs, docs): each file is in the diff and each is forced by the route taken; the seat amended the claim on the card. Answered.
  2. Replies carry the same controls (ThreadedRepliesProps) and the edited mark: in the diff, the docs table and the changeset. Answered.
  3. Zone 3: the text field is body, and the host updates its per-record slice after the write answers (no remount): both read off the diff. Answered.
  4. Client stamps is_edited / edited_at: the ruling's fence is conditional ("if the server stamps them, the client does not"); the dev measured that the server stamps neither (comment 6129076734, git grep -c with a positive control string), so the licensed branch applies and the diff stamps both only on the edit write. Answered; the trust limit of the mark is recorded as an acceptance note with its carrier (the next objectstack change to comment-access-hooks.ts). Not escalated: inferred from source, not measured through a public door, so the filing gate's reach is unmet, as the seat recorded.
  5. mentions rewritten from the new text on edit: in the diff, same shape as the composers. Answered.
  6. The stale sys_comment field-list comment rewritten: in the diff, in lines the change edits. Answered.
  7. Declared test narrowing (app-shell ran as 151 grep-selected files): the full suite's verdict is the head's Test check-run and twelve green shards. Answered.
  8. One run outside the verify lock; ablation A4's first attempt refused by the tool and re-run; the worktree recreated for round 1: process deviations with no bearing on the diff. Noted.
  9. Commit trailers in the repo's model-free form rather than the harness reminder's: consistent with both repos' rule. Noted.
  10. Round 1's one-clause edit to the enableThreading docs row: in the diff. Answered.
  11. Round 1's side effect (a reply whose parent lies before the loaded page now shows as its own row): declared in the changeset and docs; the timeline pin "with threading on it renders as a root row" covers the mechanism regardless of cause. Answered, no separate pin owed.
  12. Out of scope, orphaned replies hidden by a delete: promoted by the REWORK and fixed at this head (rootItems), pinned through the host on both blocks and in the timeline, with ablation A7 reported. Answered.
  13. Out of scope, canStoreReaction's stale docblock: a code comment, carrier named. Noted.
  14. The dev reported the PR body was not edited in round 1; the body at this head already carries the round-1 acceptance note and test lines, and they match the diff. Consistent.

Implemented-by: claude/issue-12268-feed-comment-edit-delete
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

needs:contract-review removed by the dispatching seat (domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z) · 2026-10-12T18:00Z.

  • Record: ## Contract review comment 6129751360 on this PR. Served tier CONTRACT_REVIEW_TIER, judged head 258bfee2fbc847121cac394859679dc0dbe34757 (the current head), Local-runs: none, independence pair claude/issue-12268-feed-comment-edit-delete / session_01CsyMRsFJBKNEMa4oPY1b5z, VERDICT: PASS.
  • At this head: 33 check-runs, each success or an expected skip; mergeable_state clean; check-governed-merges --pr NOT governed, 1529 lines.
  • The seat's ACCEPT is 6129645358 on objectui#12268. Landing follows in the same act: pr_ready + automerge_enable (the merge queue).
  • The reviewer's one non-blocking observation (handleEditComment resolves silently for a non-comment id, reachable only past the timeline's own gate) is noted and needs no change.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console: an author cannot edit or delete their own comment on a record page, though sys_comment's data layer allows both (split from objectui#12249)

2 participants