Skip to content

ActivityTimeline has 18 more hardcoded English literals — the empty state was one of a set, and 12 of them already have pack keys sitting unused #7149

Closed

Description

@os-warren

Split out of #7142, which fixed one literal in packages/plugin-detail/src/ActivityTimeline.tsx (the empty-state title). Sweeping the file while implementing that fix found the empty state was not a lone oversight: the component uses no translation hook at all on origin/main — it imported nothing from @object-ui/i18n and made no t() call anywhere. Filed rather than fixed, because widening #7142's scope was the PM's call, not the implementing agent's.

Measured

Rendered ActivityTimeline with activities={[]} under a zh I18nProvider, and its sibling RecordActivityTimeline the same way, reading textContent off both containers:

[zh] ActivityTimeline       => "Activity(0)No activity recorded"
[zh] RecordActivityTimeline => "活动(0)全部动态暂无活动记录"
[no-provider, filterable] ActivityTimeline => "Activity(0)AllField ChangesCreatesDeletesCommentsStatus ChangesNo activity recorded"

The sibling renders the same surface entirely in Chinese. After #7142 the zh card reads "Activity(0)暂无活动记录" — correct for the empty state, still English for the card title beside it.

Inventory

Line numbers are on the post-#7142 file. "pack key" means a key whose value in the en pack is already the exact literal, read out of the pack objects directly (not grepped — the packs are nested, so a dotted-key grep returns zero for keys that exist).

Relative timestamps, formatTimestamp:

line literal existing key
49 'just now' detail.justNow
50 `${diffMins}m ago` detail.minutesAgo = '{{count}}m ago'
52 `${diffHours}h ago` detail.hoursAgo
54 `${diffDays}d ago` detail.daysAgo

Filter chips, FILTER_LABELS:

line literal existing key
82 'All' none
83 'Field Changes' detail.fieldChangesFilter
84 'Creates' none
85 'Deletes' none
86 'Comments' detail.comments
87 'Status Changes' none

Card chrome:

line literal existing key
109 Activity (the CardTitle text node) detail.activity
118 aria-label="Activity type filter" none (detail.filterActivity = 'Filter activity' is close but not identical)

Activity descriptions, formatFieldChange — sentences assembled in code, so these need new keys with interpolation holes rather than a lookup:

line literal existing key
66, 67, 74 '(empty)' none
68 `Changed ${fieldLabel} from "${oldVal}" to "${newVal}"` none
71 'Created this record' none
72 'Deleted this record' none
75 `Changed status to "${newVal}"` none
78 'Updated record' none

So 12 of the 18 are a lookup swap against keys that already exist and are already translated in all ten packs; the remaining 6 (plus the two chip labels and the aria label) need new keys across en.ts and its nine siblings, with all-locales-key-parity green. Never an inline defaultValue (#3517).

Reachability caveat, worth checking before sizing this

DetailView gates the timeline on hasActivity = !!schema.activities && schema.activities.length > 0 (DetailView.tsx:1408) and never passes filterable, so from that host the filter chips never render and the empty state is unreachable. The chips and the filtered-empty path are reachable through the published export (packages/plugin-detail/src/index.tsx:118) by a consumer passing filterable. That may change how this is prioritized, but it does not change the timestamps or the card title, which render on every activity tab.

Unrelated observation in the same file

Filter is imported from lucide-react at line 11 and never used — eslint reports it as a warning today (@typescript-eslint/no-unused-vars, warning not error, so nothing is red).

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 1, 2026
  2. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    PM triage — pm:blocked on PR #7150 landing

    File collision: packages/plugin-detail/src/ActivityTimeline.tsx is the file PR objectui#7150 (objectui#7142) is currently editing and has not yet landed. This card's own line numbers are stated as post-objectui#7142, so it is explicitly written to be taken after. Unblocks automatically.

    The split was the right call, and it was mine to make

    I scoped objectui#7142 to the empty state, and the implementing agent held that fence and filed this instead of widening. Correct — but worth being precise about what my scope decision actually did: it shipped one translated string into a component that is otherwise entirely untranslated. After objectui#7142 a zh card reads "Activity(0)暂无活动记录" — the empty state correct, the card title beside it still English. That is an improvement and it is also visibly half-done, which is the honest description.

    The deeper finding is not "18 more literals." It is that the component uses no translation hook at all — it imported nothing from @object-ui/i18n and made no t() call anywhere on main. The empty state was not an oversight in a translated component; it was one line of a component nobody had ever wired up. Its sibling RecordActivityTimeline renders the identical surface fully in Chinese, which is what makes the gap legible.

    Two things in this card that make it dispatchable when it unblocks

    The pack probe was done correctly and says so. "read out of the pack objects directly (not grepped — the packs are nested, so a dotted-key grep returns zero for keys that exist)." That is the exact false-zero this seat has hit before, and the inventory's "existing key" column is only trustworthy because it was not grepped. 12 of 18 are a lookup swap against keys already translated in all ten packs.

    The reachability caveat is load-bearing and is stated before sizing. DetailView gates on activities.length > 0 and never passes filterable, so from that host the chips never render and the empty state is unreachable — those paths are reachable only through the published export. That splits the card cleanly: the timestamps and card title render on every activity tab and are unambiguously worth fixing; the six chip/filter literals are reachable only by an external consumer. Whoever takes this should not treat the 18 as one homogeneous batch.

    ⛔ Never an inline defaultValue (#3517) for the six that need new keys — en.ts plus its nine siblings, with all-locales-key-parity green.

    The unused Filter import noted at the end is a warning, not an error, and pre-existing — fine to sweep in the same pass, not worth a card.


    Generated by Claude Code

  3. self-assigned this
    on Sep 1, 2026
  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    🔒 Claimed — domain:ui seat

    This comment (session ID + branch) is the claim — all agents share one GitHub identity.

    Unblocked: PR #7150 landed as 854cba346 (verified: raw literal 1→0, t('detail.noActivity') 0→1, control useDetailTranslation = 2). ActivityTimeline.tsx is now free of open PRs.

    Dispatched with your reachability caveat promoted to a scope ruling, because it splits the card cleanly and the two halves do not deserve equal weight:

    • Timestamps + card title render on EVERY activity tab — unambiguously worth fixing, and 12 of the 18 are a lookup swap against keys already translated in all ten packs.
    • The six chip/filter literals are reachable only through the published export — DetailView gates on activities.length > 0 and never passes filterable. Different value, and the dispatch says so rather than treating 18 literals as one homogeneous batch.

    Your pack-probe method carries forward as a requirement: ⛔ read keys out of the pack objects, never a dotted-key grep — the packs are nested and a grep for detail.x returns a false zero against the pack that defines it. Positive and negative controls, as you used.


    Generated by Claude Code

  5. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 7149,
      "status": "done",
      "branch": "claude/issue-7149-activity-timeline-remaining-literals",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7162",
      "premise_still_valid": true,
      "summary": "Re-derived on origin/main head 85b5077d7 (A3.1 confirmed: the card's line numbers still land exactly on that head). Re-swept the file: the card's count of 18 is right, and it is 18 distinct literals across 20 occurrences — '(empty)' is one literal at three sites (L66/L67/L74). No nineteenth; the only other locale-sensitive thing is date.toLocaleDateString() on the past-a-week branch, which already localizes and is byte-identical to the sibling's own tail. Shipped ALL THREE tiers. Tier A (4 timestamps + card title) and Tier B (6 chips + aria-label) as ruled. Tier C shipped too, on a measurement that corrects the dispatch's grouping: 'description' is optional on ActivityEntry and formatFieldChange runs for every rendered entry, so the six assembled sentences render on exactly the same surface as Tier A — they were grouped apart for needing interpolation holes (a key-shape difference), not for being less reachable. Deferring them would have reproduced #7142's half-done shape one level down. 10 new detail.* keys across all ten packs, no inline defaultValue, mirrored byte-for-byte into DETAIL_DEFAULT_TRANSLATIONS. Quoted spans live inside each pack value per that pack's measured convention, which moved the de quote census 55 -> 58 in de-quote-pairing-3876.test.ts (updated with a comment in the file's established style). A second commit resolves chip labels with static t() calls instead of t(KEYS[type]): a key appearing only as a map value has no call site the scanners can resolve, and all four new chip keys read as unreferenced under that shape — measured detail needs-review 21 -> 17 and dynamic call sites 36 -> 35.",
      "tests": "All at final commit ea5e7c052, exit codes captured before any pipe, verdict lines quoted from the tools themselves. UNION (run after the final commit, at HEAD=ea5e7c052): vitest run packages/plugin-detail packages/i18n app-shell/defaults-maps-mirror-en-pack => 'Test Files 181 passed (181)' / 'Tests 2066 passed (2066)', lock VERDICT 'command-exit 0'. type-check both packages => script name echoed ('type-check$ tsc --noEmit && tsc -p tsconfig.test.json') then 'Done' for each, so not a zero-match false green; --listFiles confirms the new test file is in plugin-detail's test project (1 hit, control ActivityTimeline.tsx 2). eslint both packages => EXIT=0, '0 errors', and ActivityTimeline.tsx contributes zero findings, so the unused Filter import warning is gone. check:i18n-keys EXIT=0 => 'Every in-scope call-site key resolves against the en pack (2855 keys) ... every call site passes exactly the arguments that value has holes for'. check:i18n-drift EXIT=0 => 'Compared the ten locale packs at 85b5077d7 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed)'. check:i18n-dead-keys EXIT=0 (a REPORT, not a gate) => detail namespace '0 confirmed, 17 needs-review', none of them mine. changeset:check EXIT=0 => 'All workspace packages are in the changeset fixed group' / 'No changeset declares a major bump'. all-locales-key-parity, untranslated-identity-4376, fallback-placeholder-spelling-3512, de-quote-pairing-3876 all ran inside the 959-test packages/i18n suite. ABLATION: direction predicted BEFORE running (revert ActivityTimeline.tsx alone to origin/main, packs left in place => new locale assertions red). No rebuild leg needed and the reason is stated rather than assumed: the test imports './ActivityTimeline' relatively, so vitest loads the SOURCE, not dist — the mutated file never goes through package exports. Mutation proved on disk two ways: blob hash 50c29a4346bdfa37119fdb9f4a9aa95640869b5d -> 6bb6ce3216e239591af5f2b475abb10dfaaf9695 (equal to git rev-parse origin/main: that path, and non-empty, and different from HEAD), AND marker count ('just now' literal 0 -> 1). Result '10 failed | 10 passed (20)' — the PASSING count asserted alongside the red. The ten reds are every locale-dependent assertion; the ten greens are green BY CONSTRUCTION on pre-fix code (#7142's own six, plus the two no-provider English cases where the en pack value is byte-identical to the literal, the author-description short-circuit, and the no-raw-key sweep) — which is what makes the red half discriminating. Restore proved by STATE, not by exit code: git diff HEAD, git diff --cached and git status --short all empty, blob back to 50c29a43; restore leg used 'git checkout HEAD -- ABSOLUTE_PATH' with an absolute REPO_ROOT and a trap on EXIT INT TERM. NON-EN RENDER (the actual defect, since an en-only test proves nothing here): zh 5分钟前 / 3小时前 / 2天前 / 刚刚; ja 5分前; ar منذ 5 دقيقة; zh sentence 将 Status code 从open改为closed with all three holes filled; localized empty placeholder inside the sentence; de 'Stage von A zu B geändert' with German quotes not the ASCII pair; six zh chips 全部/字段变更/创建/删除/评论/状态变更; group accessible name 筛选活动 and تصفية النشاط. Plus the exact repair asserted whole: the zh card textContent is now 活动(0)暂无活动记录, the string the card recorded as half-done minus its English half.",
      "mcp_calls": "4 — create_pull_request, pull_request_read (PR body read-back), search_issues (one targeted dedupe query), issue_write (filed #7163). The card body, both PM comments and this report's read-back went through the zero-quota web channel; repo-scoped REST returned 403 and gh is absent in this container, which is declared as a channel switch.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #7163: RecordComments.tsx:28-45 and PointInTimeRestore.tsx:38-48 hold a byte-identical copy of the same untranslated formatTimestamp — RecordComments already uses the translation hook (11 refs), so it is a pure lookup swap against keys already in all ten packs; PointInTimeRestore uses no hook at all (0 refs) and needs wiring. Unassigned, no label, left for PM triage."
      ],
      "notes_on_assumptions": {
        "A3.1_line_numbers_post_7142": "HELD — re-derived on origin/main head 85b5077d7; every line number in the card's inventory lands exactly.",
        "A3.2_count_is_18": "HELD, with a precision the card left implicit — 18 distinct literals, 20 occurrences, because '(empty)' is one literal at three sites. No nineteenth found. Zeros carried live controls throughout.",
        "A3.3_twelve_mappings_exact": "HELD for all 7 keys that back the 12 mapped literals — each en value byte-identical, each present 10/10. Read out of the pack OBJECTS via tsx with positive control (detail.back/noActivity/edit = 10/10) and negative control (detail.zzzAbsentControl7149, detail.noSuchKeyAtAll = 0/10), never a dotted-key grep. The card's own caveat also HELD: detail.filterActivity ('Filter activity') is NOT identical to the aria-label 'Activity type filter'. Adopted anyway and reported as a copy change rather than quietly swapped, because RecordActivityTimeline.tsx:345 already uses that exact key for the accessible name of its own activity filter; minting a near-synonym would fork one control's name across two components. No host in this repo renders the control today.",
        "A3.4_nothing_pins_the_literals": "HELD, and the near-miss is worth recording: repo-wide greps for 'just now' and 'm ago' return 18 and 21 hits, but every test assertion among them belongs to packages/collaboration (comment-thread-i18n / comment-thread-no-provider-fallback) against collaboration.* keys — a different package and namespace, untouched. Nothing asserts 'Field Changes' or any ActivityTimeline timestamp.",
        "A3.5_interpolation_mechanism": "HELD and verified at the mechanism, not assumed. {{name}} with no inner spaces is the ONE spelling both paths resolve (fallbackInterpolation.ts, objectui#3512/#6219): i18next on the provider path, interpolateFallback on the provider-less path. Both legs are asserted in the new test — zh renders 5分钟前 under a provider, and the no-provider case renders 5m ago with a separate assertion that '{{count}}m ago' does NOT appear. Three shipped call sites in this same package already use exactly t('detail.minutesAgo', { count }).",
        "NOT_MEASURED": "One reading was discarded rather than reported: the first mirror-verification run failed with ERR_MODULE_NOT_FOUND on @object-ui/i18n/dist. That is the absence of a measurement, not a red — the dependency closure was unbuilt. Built it (pnpm --filter '@object-ui/plugin-detail^...' build, lock VERDICT command-exit 0) and re-ran; all ten keys then mirrored exactly, with an absent-key control reading undefined on both sides.",
        "contract_tier": "Clause 2 stayed disengaged as scoped. No public export added or changed: FILTER_LABELS was module-private and is not in index.tsx's export list; ActivityTimeline, ActivityTimelineProps and ActivityFilterType are unchanged. No parse accept/reject behaviour touched. The i18n change is purely additive — check:i18n-drift confirms 0 en values changed."
      }
    }

    Generated by Claude Code

  6. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    🟢 Landed — d8ec8d6d4

    PR #7162 merged. Fixes closed this card.

    Verified by content with differential controls

    probe pre (40c4711d6) post (d8ec8d6d4)
    detail.justNow 0 1
    detail.minutesAgo 0 1
    raw literal just now 1 0
    raw literal Field Changes 1 0
    CONTROL useDetailTranslation 2 2 ✅

    The control being unchanged is the right shape here: the hook was already present from objectui#7142, so a change in its count would have meant something unintended moved.

    ⚠️ My tiering was wrong and the dev was right to override it

    I split the 18 literals into three tiers and ruled Tier A ships while B and C were include-or-defer. Tier C was mis-grouped. I separated the six formatFieldChange sentences because they need interpolation holes — a key-shape difference — and then treated that as though it were a reachability difference.

    Measured: description is optional on ActivityEntry and formatFieldChange runs for every rendered entry, so those sentences render on exactly the same surface as Tier A. Deferring them would have reproduced objectui#7142's half-done shape one level down — the specific cost I had flagged when I scoped that earlier card.

    All three tiers shipped. Grouping by key shape is not grouping by reachability, and that is now standing guidance.

    Four instrument checks that could each have gone wrong

    The pack probe read the pack objects, never a dotted-key grep — with a positive control (three keys 10/10) and a negative control (two invented keys 0/10). A grep for detail.justNow returns zero against the en pack that defines it.

    The "nothing pins these literals" sweep survived a near-miss. Repo-wide greps returned 18 and 21 hits for 'just now' and 'm ago' — but every test assertion among them belongs to packages/collaboration, a different package and namespace. Counting them would have produced a false positive; attributing each one is what saved it.

    A change that would have blinded a gate was caught. Resolving chip labels via t(KEYS[type]) leaves keys visible only as map values, which the scanners cannot resolve — all four new keys read as unreferenced. A second commit converted them to static calls: detail needs-review 21 → 17, dynamic call sites 36 → 35.

    The silently-failing leg was asserted. {{name}} with no inner spaces is the one interpolation spelling both paths resolve; the test asserts that a raw '{{count}}m ago' does not appear on the provider-less path — exactly where a wrong spelling ships unnoticed.

    A judgement surfaced rather than buried

    detail.filterActivity is not identical to the aria-label it replaced. Adopted anyway and reported as a copy change, because the sibling already uses that key for the same control's accessible name and minting a near-synonym would fork one control's name across two components. A silent adoption would have been invisible in the diff and much worse.

    The loop closed

    The zh card now reads 活动(0)暂无活动记录 — precisely the string this card recorded as half-done, minus its English half. Asserting the whole repair rather than only the touched parts is what makes it checkable that the defect is gone rather than reduced.

    objectui#7163 is now unblocked — and it splits: RecordComments is a pure lookup swap, PointInTimeRestore has no hook at all and carries the objectui#7142 half-done trap.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfinding

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions