Repository navigation
DetailSection's click-to-copy writes [object Object] to the clipboard for every object-valued cell (address, geolocation, JSON, attachment) #8395
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 7, 2026 Claim: session
session_01YBWFb5YgMU5dw8p2VKj16S· branchclaude/issue-8395-copyfield-object-valuesPM dispatch (
domain:uiseat). The assignee and this comment are set by the PM on the dev's behalf — the dev inherits both, posts no second claim, and never writes the assignee field.Unblocked: objectui#8376 / PR #8396 has landed (
hasCellValuenow inDetailSection.tsx), so the file is free.⚠️ Re-derive the line — it moved.The defect is crisp:
handleCopyFieldwritesString(value), so click-to-copy puts the literal text[object Object]on the clipboard for every object-valued cell (address, geolocation, JSON, attachment, multiselect, expanded lookup) — while the cell beside the button renders that same value correctly.⚠️ That asymmetry is the design constraint, not just the symptom: the cell already knows how to turn the value into text, and the copy button does not ask it. Before writing a serializer of your own, measure whether the rendered text is reachable — copying what the user can actually see is a different and better contract than copying a re-derivation that can drift from it. If it is not reachable, say so and then propose.⛔ Escalation fence: "what should copying an object cell put on the clipboard" is a product question wherever the answer is not simply the text on screen — a JSON blob, a formatted address, a lat/long pair and an attachment filename are four different answers. If the measurement does not hand you one obvious contract, report the options costed and stop.
The pin must assert the clipboard payload, with a control proving the cell rendered its value — an assertion that the payload is not
[object Object]passes on a cell that copied nothing.
Generated by Claude Code
os-dev-report
{
"issue": 8395,
"status": "needs_decision",
"branch": "claude/issue-8395-copyfield-object-values",
"pr": null,
"premise_still_valid": true,
"summary": "The defect is real and re-derived: at origin/main ca39427 the read site is packages/plugin-detail/src/DetailSection.tsx LINE 220 (the issue quoted it from another branch; hasCellValue from PR #8396 is present at line 94, so the file is free as the claim said). A second, different String(value) sits at line 366 — the displayValue fallback when no cell renderer resolves — and is NOT this defect. I did NOT write a fix: the escalation fence fired. The measurement was run rather than reasoned, and it does not hand one obvious contract; worse, it FALSIFIES the brief's own preference. Option A (copy the rendered text) is reachable — the value box textContent is complete, because TruncatedText truncates in CSS (class 'truncate') and renders the full string, so the issue body's stated cost 'a truncated value would have to be handled' is not a real cost — but adopting it would fix 5 object kinds while breaking 4 more and silently REGRESSING at least 5 scalar kinds that are correct today, two of them destructively. Details and the per-kind table are in tests and open_questions. Reporting options costed and stopping, per the fence.",
"tests": "MEASUREMENT ONLY — no implementation was written, therefore NO ablation was run and no two hashes exist. That is not a skipped step: an ablation restores a READ SITE from a committed implementation, and there is no implementation to restore to. Stating this plainly rather than filling the template's slot. Nothing was committed; the branch was pushed empty as the routing probe (push succeeded, so the write route is live) and holds zero commits over origin/main. The measurement harness was written into the worktree, run, then deleted; the tree ended clean (git status empty) and the worktree was removed.\n\nCOMMANDS (canonical invocation per scripts/vitest-invocation-guard.mjs — repo root, no '--' before paths; run under the shared heavy-verify lock at /home/user/objectstack/scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue8395):\n os-verify-lock.sh -c 'cd /home/user/objectui-issue-8395 && NODE_OPTIONS=--max-old-space-size=4096 pnpm exec vitest run packages/plugin-detail/src/tests/ZZmeasure.copyPayload.test.tsx'\n VERDICT command-exit 0 · held the lock 9s · waited 5s (run 2 of 2; run 1: VERDICT command-exit 0 · held 10s · waited 0s)\n Test Files 1 passed (1) | Tests 1 passed (1)\n\nHARNESS: a real DetailSection rendered with 22 rows, one per field type. window.innerWidth pinned to 1280 (DetailSection has mobile/desktop row shapes and an isMobile auto-hide threshold). navigator.clipboard.writeText replaced by a vi.fn spy via Object.defineProperty(navigator, 'clipboard', ...) — the same pattern packages/app-shell/src/console/organizations/tests/acceptInvitationLink.mount.test.tsx already uses. WHAT IS OBSERVED, exactly: the ARGUMENT to navigator.clipboard.writeText. That is the path the button actually takes — handleCopyField at line 219-224 calls navigator.clipboard.writeText(textValue).then(...) with no document.execCommand fallback and no component callback anywhere on the path; grep for execCommand in packages/plugin-detail/src returns zero.\n\nMEASURED TABLE (rendered textContent of the value box, vs the argument handed to writeText):\n amount number scale 2 RENDERED '16.00' CLIPBOARD '16'\n price currency RENDERED '1,234.50' CLIPBOARD '1234.5'\n ratio percent RENDERED '12%' CLIPBOARD '0.123'\n due date RENDERED 'Mar 4' CLIPBOARD '2026-03-04'\n seen_at datetime RENDERED '3/4/20265:06 am' CLIPBOARD '2026-03-04T05:06:07Z'\n stage select RENDERED 'Closed Won' CLIPBOARD 'won'\n pw password RENDERED 'bullets' CLIPBOARD 's3cret-value'\n name text CONTROL RENDERED 'Plain String Value' CLIPBOARD 'Plain String Value'\n billing_address address RENDERED '1 Main St, Springfield, IL 62704, USA' CLIPBOARD '[object Object]'\n office_location geoloc RENDERED '30.2742, 120.1551' CLIPBOARD '[object Object]'\n payload json RENDERED '{"a":1,"b":["x","y"]}' CLIPBOARD '[object Object]'\n contract file (single) RENDERED 'contract.pdf' CLIPBOARD '[object Object]'\n owner_ref lookup expanded RENDERED 'Acme Corp' CLIPBOARD '[object Object]'\n attachments file (array) RENDERED '2 files' CLIPBOARD '[object Object],[object Object]'\n stuff repeater RENDERED '2 项' CLIPBOARD '[object Object],[object Object]'\n tags multiselect RENDERED 'AlphaBeta' CLIPBOARD 'alpha,beta'\n logo image RENDERED '' CLIPBOARD '[object Object]'\n\nTHE CONTROL THE BRIEF ASKED FOR: every object row above proves the cell DID render its value (a non-empty, type-correct string sits next to a broken payload), so 'the payload is not [object Object]' is never satisfied by a cell that rendered nothing. The 'name' row is the unchanged-string control: rendered and copied text are identical.\n\nMEASUREMENT ARTEFACT, declared so it is not read as a finding: the 'active' (boolean) row reported no clipboard call. Cause is my selector, not the component — BooleanCellRenderer renders a Radix Checkbox, which IS a button element, so querySelector('button') matched the checkbox instead of the copy button. hasCellValue(true) is true (recordDisplayValueAt returns 'true'), so that row is copy-interactive and its copy button is present. A real pin must click the row container carrying role='button' (or the copy button specifically), never the first button in the row. Its rendered textContent is '' — which is why boolean is listed as an option-A casualty below.",
"mcp_calls": "7 — issue_read get, issue_read get_comments, search_issues x2 (dedup for the two filed findings), issue_write create x2, add_issue_comment (this report). CHANNEL DECLARATION: the repo-scoped REST probe GET /repos/objectstack-ai/objectui returned 403 in this seat, so per the dispatch rule every read and write went through MCP; each search was a single targeted strike and both were self-validating (the first returned #8395 itself, which IS about this file's clipboard write).",
"open_questions": [
{
"question": "WHAT SHOULD COPYING AN OBJECT-VALUED CELL PUT ON THE CLIPBOARD? The measurement did not hand one obvious contract, so the escalation fence fires. Costed options below. Note also that the dispatch carried NO four-axis decision frame (the {decision_frame} slot of rules/dev-template.md was not filled), and the standing rule forbids me inventing my own axes — so the costs below are measured costs, not an axis analysis. Please send the frame with the next dispatch, or confirm that measured cost is the whole basis you want.",
"options": [
"A — COPY THE RENDERED TEXT (the brief's stated preference). REACHABLE: yes, via a ref on the value box; and the one cost the issue body named is not real — TruncatedText truncates in CSS ('block max-w-full truncate' with the full string as its child and in its title), so textContent is complete, never ellipsized. FIXES 5 kinds: address, geolocation, json, single file, expanded lookup. BREAKS 4: image copies '' (empty string — this is LITERALLY the worse-than-the-bug implementation the brief warned about, arrived at by following the brief), boolean copies '' (Radix Checkbox, no text), multiselect copies 'AlphaBeta' (badges concatenate with no separator; today's 'alpha,beta' is strictly more useful), file arrays copy '2 files' (a count, not the data), repeater copies '2 项'. REGRESSES 5 scalar kinds that are correct today, two destructively: date renders 'Mar 4' so the YEAR IS LOST (today '2026-03-04'); percent renders '12%' against a stored 0.123, a DIFFERENT QUANTITY; datetime textContent concatenates to '3/4/20265:06 am' with no separator and is unparseable (today an ISO string); currency gains a thousands separator '1,234.50' and number gains display padding '16.00', both breaking spreadsheet paste. FURTHER COST NOT IN THE BRIEF: the payload becomes DISPLAY-LOCALE DEPENDENT — AddressCellRenderer orders parts by useDisplayLocale (objectui#4028) and the number/date renderers format by locale — so the same stored record yields different clipboard text per UI language. Blast radius is every field type, not the object ones the card is about.",
"B — A SHARED value-to-text FORMATTER that reuses the existing formatters (formatAddress etc.) rather than re-spelling them. Deterministic, unit-testable, no DOM read, no layout coupling, no locale coupling unless chosen deliberately, and it leaves every scalar payload exactly as it is today. COST: it is a second formatting authority beside the cell renderers — the drift shape objectui#4037 closed — which reuse mitigates but does not remove. AND it does not avoid the product question: it REQUIRES answering it per kind, which is precisely what I am escalating. Each of these has at least two defensible answers and the brief names four of them: address (formatAddress, and under which locale, or none?), geolocation (the stored full precision, or the displayed 4-decimal rounding — the cell shows 30.2742 for a stored 30.27415123), json (compact or pretty?), file (filename, or filename list, or URL?), multiselect (localized LABELS as seen, or raw VALUES as stored — today it is values), lookup (display name, or the id?), image (URL, or filename?).",
"C — MINIMAL AND CONTRACT-FREE: keep String(value) for non-objects and JSON.stringify objects. Never yields '[object Object]', is lossless and machine-parseable, touches no scalar, and needs no DOM. COST: it answers the product question by fiat with 'a JSON blob' for all seven kinds, which is exactly one of the four answers the fence says is a product call — a reader looking at '1 Main St, Springfield, IL 62704, USA' and clicking copy would get the raw object. Cheapest to ship and cheapest to change later; the honest framing is 'stop the bleeding, defer the contract'.",
"D — DO NOT OFFER COPY ON CELLS THAT RENDER NO TEXT (a companion to A, not a standalone). Would fix A's image/boolean casualties. COST: it narrows canCopy away from hasCellValue, and objectui#8376's docblock states the three readers of that definition MUST agree; a filled image row would then show neither a copy button nor an em-dash. That is a change to the emptiness authority and belongs to whoever owns objectui#8376, not to this card."
],
"recommendation": "C, then B — but this is a measured-cost recommendation, not an axis analysis, because no decision frame was supplied. C is the only option that cannot make anything worse than it is today: it touches only the branch that is broken, leaves all 5 correct-today scalar payloads untouched, and is a one-line change with a per-kind pin. Ship C to stop the silent data loss, and let B settle the per-kind contract as a separate, properly specified card once the product answers exist. I recommend AGAINST A as specified: my measurement falsifies its premise. 'Copying what the user can actually see' is a better contract only where the visible text is the value; measured, it is a lossy DISPLAY of the value for dates, percents, currencies and numbers, an empty string for images and booleans, and a COUNT for file arrays and repeaters. Adopting it would trade a loud, obvious bug (a reader pastes '[object Object]' and knows immediately something is wrong) for a quiet one (a reader pastes 'Mar 4' and never learns the year was dropped)."
}
],
"out_of_scope_findings": [
"filed as #8440: DetailSection's click-to-copy writes a password/secret field's RAW value while the cell beside it renders the mask — measured, CLIPBOARD 's3cret-value' vs RENDERED bullets. Reachable because objectui#4221 put those types in isInlineExcludedDetailFieldType, so canInlineEditField is false and copyInteractive is true. Same handler as this card, different branch of it, and independently actionable — do not block it on the object-cell contract.",
"filed as #8441: therepeatercell renderer in packages/fields/src/index.tsx lines 2390-2395 hardcodes a Chinese count label ('{n} 项'), violating AGENTS.md Commandment #-1 (English-only codebase) and bypassing i18n — it renders '2 项' next to English siblings '[Vector]', '[Grid]' and FileCellRenderer's 'N files' in the same table. Same class as the closed objectui#3342 / #2264 / #3736, a site they missed.",
"NOT FILED, reported for PM judgement: FileCellRenderer (packages/fields/src/index.tsx ~1649-1655) renders 'N files' for an array — English, but unlocalized and not plural-safe for non-English locales. It is the same line of code as #8441's neighbour and I folded a note about it into #8441's body rather than filing a third near-duplicate card. Split it out if you would rather it be tracked separately."
]
}
Generated by Claude Code
PM ruling — C, and the measurement is accepted as falsifying my own brief.
My brief was wrong, and your measurement is why
I wrote: "Copying what the user can actually see is a different and better contract than copying a re-derivation that can drift from it." Measured, that is the worse contract for 9 of 17 field types, and two of them lose data silently:
date RENDERED 'Mar 4' → the YEAR is gone (today '2026-03-04') percent RENDERED '12%' stored 0.123 → a DIFFERENT QUANTITY datetime RENDERED '3/4/20265:06 am' → unparseable currency '1,234.50' · number '16.00' → break spreadsheet paste image '' · boolean '' → the empty string files '2 files' · repeater '2 项' → a COUNT, not the data multiselect 'AlphaBeta' → today's 'alpha,beta' is strictly better⭐ And the sharpest part: option A reaches the "strictly worse than the bug" implementation my own brief told you to watch for, by following my own brief — image and boolean cells would copy
''. That is the second time today a dispatch's specified shape would have been satisfied by something worse than the defect, and the first time the shape was mine rather than the card's.Your framing settles it: A trades a loud bug (a reader pastes
[object Object]and knows instantly) for a quiet one (a reader pastesMar 4and never learns the year was dropped).Also noted: the one cost the issue body named — truncation — is not real (
TruncatedTexttruncates in CSS, sotextContentis complete), and A would additionally make the clipboard payload display-locale dependent, which no one asked for.Ruling: ship C now, B is a separate card
C — keep
String(value)for non-objects,JSON.stringifyfor objects. It is the only option that cannot make anything worse than today: it touches only the broken branch, leaves all five correct-today scalar payloads byte-identical, and needs no DOM read and no locale coupling.Yes, C answers "what should an object cell copy" by fiat with a JSON blob. That is a defensible default, not a settled contract, and the PR must say so at the read site — so that whoever takes B is not misled into thinking the question was decided. Cheapest to ship, cheapest to change.
B is the right long-term shape and it is a separate, properly specified card — it requires per-kind product answers (address under which locale, geolocation at stored precision or displayed rounding, json compact or pretty, file filename/list/URL, multiselect labels or values, lookup name or id, image URL or filename), and those answers do not exist yet.
D stays out: narrowing
canCopyaway fromhasCellValuecontradicts objectui#8376's docblock that the three readers of that definition must agree. Correctly identified as belonging to that card's owner.Your process question — answered
You are right that my dispatch carried no decision frame, and right not to invent axes. For this card: measured cost is the whole basis I want, and your report supplies it. Going forward I will send an explicit frame on any card whose fence I expect to fire, rather than leaving you to choose between inventing axes and reporting bare costs.
Your two filed cards
objectui#8440 (copy writes a secret's raw value while the cell renders the mask) and objectui#8441 (the hardcoded
2 项) are both accepted and labelled. objectui#8440 is not folded into C — it is the same handler but a different branch and, as you note, its own decision; it goes to the maintainer box because both available answers (copy the mask / withdraw the affordance) have a real cost, the second one being the samecanCopy-vs-hasCellValueconstraint that rules D out here.The
FileCellRenderer'N files'note folded into objectui#8441 is the right call — a third near-duplicate card would have been noise.Re-dispatching this card as C now.
Generated by Claude Code
os-dev-report
{
"issue": 8395,
"status": "done",
"branch": "claude/issue-8395-copyfield-object-values",
"pr": "#8460",
"premise_still_valid": true,
"summary": "Implemented the PM's option C at the one read site and nothing more. packages/plugin-detail/src/DetailSection.tsx: handleCopyField now writes JSON.stringify(value) for objects and keeps String(value) for everything else. Line re-derived on this branch's base ca39427: the copy read site was line 220 before the edit (the handler now starts at line 258 after a docblock); the OTHER String(value) — displayValue's no-renderer fallback, line 366 before / 420 after — is untouched and the ablation asserts it unmoved. The read site carries a docblock saying in as many words that the JSON blob is a DEFENSIBLE DEFAULT, NOT A SETTLED CONTRACT, naming objectui#8395's option B as where the per-kind contract gets decided, and recording why options A and D were rejected so the next reader does not re-derive them. objectui#8440's password/secret branch is byte-for-byte untouched and the pin deliberately asserts nothing about it. Draft PR #8460 opened against main, its first body line is the closing reference to this card (spelled out there, not repeated here so no scanner reads this comment as a closing statement), read back verbatim after creation (tables and the session-URL footer intact).",
"tests": "PIN: packages/plugin-detail/src/tests/DetailSection.copyObjectValues-8395.test.tsx — 17 cases, both halves, no negations. Instrument: the ARGUMENT to navigator.clipboard.writeText, spied via Object.defineProperty(navigator,'clipboard',...) (the acceptInvitationLink.mount pattern); window.innerWidth pinned to 1280; rows located by label then by [role='button'] — NEVER querySelector('button'), and that trap is itself pinned (measured: the boolean row's first button element carries role='checkbox', i.e. the Radix Checkbox, not the copy button). OBJECT ROWS (7 kinds that render text, each with a rendered-text CONTROL so no row can pass on a cell that rendered nothing): address rendered '1 Main St, Springfield, IL 62704, USA' copies {"street":"1 Main St","city":"Springfield","state":"IL","postalCode":"62704","country":"USA"} | geolocation rendered '30.2742, 120.1551' copies {"latitude":30.2741567,"longitude":120.1551234} (stored precision deliberately exceeds the displayed rounding, so no assertion here is satisfiable by re-deriving from the rendered text) | json rendered '{"a":1,"b":["x","y"]}' copies the same | file(single) rendered 'contract.pdf' copies {"name":"contract.pdf","url":"https://cdn.example.com/contract.pdf\"} | lookup(expanded) rendered 'Acme Corp' copies {"id":"acct-1","name":"Acme Corp"} | file(array) rendered '2 files' copies [{"name":"a.pdf"},{"name":"b.pdf"}] | repeater rendered a count copies [{"label":"one"},{"label":"two"}]. PLUS image (8th object kind; renders an img element and NO text, so its control is the element, not text) copies {"name":"logo.png","url":"https://cdn.example.com/logo.png\"}; PLUS multiselect ['alpha','beta'] copies ["alpha","beta"] — declared move, see open_questions. BYTE-IDENTICAL SCALAR HALF, each asserting the rendered text beside the payload so the file is red for any later 'copy the rendered text' rewrite: amount rendered '16.00' copies '16'; price rendered '1,234.50' copies '1234.5'; ratio rendered '12%' copies '0.123'; due rendered 'Mar 4' copies '2026-03-04'; stage rendered 'Closed Won' copies 'won'; text control 'Plain String Value' both sides; boolean copies 'true'. ABLATION (from the COMMITTED implementation 5ed30e2, restoring the copy handler's bare one-liner; trap on EXIT INT TERM with absolute paths): HEAD blob 014ec9dc9f356d7c2f0267c5c4f1389cfca70324 -> on-disk mutated 6f5bde22876b224f753718e4a028eb41a9d932c3 (differs ⇒ the mutation reached disk); anchors by content, not by a whole-file count: 'JSON.stringify(value)' 1 -> 0, the bare one-liner 0 -> 1, and the OTHER site ' return String(value);' 1 -> 1 (unmoved); the mutated handler was read back off disk and printed. RED RUN: 'Tests 9 failed | 8 passed (17)'. Red rows BY NAME, all nine object-valued: OBJECT — 'Billing Address' / 'Office Location' / 'Payload' / 'Contract' / 'Owner' / 'Attachments' / 'Line Items' copies parseable JSON …; OBJECT — an image cell copies its JSON even though it renders no text; OBJECT — a multiselect copies its stored values as a JSON array. Each failed on the payload (Received: '[object Object]') with its rendered-value CONTROL still passing ⇒ the failure is about what was copied, never about a blank cell. The 8 that stayed green are exactly the ones that must: the six scalars, the boolean, and the unserializable-value case. RESTORE BY STATE: on-disk hash == HEAD blob AND 'git diff HEAD' empty (never by an exit code). No dist is involved: the pin imports '../DetailSection' relatively inside the package, and the root vitest config aliases package specifiers to src — so no build stands between the mutation and the run. GREEN RUNS (all through the shared lock at scripts/pm/os-verify-lock.sh, slot issue8395, verdict lines quoted from the wrapper's own output, never a bare exit code): pin — 'VERDICT command-exit 0 · held the lock 12s', 'Tests 17 passed (17)'; whole affected package 'pnpm exec vitest run packages/plugin-detail/' — 'VERDICT command-exit 0 · held the lock 181s', 'Test Files 138 passed (138)', 'Tests 1248 passed (1248)'; closure build then 'pnpm --filter @object-ui/plugin-detail type-check' (which is 'tsc --noEmit && tsc -p tsconfig.test.json', so the TEST files are type-checked too, not excluded) — 'VERDICT command-exit 0 · held the lock 81s'. UNLOCKED gates: package-scoped 'eslint .' in packages/plugin-detail exit 0, '901 problems (0 errors, 901 warnings)' — identical warning count to the pre-change baseline and zero output for either changed file; node scripts/check-changeset-presence.mjs ✅ '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'; node scripts/check-changeset-no-major.mjs ✅ 'No changeset declares a major bump'; node scripts/check-governed-queue-guard.mjs --test on all three changed paths — '✅ NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched'; control-byte scan 'grep -naP' over the three changed files — zero hits, WITH a lit control (a file containing a real BEL byte) that fired. SCOPE OF THE LOCAL RUN, declared: the affected surface is packages/plugin-detail only. Measured, not assumed — of the nine test files in the repo that touch 'clipboard', the only two that render this surface are in packages/plugin-detail; no downstream package asserts this payload. Repo-wide 'pnpm lint' / full 'pnpm test' were left to CI.",
"mcp_calls": "5 — issue_read get, issue_read get_comments, create_pull_request, pull_request_read (the post-create body read-back), add_issue_comment (this report). No search_issues this run: I filed no new cards, so no dedup query was owed. Channel note: the previous run on this card measured a 403 on the repo-scoped REST probe in this seat and declared MCP for everything; I did not re-probe because every read I needed was either local git or the two issue reads above.",
"open_questions": [
{
"question": "MULTISELECT is the one payload that moves without having been broken, and the brief did not mention it. An array IS an object, so ruling C's literal wording moves ['alpha','beta'] from 'alpha,beta' to ["alpha","beta"]. I implemented the ruling literally and pinned the new payload with a comment declaring it. Confirm, or tell me to special-case arrays of primitives back to String().",
"options": [
"A (implemented) — the literal ruling: typeof value === 'object' covers arrays, so a multiselect copies a JSON array. Lossless and unambiguous where the old form was ambiguous for any value containing a comma; costs one behaviour change on a kind that was not broken.",
"B — keep String() for arrays whose every element is a primitive, so multiselect still copies 'alpha,beta'. Preserves a payload nobody complained about, but it invents a THIRD rule at the read site that the ruling did not authorise, and it leaves comma-bearing values ambiguous."
],
"recommendation": "A, as shipped. It is what the ruling says, and the pin makes the move visible and reversible in one edit if you disagree. The change is called out in the PR body, the changeset and the test comment rather than buried."
},
{
"question": "One deviation from the literal one-liner, flagged for a strike-or-keep call: JSON.stringify throws on a cycle and this runs inside a click handler, so the stringify is wrapped in the SAME try/catch that JsonCellRenderer already applies to this exact operation on this exact value, falling back to String(value).",
"options": [
"A (implemented) — keep the guard. Mirrors the shipped precedent on the same surface, keeps the row consistent with its own cell (which also falls back), and adds no new crash path. Pinned: a self-referencing value still copies '[object Object]' and the click does not throw.",
"B — bare JSON.stringify, exactly the one-liner. Smaller diff; a cyclic value would throw out of an onClick (uncaught, clipboard never written) where today it copies '[object Object]'."
],
"recommendation": "A. It is three lines, it follows a precedent already in this repo for the same operation, and it converts a new crash path back into today's behaviour. Strike it if you want the diff minimal — the pin for it is one self-contained case."
}
],
"out_of_scope_findings": [
"NONE NEW — nothing was filed this run. The two cards the previous run filed (objectui#8440 password/secret raw copy, objectui#8441 the hardcoded '2 项' repeater label) are the same sites I worked beside, and both are already tracked. I deliberately did NOT pin the repeater's Chinese label: the pin asserts only the COUNT the cell renders, so objectui#8441's fix will not turn this file red.",
"NOT A FINDING, recorded so the next reader does not re-derive it: my first address fixture spelled the postal code 'postal_code' and the rendered line silently dropped it. That is correct behaviour, not a defect — formatAddress reads 'postalCode' (the spec spelling, objectstack#5143) and the legacy 'zipCode' only. The fixture was moved onto the spec spelling; had it not been, the address row's rendered-text CONTROL would have been quietly weaker than it looks."
]
}
Generated by Claude Code
Filed by the objectui#8376 dev (branch
claude/issue-8376-detailsection-emptiness-authority) while converging that file's emptiness tests. ⛔ Not claimed. Out of scope there: #8376 is about which rows count as EMPTY; this is about what the copy affordance WRITES for a row that is filled, and fixing it does not move any emptiness predicate.What
packages/plugin-detail/src/DetailSection.tsx:String(value)on an object is'[object Object]'. The affordance is offered for every non-empty value — objects included — so on a detail page a reader who clicks the copy button (or the row, or presses Enter on it) on an address, geolocation, JSON, attachment/file, multiselect or expanded lookup cell silently gets[object Object]on the clipboard.Reachable in read mode on any non-editable object-valued row, which is the common case: the pencil affordance replaces copy only when the field is inline-editable AND the parent threaded
onEnterInlineEdit.Why it is worth fixing rather than removing
The cell next to the button is already rendering that value correctly —
AddressCellRendererprints a formatted postal address (objectui#4037),LocationCellRendererprints30.2741, 120.1551,SelectCellRendererprints option badges. So the rendered text the reader is looking at is exactly what they expect to copy, and the clipboard gets a placeholder string instead. The failure is silent: nothing errors, and it is only noticed on paste.Fix shape (a suggestion, not a ruling)
Two candidates, and a taker should decide rather than assume:
textContent. Matches what the reader sees, and needs no per-type table. Costs a DOM read at click time and depends on the renderer's formatting (a truncated value would have to be handled —TruncatedTextis in this path).formatAddressfor address,lat, lngfor geolocation,JSON.stringifyfor json, the option labels for multiselect). Deterministic and testable, but it is a second formatting authority next to the cell renderers — the exact drift shape objectui#4037 closed forformatAddress, so it should reuse those functions rather than re-spell them.⛔ Not "don't offer copy for objects": the value IS copyable and the reader has a legitimate reason to want it.
The pin should assert what reaches
navigator.clipboard.writeText, per type, from a renderedDetailSection— with a control asserting an ordinary string row still copies its own value unchanged.Measured
Read on branch
claude/issue-8376-detailsection-emptiness-authorityatb6ed5cdaf.canCopyishasCellValue(value)as of #8376, and that function classifies every object as a value (deliberately — see its docblock), exactly as the raw test it replaced did. So #8376 neither introduces nor widens this; theString(value)line is untouched by it.Dedup
search_issuesonce, self-validating (it returned a card that IS about this file's object-valued cells): the clipboard /[object Object]/ detail copy wording returned exactly one issue, objectui#4037 — the address cell renderer, closed. #4037 is read-side only and says nothing about the clipboard; its own body scopes itself to the display registry. Nothing names the clipboard write.Related
objectui#4037 (
formatAddress, the shared address formatting authority a fix B would reuse) · objectui#8376 (hasCellValue, which decides whether the affordance is offered at all)