Skip to content

fix(ui): one record action reads as one timeline row with its field changes; every timeline peek row names its actor - #30342

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-7ocxgx
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-7ocxgx

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #30122

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么: 记录动态里,一个操作同时写下的「活动记录」和「审计记录」现在合成一行:这一行保留谁做的和做了什么(「更新了 线索…:公司 · Dev Admin」),下面直接带上改前→改后的值;看不到审计日志的成员看到的也是同一行,只是没有改前改后值。右栏动态预览的每一行也显示操作人了。
  • 为什么改: 卡片的期望是「每行都说清是谁;一个操作对所有人都只是一行,有权限的人在这一行上看到字段差异」。实测:流内紧凑行早已显示操作人,缺的只是右栏预览行;重复两行是真的(新建一条线索,管理员看到「创建了…」和一长串初始值两行)。
  • 风险与回滚: 风险低:只改 plugin-detail 的前端合并与显示,不改数据、接口、spec,ACTIVITY_TYPE_TO_FEED_TYPE 未动,裁决 C(「变更」只放审计条目)不变。配对规则:同一记录、动作对应(create↔created、update↔updated、delete↔deleted)、同一操作人、时间相差不超过 5 秒,就近配对;配不上的照旧各自一行。回滚即撤销本 PR。注意:页签上的「动态 N」数字来自服务端计数,新建一条有字段历史的记录后仍可能比显示的行数多 1,要等另一张卡改 spec 与 REST。
  • 先看: ui/packages/plugin-detail/src/recordTimelineStream.ts(pairActions 与 mergeTimelineStream)、ui/packages/plugin-detail/src/RecordActivityTimeline.tsx(合并行的显示与预览行的操作人)、ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx(新 pin)。
  • 两分钟验证: pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx,预期全部通过;或 pnpm dev:crm 后新建一条线索、改公司名,打开动态页签:「全部」里只有两行(创建了、更新了),每行下面有改前→改后。
  • 需要你做的: 无,席位落地。

What changes

  • One action, one row. mergeTimelineStream (recordTimelineStream.ts) now pairs an activity row with the audit entry the same action wrote, and draws them as ONE row: the activity row, carrying the audit entry as fieldHistory (a package-local hand-off type, TimelineStreamItem). The row keeps its actor and summary; RecordActivityTimeline draws the audit entry's before and after values on a second line of the same row (data-stream-change-diff).
  • The pairing rule (pairActions, pairGap): same record (both sources are read for this one record); matching action kind, audit create / update / delete to activity created / updated / deleted; same actor (the audit's user_id equals the activity's actor_id; where neither names a user, the same display name); stamps no more than TIMELINE_PAIR_WINDOW_MS = 5000 ms apart. Candidates pair nearest in time first, each row and each entry once. Measured on a live CRM boot: activity timestamp and audit created_at of one create, and of one update, were 1 ms apart, with the same user id; plugin-audit writes both in one call. auditEntriesToFeedItems carries the ledger's action and user_id onto its items for this; the host already spread user_id into its enriched entries, so RecordDetailView is unchanged.
  • Unpaired rows draw as before: an audit entry with no activity row is its own row, and with field changes present an unpaired updated activity row still steps aside (the old blanket rule, now applied only to rows that did not pair).
  • Chips (ruling C on [Decision] Field changes are recorded but not shown unless an object opts in: who decides a record shows its change history? #30074 unchanged). 全部 and 活动 show the paired row with its diff; 变更, and the panel's changes_only, show each audit entry on its own (fieldHistoryEntriesOf). A reader without audit access gets no field changes from the host, so nothing pairs and the activity row draws alone, as before.
  • Actor. Measured first: the stream's compact rows already name the actor on main (and did on 987ff33b88); full comment rows do too. The one face without a visible actor was the 动态 panel's peek row (actor only in the hover title). It now shows the actor beside the time (data-record-timeline-peek-actor).
  • Changeset .changeset/30122-timeline-one-row-per-action.md (@object-ui/plugin-detail patch); plugin-detail README says one action is one row.

Tests

All readings on head 0b041cec, the final commit.

  • Pairing pins (new top-level describe in recordTimelineStream-27055.test.tsx): create pairs into one row and 变更 still shows the audit entry alone; update pairs; unpaired audit and unpaired activity rows stay two rows; different actors do not pair; one past the window does not pair and the window's edge does; a different action kind does not pair; two entries for one row pair nearest first; the system on both sides pairs by name.
  • Renderer pins: an audit reader sees ONE compact row with the summary, the actor and the diff line; a reader without the audit log sees the same one row with no diff line; 变更 shows the audit diff on its own row; every peek row names its actor.
  • The ruling C pins from feat(ui): the timeline's Changes chip holds the audit's field changes only; the CRM opens field history on its four main objects #30294 are unchanged and green (the four chips, activity under 活动 with history on and off, panel changes_only).
  • Ablation via scripts/ablation-replace.mjs on recordTimelineStream.ts, anchor const pairs = pairActions(activity, changes); replaced by an empty map (the subject resolves to src through the ui/vitest.config.mts aliases, no dist leg): anchor 1 to 0, blob a406f0a324ba to b2e7fb7be1e8; 6 tests red, exactly the six pins that need a pair (create, update, window edge, nearest first, system by name, the audit reader's one row); the unpaired, different-actor, non-audit-reader, 变更 and peek pins green. Restored: blob equals HEAD a406f0a324ba, git diff HEAD empty.
  • Live (example CRM, objectstack dev --seed-admin --fresh, Vite console, zh-CN). As admin: create a lead, then change its company. 全部 draws two rows: 「更新了 线索 "Live after Lead":公司 · Dev Admin · 刚刚」 with 「公司 Pair Co → Pair Holdings」 under it, and 「创建了 线索 "Live after Lead" · Dev Admin · 刚刚」 with the initial values under it; 变更 shows the two audit entries on their own; the peek rows read summary, actor, time. As a member granted crm_sales_user plus a scratch set reading sys_activity and sys_comment (their sys_audit_log read answers 403 PERMISSION_DENIED): 全部 draws the same two rows with no diff, peek rows name the actor. Screenshots are kept in the project files, not committed.
Test readings
  • Targeted, the three timeline test files: Test Files 3 passed (3), Tests 80 passed (80) after the nearest-first fix (the first run caught a greedy-order pairing: 1 red, fixed in 0b041cec).
  • @object-ui/plugin-detail whole package plus RecordDetailView.recordPageTiers-26678.test.tsx: Test Files 370 passed, 1 skipped (371); Tests 3303 passed, 18 skipped (3321); lock VERDICT command-exit 0.
  • Type-check: @object-ui/plugin-detail exit 0; @object-ui/app-shell exit 0 after a full build (both tsc --noEmit and tsc -p tsconfig.test.json).
  • Ablation run: Test Files 1 failed, 2 passed (3); Tests 6 failed, 74 passed (80).

Acceptance notes

  • The 动态 N badge can still read one more than the rows drawn. The tab badge, the panel's 「查看全部 N 条」 and the peek total read the server's timeline count (packages/rest/src/record-page-routes.ts, answerTimeline: comments + audit writes + drawn activity, minus updated rows when any field change exists), whose formula the spec spells as an upper bound (packages/spec/src/api/record-page.zod.ts, the RecordPageTimelineRequestSchema table and RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE). Live: one create plus one update read 「动态 3」 for the admin over two drawn rows (the member, who counts no audit, read 「动态 2」). Counts the client makes itself (the stream's own header count and the count probe where the host has no total) now count one per action. Options for the follow-up card: (A) let created and deleted step aside too, beside updated, when any field change is counted, in the spec table, its .describe() text and answerTimeline; (B) count only activity rows when both sources are on and ignore audit writes, dropping unpaired audit entries from the count; (C) leave the upper bound. The seat files it as its own card.
  • A reader without audit access still sees an empty 变更 chip on a history-on object: the host's historyEnabled reads enable.trackHistory and whether sys_audit_log is registered, not whether this reader may read it (live: the member's 变更 was empty). Not changed here.
  • Window choice: 5 s is far above the 1 ms measured and allows a driver that keeps whole seconds; two different actions by the same user on the same record within 5 s of each other still pair correctly because pairing also needs the matching kind and goes nearest first.
  • No other open PR names recordTimelineStream or RecordActivityTimeline (search at PR time: 0 and 0).

Verification

Gates from node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived at 0b041cec (35 commands).

Gate readings
  • 35 of 35 run serially after a full build, each exit code captured before any pipe: all 35 exit 0. --ran: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED (a DERIVED zero — all 35 recorded an exit code and none of them is 3)".
  • node scripts/check-sdui-manifest.mjs exit 0; pnpm --dir ui exec vitest run scripts/: Test Files 170 passed, 2 skipped (172); Tests 4985 passed, 2 skipped.
  • pnpm --dir ui run check:new-line-citations: 0 new citations, exit 0.
  • check-platform-checklist not owed: nothing renamed or removed (new module members only; none added to the package entry).
  • main moved 1 commit after the merge base by PR time; it touches no path in this diff and git merge-tree is clean. CI runs the merge ref.
  • Not run locally, CI's: Build Core, Type Check UI and the workspace type-check lanes, Test Core, Test UI (whole app-shell), the repo-wide lint.

Generated by Claude Code

crm_lead, crm_account, crm_contact and crm_opportunity declare
enable.trackHistory, so their record pages show each field change with its
before and after values.

Claude-Session: https://claude.ai/code/session_01QSeKYnE9YNkSZen7sijLA3
Co-authored-by: Claude <noreply@anthropic.com>
… is shown

RecordDetailView hands its trackHistory gate (historyEnabled) to the stream
through RecordFieldHistoryContext; record:activity drops the changes chip
while it is false and falls back to all when the chosen chip goes away.

Claude-Session: https://claude.ai/code/session_01QSeKYnE9YNkSZen7sijLA3
Co-authored-by: Claude <noreply@anthropic.com>
… only; activity events sit under Activity

Ruling C on the record timeline: an item is a field-history entry when it is a
field_change read from the audit log (source 'sys_audit_log'). The stream's
Changes chip and the panel's changes_only hold those and nothing else; the
activity rows that share the field_change presentation (created, updated,
deleted, assigned, shared) sit under Activity whatever the history flag.
ACTIVITY_TYPE_TO_FEED_TYPE is unchanged.

Claude-Session: https://claude.ai/code/session_01WGufzic59DvGAbu7U78pZt
Co-authored-by: Claude <noreply@anthropic.com>
…nd README follow ruling C

Claude-Session: https://claude.ai/code/session_01WGufzic59DvGAbu7U78pZt
Co-authored-by: Claude <noreply@anthropic.com>
…y peek row names its actor

An activity row and the audit entry the same action wrote (same record,
matching action kind, same actor, stamped within TIMELINE_PAIR_WINDOW_MS)
merge into one stream row: the activity row keeps its actor and summary and
carries the audit entry as fieldHistory, drawn as the before and after values
on that row. Changes and the panel changes_only still show each audit entry
on its own. Unpaired rows draw as before. The panel peek row shows the actor.

Claude-Session: https://claude.ai/code/session_01WGufzic59DvGAbu7U78pZt
Co-authored-by: Claude <noreply@anthropic.com>
…nd README for one row per action

Claude-Session: https://claude.ai/code/session_01WGufzic59DvGAbu7U78pZt
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-support-ai · session_01WGufzfE4V9fzZgJLrLDmMC

维护者速读(席位终稿)

  • 改了什么:记录页「动态」里,同一次操作原来会出现两行:一行「创建了 / 更新了」,旁边一行审计明细。现在合成一行,执行人和摘要在上面,修改前后的值列在同一行下面。「动态」面板的预览行也补上了执行人。
  • 为什么改:Record timeline: compact rows name no actor, and an audit reader sees every action twice #30122:用户分不清谁做了什么,审计读者看到每个操作两遍。
  • 风险与回滚:低。只改前端合并和显示,不改服务端、接口和 spec。「变更」筛选仍按裁决 C 单独列出审计明细。没有审计权限的读者看到的仍是一行、没有明细。回滚即 revert 本 PR。
  • 先看:ui/packages/plugin-detail/src/recordTimelineStream.ts 的 pairActions 与 mergeTimelineStream。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx。
  • 需要你做的:无。不是受管面,席位直接落地。
  • 席位意见:ACCEPT。「动态 N」角标仍按服务端口径计数(一次新建加一次修改显示 3),已另立卡 Record page 动态 badge still counts one create twice for an audit reader after the timeline draws one row per action #30343。

席位核对 · ACCEPT · head 0b041cec · 2026-11-05T06:47Z

  1. Shape: draft, base main, Fixes #30122. One changeset, patch, Clause-②: no / Metadata-protocol: none. 5 files, +278/−23. check-governed-merges --pr 30342: NOT governed. Branch reused after feat(ui): the timeline's Changes chip holds the audit's field changes only; the CRM opens field history on its four main objects #30294's squash-merge with a clean main merge (no force-push).
  2. Read by the seat: pairActions pairs an activity row with an audit entry when the audit action maps to the row's type (create→created, update→RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, delete→deleted), the actor matches (user id, else name when neither carries an id), the row is not a reply, and the stamps are within TIMELINE_PAIR_WINDOW_MS = 5 s; candidates are taken nearest first, each side at most once. A pair draws as the activity row with fieldHistory attached; unpaired audit entries draw as before, and an unpaired updated row still steps aside when field changes exist (unchanged behaviour). fieldHistoryEntriesOf keeps 变更 / changes_only listing each audit entry on its own, so ruling C (6649643659) holds; ACTIVITY_TYPE_TO_FEED_TYPE untouched. Peek rows gain the actor. Shot after-admin-all.png: two actions, two rows, diff on each.
  3. Accepted deviations: the count half of Wanted 3 is not delivered, by the seat's agreement (spec + REST); filed as Record page 动态 badge still counts one create twice for an audit reader after the timeline draws one row per action #30343 with options A/B/C, seat recommends A. The actor half needed only the peek row (compact stream rows already named the actor on main). Live member used a scratch permission set to read sys_activity / sys_comment on the fresh boot.
  4. Evidence: ablation red on exactly the 6 pair-needing pins, restored; ruling-C pins from feat(ui): the timeline's Changes chip holds the audit's field changes only; the CRM opens field history on its four main objects #30294 green unchanged; dispatch-gates 35/35 exit 0 with --ran zero NOT-MEASURED. Live (CRM, zh-CN): admin and a member without audit access both see one row per action; the member's rows carry no diff. CI on this head running at review time; auto-merge waits on it.
  5. Out of scope: Record page 动态 badge still counts one create twice for an audit reader after the timeline draws one row per action #30343 (动态 badge count, spec + REST); Record page offers an always-empty 变更 chip to a reader who cannot read the audit log #30344 (a reader without audit access is offered an always-empty 变更 chip). Skills-docs: n/a.
  6. Landing: ordinary queue, now: relay pr_ready + automerge_enable, follow to MERGED, landing record on Record timeline: compact rows name no actor, and an audit reader sees every action twice #30122.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 06:48
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 06:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit ff3f83b Nov 5, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-7ocxgx branch November 5, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Record timeline: compact rows name no actor, and an audit reader sees every action twice

1 participant