Skip to content

Commit 55a1feb

Browse files
feat(app-shell): one top bar above the record header on every face, the enlarged related list draws a toolbar, and Activity counts on a deep link (#28458)
domain:ui#5 · os-sales · session_01L6bhgVP3twvBvDTwoLPU6J <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuP5SeDdTXdueuof1bJtgkLy)_ Fixes #27524 Clause-②: yes (widening: `@object-ui/components` gains `RecordTopBar`, `RecordTopBarButton`, `useRecordStepKeys` and the types `RecordTopBarProps` / `RecordTopBarStepper`; `NavigationOverlay` gains an optional `toggles` prop and its `stepper` an optional `position` and `scope`; `@object-ui/i18n` gains eight `detail.*` keys in all ten locales; no entry export retires and no accept set moves. The claim's provisional `no` is corrected here, measured on the diff.) Metadata-protocol: none ## 维护者速读 - **改了什么**:记录在每一种呈现方式里(整页、分屏面板、覆盖抽屉、最大化、手机)都在记录头上方画一条 40 px 顶栏。左边是面包屑「对象 › 记录名」(点对象回列表;面板下一层仍是「← 路径」);右边按固定顺序只画适用的控件:计数「3 / 21」和 ↓ ↑、⊟、⤢ / ⤡、✕。原来记录头上方的面包屑行(‹ 来源、收为抽屉)删掉,⊟ 从记录头操作行移进顶栏。另带两项(③ 预览闸门裁定):放大的相关列表画看板工具栏(筛选、全部 / 我的 / 逾期、新建、导出)而不是卡片头尾;深链到别的标签时「动态」标签也显示条数。 - **为什么改**:#27524 Target 7 和 Target 4 的 ⊟ 部分(画布 60 定稿),四个串行 PR 的最后一个,带 `Fixes #27524`。 - **风险与回滚**:只动 ui 包(components、app-shell、plugin-detail、十个语言包),不碰 spec、不碰服务端。没有退役任何入口导出(`PanelToggles`、`PageHeaderTrailingContext` 都保留,宿主自己的普通覆盖层仍把 ⊟ 放在记录头末尾),所以是 widening。整页上的计数和 ↓ ↑ 只在从列表打开时出现(行的 ↗,或行本身就开整页的列表,比如 HotCRM 线索),用 `replace` 换页,不增历史;⤡ 只给 ↗ 打开的整页。回滚即 revert 本 PR。 - **先看**:`ui/packages/components/src/custom/record-top-bar.tsx`(顶栏和 j / k 归属);`ui/packages/app-shell/src/views/RecordDetailView.tsx` 的 `pageLeading` / `pageBar`;`recordDrawerStack.tsx` 的 `useRecordPageReturn` 和 `RecordPaneBarToggles`;`ui/packages/plugin-detail/src/RelatedList.tsx` 的 `enlargedFace`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/components/src/__tests__/record-top-bar-27524.test.tsx packages/app-shell/src/views/RecordDetailView.topBar-27524.test.tsx packages/plugin-detail/src/__tests__/RelatedList.enlargedToolbar-27524.test.tsx packages/plugin-detail/src/__tests__/RecordPageColumns.tabRow-27524.test.tsx packages/app-shell/src/views/objectViewRecordNavigation.listRows-27524.test.tsx`,预期 5 个文件 34 个测试全绿。 - **需要你做的**:无,席位做预览闸门(HotCRM 截图对照 v4 画板)后落地。 ## What changes Record page v4, Target 7 and the ⊟ half of Target 4, plus the two ride-alongs ruled at ③'s preview gate. PR ④ of the four serial PRs for #27524, cut from `main` after ③ (#28394, `bf74607954`). ### The top bar (Target 7) `RecordTopBar` (`@object-ui/components`, new `custom/record-top-bar.tsx`) is one 40 px row above the record header. The page, the split pane, the overlay drawer, the maximized pane and a phone all draw this one component. - **Left: the breadcrumb.** - At the pane's root it reads 「对象 › 记录名」 (for example 「线索 › Mira Costa」). The object name closes the pane, which returns to the list. - One level down in the pane, the bar keeps 「← 路径」 as #26679 drew it. - On the page, the object goes to the list the page was opened from (the `?from=` origin, without the record the list had open) or else to the object's list. - A page opened under a `?from=` trail draws ← and the path, and each ancestor links to its own page. - The record's name in the pane bar comes from the record itself, through `RecordPaneBarContext.reportTitle`. - **Right, in a fixed order.** First the counter 「n / N」 and ↓ ↑, then a hairline, then ⊟, then ⤢ / ⤡, then ✕. Each one is drawn only where it applies: - **The pane.** The counter and ↓ ↑ show whenever the list reported its rows (#26679's sibling set). The tooltip names the list: 「全部线索 中第 3 条,共 21 条」. ✕ closes the whole path. - **The page.** The counter and ↓ ↑ show only for a page a list opened. That is the row's ↗, or a row click or record link on a list whose rows open as pages, as HotCRM's lead list does. Every navigation the list makes to a record's page (`handleNavOverlayNavigate`) now hands the shown rows in the page's `from` state when the record is one of them, and the ↗ also hands them in its way back to the pane. ↓ ↑ and `j` / `k` replace the page with the sibling's page, keep the list's state, and drop `?related=`. On the page the arrow keys still scroll. - **⤡ 「收为抽屉」** shows only for a page the row's ↗ opened. It returns to the list with the record you stepped to open in the pane. A list whose rows open as pages opens no pane, so there is nothing to collapse to. - **A page opened directly** shows only ⊟. It has no counter, no ⤡ and no ✕. - **The step keys** belong to the latest host. While a pane is open over a page, the pane owns `j` / `k` until it closes (`useRecordStepKeys` keeps an owner stack). - **What goes.** The crumb row above the record header goes: the 「‹ origin」 and 「收为抽屉」 row, together with its small ‹ line. The header's action row keeps the business actions only. ### ⊟ in the top bar (Target 4) The right-column switch (「右栏」, ⌘⇧,) leaves the header's action row. - On the page it is drawn in the page's top bar. - In the pane, the record portals it into the pane's own bar (`RecordPaneBarToggles` into the bar's `data-record-pane-toggles` slot). - It is drawn only where the face's tier has a right column, the rule ② already applied to `PanelToggles`. - A host's plain overlay without the console's pane (`InterfaceListPage`, `ObjectDataPage`) has no bar slot. It keeps ⊟ at the end of the header's action row through `PageHeaderTrailingContext`, so that export stays. ### Ride-along (a): the enlarged related list draws the board toolbar When a related list is the active tab, `RecordPageColumns` provides `RelatedListEnlargedContext`. `RelatedList` then draws: - **A toolbar** (`role="toolbar"`) over the table, in place of the card header and the card foot. The toolbar holds: - the filter input; - the chips 全部 N / 我的 / 逾期; - the child object's toolbar actions; - 新建; - 导出. - **A status line** 「N 条 · 第 x / y 页」 between the toolbar and the table. - **The table at the face's full height.** Each chip is drawn only where the child object declares what it says: - **我的** where the object has an `owner_id` column. It filters with `{ owner_id: '{current_user_id}' }`. - **逾期** where a `date` / `datetime` field declares `dueLike: true` with no `settledWhen`. It filters with `{ field: { $lt: '{today}' } }`. A `settledWhen` is CEL that no query can ask, so such a field gets no chip. - **全部** alone is no choice, so then no chip group is drawn. A chip narrows the list's own read: it is merged beside the parent scope with `mergeFilterNodes`. **导出** downloads a CSV through `dataSource.exportDownload`. It sends the same parent, chip and list filter, the filter text as `search`, the shown columns and the order. ### Ride-along (b): 动态's count on a deep link `RecordTimelineCountProbe` (in `record-activity.tsx`) reads the feed the page already holds and reports its size to the tab row. The feed is the discussion context, the field-change context, and the node's types and `showCompleted`. The probe starts no read of its own. It is mounted only when the 动态 tab is not active and the stream is not drawn elsewhere: a deep link to another tab, or ⊟ off. Before ④, 动态 had no count there until the stream was drawn once (③'s noted gap). ### i18n Eight keys arrive in all ten packs, with each pack's plural forms: `detail.recordOfScope`, `detail.relatedFilter`, `detail.relatedChips`, `detail.relatedChipAll`, `detail.relatedChipMine`, `detail.relatedChipOverdue`, `detail.relatedExport` and `detail.relatedRows`. ### File surface beyond the claim - `app-shell`: - `ObjectView.tsx`, `objectViewRecordNavigation.ts` and `objectViewRecordOverlay.tsx`: the view's label as the counter's scope, and the shown rows handed to every page the list opens. - `recordDrawerContext.ts`: the pane bar context. - `urlParams.ts`: one state-key constant. - `components`: `custom/record-top-bar.tsx` and its line in `custom/index.ts`, in place of `renderers/layout/containers.tsx`. The bar is a frame component, not a page-layout slot, so `containers.tsx` is untouched. `panel-toggles.tsx` is untouched too: ⊟ is still `PanelToggles`, drawn in a new place. - `plugin-detail`: `RelatedList.tsx`, `relatedListCompact.ts`, `renderers/record-activity.tsx` and `useDetailTranslation.ts`, for the two ride-alongs. #27545's split / overlay / maximize mechanics are not changed. `NavigationOverlay` only draws its existing ⤢ / ⤡ / ✕ into the shared bar. ## Tests New: - `components/src/__tests__/record-top-bar-27524.test.tsx` (6): - the right end's fixed order; - the counter's text and its tooltip naming the list; - only what is handed is drawn; - the pair without a counter; - the step-key owner stack: a page steps with `j` / `k` but not the arrows, a pane over it takes the keys until it closes, and a pane with no set holds them without stepping the page; - `NavigationOverlay` drawing the shared bar. - `app-shell/src/views/RecordDetailView.topBar-27524.test.tsx` (7), a real `RecordDetailView`: - a direct page: the crumb, only ⊟, no crumb row; - a page from ↗: the counter 「2 / 3」 with its scope, ↓ to the sibling by `replace`, `k` back, ArrowUp ignored, ⤡ back to `?recordId=` with the restore state; - a page a list opened as a page: 「1 / 3」 with its scope, no ⤡, `j` and ↑ step, and the next page keeps the same `from` state; - control: an origin without its rows draws no counter; - the object crumb back to the origin list; - a `?from=` trail with ← and the ancestors; - the pane: ⊟ portalled into the pane's bar, the record's title reported to the crumb, one bar only, the scope tooltip. - `app-shell/src/views/objectViewRecordNavigation.listRows-27524.test.tsx` (3), the list's navigation hook: - a row that opens as a page hands the origin, the view's name and the shown rows; - control: a record the list does not show gets the origin alone; - the row's ↗ hands the rows both in the way back to the pane and in the origin. - `plugin-detail/src/__tests__/RelatedList.enlargedToolbar-27524.test.tsx` (5): - the toolbar with no card; - 我的 and 逾期 narrowing the read beside the parent; - a chip drawn only where the object declares it; - 导出 with the parent and the chip; - control: outside the active tab the card face stays. - `plugin-detail/src/__tests__/RecordPageColumns.tabRow-27524.test.tsx` (+3): - 动态's count on a deep link to another tab; - control: no count while the feed is loading or absent; - the count with ⊟ off. Pins updated to the new anatomy, not deleted: - `RecordDetailView.panelToggles-27052` (#27052): ⊟ in the top bar, nothing in the header's trailing slot, no crumb row, the bar before the header. - `recordDrawerStack-26679` and `recordDrawerStack.rootStepper-26679` (#26679): the root crumb 「对象 › 记录名」 and the counter. - `RelatedCardStack-27055` (#27055): the enlarged face's stub sees the enlarged context. - `RecordDetailView.pageHeaderTitleFls-10499`: the record's gated name now appears twice, in the H1 and in the top bar's crumb. The pin checks that the crumb names the same gated title and never the denied email, and that the body still shows the name once. - `residue-namespaces-3546`: RelatedList's `detail.add` sites number four with the toolbar's Add. - The #26678 pins (`RecordDetailView.recordPageTiers-26678`, `RelatedList.compactColumn-26678`) needed no change and ran green in the broad suite below. No test file is named for #27473. The plugin-detail package and the record-view tests, where its pins would sit, ran in full in that suite. <details><summary>Eleven ablations (each through <code>node scripts/ablation-replace.mjs</code>, restore proven blob == HEAD)</summary> - The page's bar not drawn: `RecordDetailView.topBar-27524`, 4 failed. - The pane toggles not portalled: 1 failed. - The page stepper withheld: 1 failed. - The page's `from` rows not read (`readFromSiblings`): `RecordDetailView.topBar-27524`, 1 failed | 6 passed. - The list's navigation not handing its rows in `from`: `objectViewRecordNavigation.listRows-27524`, 2 failed | 1 passed. - The root crumb reverted to the bare path: 1 failed. - The step-key owner stack disabled (every host steps): `record-top-bar-27524`, 2 failed. - The count probe not mounted: `RecordPageColumns.tabRow-27524`, 2 failed. - The enlarged face off: `RelatedList.enlargedToolbar-27524`, 4 failed | 1 passed. - The chip left out of the read: `RelatedList.enlargedToolbar-27524`, 2 failed | 3 passed. - The enlarged context not provided by `RecordPageColumns`: `RelatedCardStack-27055`, 1 failed | 12 passed. </details> ## Acceptance notes **Screenshots on HotCRM** against the v4 boards at `a7825499aa`, zh-CN. They are committed on this branch in `b0a817c91d` and removed again in the next commit, so they never reach `main`. The folder's [README](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/README.md) says how they were made, and [`facts.json`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/facts.json) records each shot's top bar as read from the DOM. HotCRM runs on this branch's own server. The published 18.2.0 server has no `POST /data/:object/:id/page`, which the console's record page now reads, so it shows 「未找到记录」. Three of HotCRM's lead form views declare the form `type`s that spec 18.3.0 retired; in the rig's clone only, they were set to `simple`. | Shot | What the top bar shows | Board | |:--|:--|:--| | [`01-lead-1440-from-list.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/01-lead-1440-from-list.png?raw=true) | Lead at 1440, opened from the lead list. HotCRM's lead list opens its rows as pages, so there is no ↗ there. The bar reads 「线索 › Mira Costa」 with `1 / 21`, the tooltip 「全部线索 中第 1 条,共 21 条」, and ↓ ↑. There is no ⊟: the lead declares its own page layout, which has no right column. | RecordPageV4Panels | | [`02-lead-1440-after-step-down.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/02-lead-1440-after-step-down.png?raw=true) | After ↓: `2 / 21`, Kai Watanabe, the page replaced. | RecordPageV4Panels | | [`03-lead-1200-direct.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/03-lead-1200-direct.png?raw=true) | Lead at 1200, opened directly: the crumb only. | RecordPageV4TwoCol | | [`04-contact-split-720.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/04-contact-split-720.png?raw=true) | Contact in the split pane at 720: 「联系人 › John Smith」, ⤢, ✕. There is no counter because the contact list is grouped by account (see the first note below). There is no ⊟ because the 720 tier has no right column. | RecordDrawerV4Peek | | [`05-contact-maximized.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/05-contact-maximized.png?raw=true) | The pane maximized: ⊟, ⤡, ✕. | RecordDrawerV4Max | | [`06-contact-split-task-tab.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/06-contact-split-task-tab.png?raw=true) | The pane with 任务 active, chosen from 「+5 ▾」. The toolbar holds the filter, 全部 1 / 我的, 新建 and 导出, with 「1 条」 under it. | RecordDrawerV4Enlarged | | [`07-contact-page-task-tab-deep-link.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/07-contact-page-task-tab-deep-link.png?raw=true), [`07b-contact-page-task-tab-mine.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/07b-contact-page-task-tab-mine.png?raw=true) | The contact page deep-linked to 任务: the same toolbar, and 动态 counts `2` although its stream is not drawn. | RecordPageV4Enlarged | | [`08-product-1440.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/08-product-1440.png?raw=true) | Product at 1440, opened directly: the crumb and ⊟. | RecordPageV4Product | | [`09-lead-phone-390.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/09-lead-phone-390.png?raw=true), [`09b-contact-phone-390.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/09b-contact-phone-390.png?raw=true) | A 390 phone: the crumb only. | RecordMobile | | [`10-crm_account-split-pane-counter.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/10-crm_account-split-pane-counter.png?raw=true), [`11-crm_account-split-pane-after-step.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/11-crm_account-split-pane-after-step.png?raw=true) | An account in the pane over the ungrouped account list: `3 / 9`, then `4 / 9` after ↓, with ⤢ and ✕. | RecordDrawerV4Peek | | [`12-crm_account-page-from-arrow.png`](https://github.com/objectstack-ai/objectstack/blob/b0a817c91dcac099c993f1a9246198079159f5a6/evidence/record-page-v4-top-bar/12-crm_account-page-from-arrow.png?raw=true) | An account page opened by the row's ↗: `1 / 9`, ↓ ↑, ⊟, ⤡ 「收为抽屉」. | RecordPageV4Panels | No shot has the crumb row (`crumbRow: false` in every `facts.json` entry). Every top bar measures 40 px. Not built here, reported: - **The counter's group scope.** Target 7 says the tooltip names the group when the list was grouped or is a kanban column. `ListView` reports its row sequence only for the paged, ungrouped grid (#26679, `rowSequenceShown`). On a grouped list or a kanban board the pane therefore has no sibling set and draws no counter at all; the HotCRM contact list, grouped by account, is one. Naming the group needs `ListView` (plugin-list, outside this claim) to report grouped and lane rows with their group. That is a follow-up. - **逾期 on HotCRM.** `crm_task.due_date` does not declare `dueLike`, so HotCRM's 任务 tab shows 全部 / 我的 only. The chip appears where an object declares the deadline. - **The object crumb.** It returns to the list without a pane, so the board's "the record you last saw stays selected" is not drawn: the list has no selection without a pane. ⤡ is the path that returns with the record open. - **导出.** It sends the filter text to the server as `search`, while the table's own text filter is a client-side contains sweep over the fetched page. The CSV can differ where the server's search matches other fields. - **The phone.** It draws the same bar. The board's phone header (‹ title ↑ ↓ ⋯) and bottom action bar are a separate face and are not in ④. - **The app header's crumb** is unchanged (#27950 holds the list title, per the release). ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` is not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the changed files: 0 errors. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the project's Chinese summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. ## Verification All local readings are at head `d94e010b0a`, which includes the merge of `origin/main` `5517005d3a`. Against that merge base the change is 37 paths, +2,125 / −303. The evidence folder is added and removed inside the range, so it is not among them. <details><summary>Tests, type-check, lint</summary> - **Broad suite.** Under the verify lock: `pnpm exec vitest run --maxWorkers=3 packages/plugin-detail packages/components packages/i18n` plus the 619 other test files under `ui/` that name a module, test id or key this PR changes, derived by `git grep`. - The run was on `6c535caa83`: `Test Files 4 failed | 1529 passed | 2 skipped (1535)`, `Tests 10 failed | 17140 passed | 67 skipped (17217)`. - All ten reds came from this change, in four files: - `all-locales-key-parity` and `plural-categories-11432`: fr / es / pt lacked `detail.relatedRows_many`, and ar's `_one` / `_two` lacked `{{count}}`; - `residue-namespaces-3546`: RelatedList's `detail.add` count, now 4 with the toolbar's Add; - `RecordDetailView.pageHeaderTitleFls-10499`: the record's name now also appears in the top bar's crumb. The pin now checks that the crumb names the same gated title and never the denied email. - All four were fixed in `d94e010b0a`, and the four files rerun green: `Tests 107 passed (107)`. - **Type-check.** `type-check` (`tsc --noEmit` + `tsc -p tsconfig.test.json`) of `@object-ui/app-shell`, `@object-ui/components` and `@object-ui/plugin-detail` under the lock, and `@object-ui/i18n` at `d94e010b0a`: each exit 0. - **eslint** on the 36 added or modified `ts` / `tsx` files under `ui/` at `d94e010b0a`: 0 errors. The warnings left on added lines are `react-refresh/only-export-components` on the hook exports beside components, a pattern these files already follow. - **`pnpm --dir ui run check:i18n-drift`**: exit 0. </details> <details><summary>Derived gate families: 34 derived, 34 run, 0 NOT-MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives the same 34 commands at `857d25f771` and at `d94e010b0a`. Each was run at `d94e010b0a` with stdin from `/dev/null`, and its exit code was captured before any pipe. An earlier pass at `857d25f771` had one red: `pnpm --dir ui exec vitest run scripts/`, in `check-doc-snippet-types`' root-bound case on `plugin-grid` / `plugin-report` READMEs that this branch does not touch. It came while a `turbo` build was rewriting `@objectstack/spec`'s `dist` (the HotCRM rig), and it is green on every run since: `169 passed | 2 skipped`, `4901 passed`. `--ran` on this record: "34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero — all 34 recorded an exit code and none of them is 3". ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> <details><summary>Merge-tree and what was not run</summary> - `main` has moved 7 commits past the merge base. Two of them are #28438, which touches `ObjectView.tsx`, and #28436. `git merge-tree --write-tree HEAD origin/main` (`776c1d875b`) is clean, and so is the open #28440. No second merge was pushed. - Not run locally, left to CI: the whole `ui/` suite, the other type-check lanes and the repo-wide scans. </details> CI check-runs: not started at the time of writing. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVP3twvBvDTwoLPU6J)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVP3twvBvDTwoLPU6J --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e1089e commit 55a1feb

37 files changed

Lines changed: 2125 additions & 303 deletions

‎.changeset/27524-record-top-bar.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/plugin-detail': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
feat(app-shell): one top bar above the record header on every face — the breadcrumb on the left; the counter with ↓ ↑, ⊟, ⤢ / ⤡ and ✕ on the right, each only where it applies
9+
10+
Clause-②: yes (widening: `@object-ui/components` gains `RecordTopBar`, `RecordTopBarButton`, `useRecordStepKeys` and the types `RecordTopBarProps` and `RecordTopBarStepper`, `NavigationOverlay` gains an optional `toggles` prop and its `stepper` an optional `position` and `scope`; `@object-ui/i18n` gains `detail.recordOfScope`, `detail.relatedFilter`, `detail.relatedChips`, `detail.relatedChipAll`, `detail.relatedChipMine`, `detail.relatedChipOverdue`, `detail.relatedExport` and `detail.relatedRows` in all ten locales; no export retires and no accept set moves)
11+
Metadata-protocol: none
12+
13+
The record page v4 draws one 40 px top bar above the record header, on every face: the full page, the split pane, the overlay drawer, the maximized pane and a phone. The bar holds the frame controls. The record header's action row keeps the business actions only.
14+
15+
- **Left: the breadcrumb.** At the root it reads 「对象 › 记录名」 (for example 「Leads › Mira Costa」). The object returns to the list: in a pane it closes the pane, and on a page it goes to the list the page was opened from, or to the object's list. One level down in a pane, the bar keeps 「← 路径」 as before. A page opened under a `?from=` trail shows ← and the same path, and each ancestor links to its page.
16+
- **Right, in a fixed order.** First the counter 「3 / 21」 and ↓ ↑. Its tooltip names the list's view. Then ⊟, then ⤢ / ⤡, then ✕. Each control is drawn only where it applies:
17+
- In a pane, the counter and ↓ ↑ show whenever the pane has a list context. ✕ shows on the pane faces only, and it closes the whole path.
18+
- On a page, the counter and ↓ ↑ show only for a page a list opened: the row's ↗, or a row click or record link on a list whose rows open as pages. ↓ ↑ and `j` / `k` step through that list's rows: a step replaces the page with the sibling's page. The arrow keys still scroll the page.
19+
- ⤡ 「收为抽屉」 (Collapse to drawer) shows only for a page the row's ↗ opened. It returns to the list with the record you stepped to open in the pane.
20+
- A page opened directly shows only ⊟.
21+
- While a pane is open over a page, the pane owns `j` / `k`.
22+
- **⊟ moves into the bar.** The right-column switch (「右栏」, ⌘⇧,) leaves the record header's action row. It is drawn in the top bar only where the face's tier has a right column. In a pane, the record puts it into the pane's own bar. A host's plain overlay without the console's pane keeps it at the end of the header's action row.
23+
- **The crumb row goes.** The row above the record header that held 「‹ origin」 and 「Collapse to drawer」 is removed. Both now live in the top bar.
24+
- **The enlarged related list draws a toolbar.** When a related list is the active tab, it draws a toolbar over the table instead of the related list's card header and foot. The toolbar holds a filter input, the chips 全部 N / 我的 / 逾期, the child object's toolbar actions, 新建 and 导出, and a 「N 条 · 第 x / y 页」 line sits above the table. 我的 is drawn where the child object has an `owner_id` column. 逾期 is drawn where the child object declares a deadline (`dueLike: true` on a `date` or `datetime` field) with no `settledWhen`, because no query can ask which deadlines are settled. 导出 downloads a CSV through the server with the same parent, filter, chip, filter text and order the table shows.
25+
- **动态 count on a deep link.** A link that opens the record on another tab, or a page with the right column switched off, still shows the 动态 tab's count. The count comes from the feed the page already holds; it starts no read of its own.

‎ui/packages/app-shell/src/urlParams.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ export const RECORD_DETAIL_TAB_PARAM = 'tab';
104104
*/
105105
export const RECORD_RELATED_ENLARGE_PARAM = 'related';
106106

107+
/**
108+
* History-state flag beside {@link RECORD_RELATED_ENLARGE_PARAM}: the record
109+
* view pushed the `?related=` entry, so closing the tab pops it. Not a URL
110+
* parameter; a navigation that replaces the entry drops it.
111+
*/
112+
export const RECORD_RELATED_ENLARGE_PUSHED_STATE = '__relatedEnlargePushed';
113+
107114
/** Command palette overlay (ADR-0054 Phase 1; alias `cmdk`). */
108115
export const COMMAND_PALETTE_PARAM = 'palette';
109116

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
745745
// an object that declares `page` (or `new_window` / `none`) keeps its
746746
// declaration. objectstack#27545 — the pane beside the list is the default.
747747
const [recordOpenPreference, setRecordOpenPreference] = useRecordOpenPreference();
748-
const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleOpenAsPage, handleOpenRowAsPage, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep } = useObjectViewRecordNavigation({
748+
const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleOpenAsPage, handleOpenRowAsPage, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep, listLabel } = useObjectViewRecordNavigation({
749749
activeView,
750750
objectDef,
751751
objects,
@@ -1168,6 +1168,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
11681168
drawerRoot={drawerRoot}
11691169
drawerRestore={drawerRestore}
11701170
handleRootStep={handleRootStep}
1171+
stepScope={listLabel}
11711172
dataSource={dataSource}
11721173
objects={objects}
11731174
onEdit={onEdit}

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,16 @@ describe('RecordDetailView — `page:header` builds the H1 from the fields the v
204204
// `titleFormat: '{email}'` it hid `name` while the H1 printed the denied
205205
// email, and the readable name was on screen nowhere. The H1 now reads the
206206
// same row: it shows the name, and the hidden `name` row is its duplicate,
207-
// as on a backend that strips `email`. The name is on the page once, in
208-
// the H1; it does not come back as a body row.
207+
// as on a backend that strips `email`. The name is in the record once, in
208+
// the H1; it does not come back as a body row. The top bar's crumb
209+
// 「对象 › 记录名」 (objectstack#27524) names the record with the same
210+
// gated title, never the denied email.
209211
await renderRecordPage({ titleFormat: '{email}' }, denying('email'), 'Ada Lovelace');
212+
const bar = document.querySelector('[data-record-top-bar]');
213+
expect(bar?.textContent).toContain('Ada Lovelace');
214+
expect(bar?.textContent).not.toContain(EMAIL);
210215
const text = document.body.textContent ?? '';
211-
expect(text.split('Ada Lovelace').length - 1).toBe(1);
216+
expect(text.split('Ada Lovelace').length - 1 - ((bar?.textContent ?? '').split('Ada Lovelace').length - 1)).toBe(1);
212217
expect(text).toContain('555-0100');
213218
});
214219

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

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
* for an event that carries no `code`, by the character it typed
2424
* (objectstack#27241), and does nothing while a text field has focus;
2525
* `⌘⇧.` (the timeline column's) is no longer the page's;
26-
* - ⊟ is ONE icon, named by its label, at the end of the record header's
27-
* action row (objectstack#27241) — not on the crumb row, which keeps only
28-
* the ways back and has no child without one;
26+
* - ⊟ is ONE icon, named by its label, in the record's top bar
27+
* (objectstack#27524, Target 7: after the step group, before ⤢ / ⤡) — not
28+
* in the header's action row, which keeps the business actions, and the
29+
* crumb row is gone; a record drawn with no top bar of its own (a host's
30+
* plain overlay) keeps it at the end of that row (objectstack#27241);
2931
* - it is drawn only where the tier has a right column: both two-column
3032
* tiers, the embedded record at those widths too, never the one column;
3133
* - the chord is the topmost record view's: a drawer wide enough for its
@@ -218,16 +220,19 @@ describe('RecordDetailView — the right-column switch ⊟ (objectstack#27052, o
218220
it('one ⊟ icon at the end of the header\'s action row, on; off draws the details alone; on restores', async () => {
219221
const { container } = renderPage();
220222
const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
221-
// objectstack#27241 — the end of the header's action row, after ⋯ and a
222-
// hairline; the crumb row keeps only the ways back (none on a direct open).
223-
expect(group.closest('[data-record-crumb-row]')).toBeNull();
224-
const toolbar = group.closest('[role="toolbar"]')!;
225-
expect(toolbar).not.toBeNull();
226-
expect(toolbar.closest('[data-record-column]')).toBeNull();
227-
const trailing = group.closest('[data-page-header-trailing]')!;
228-
expect(trailing.querySelector('[role="separator"][aria-orientation="vertical"]')).not.toBeNull();
229-
expect(toolbar.lastElementChild).toBe(trailing);
230-
expect(container.querySelector('[data-record-crumb-row]')?.childElementCount ?? 0).toBe(0);
223+
// objectstack#27524, Target 7 — in the page's top bar, among its frame
224+
// controls: no list context on a direct open, so no step group before it.
225+
const bar = group.closest('[data-record-top-bar]')!;
226+
expect(bar).not.toBeNull();
227+
expect(group.closest('[data-record-top-bar-controls]')).not.toBeNull();
228+
expect(bar.querySelector('[data-record-top-bar-step]')).toBeNull();
229+
// Not in the header's action row any more, and the crumb row is gone.
230+
expect(group.closest('[role="toolbar"]')).toBeNull();
231+
expect(container.querySelector('[data-page-header-trailing]')).toBeNull();
232+
expect(container.querySelector('[data-record-crumb-row]')).toBeNull();
233+
// The bar sits above the record header.
234+
const header = container.querySelector('[role="toolbar"]')!;
235+
expect(bar.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
231236
// ONE switch, an icon named by its label: the segmented text control and
232237
// the timeline segment are gone.
233238
expect(group.querySelectorAll('button')).toHaveLength(1);
@@ -315,7 +320,7 @@ describe('RecordDetailView — the right-column switch ⊟ (objectstack#27052, o
315320
const { container } = renderPage({ width: 900 });
316321
const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
317322
expect(container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('two-column');
318-
expect(group.closest('[role="toolbar"]')).not.toBeNull();
323+
expect(group.closest('[data-record-top-bar]')).not.toBeNull();
319324
expect(shown(container)).toBe('details right');
320325
fireEvent.click(rightColumnSwitch());
321326
await waitFor(() => expect(shown(container)).toBe('details'));

0 commit comments

Comments
 (0)