Skip to content

script actions can only redirect to a NEW TAB — the redirectUrl convention has no same-tab (SPA route) form #9566

Description

@baozhoutao

Current behavior

When a server-side action handler returns { redirectUrl }, objectui's consoleServerAction.ts (the one place the /api/v1/actions/... response is interpreted for navigation) has exactly two paths:

  • with opensInNewTab: true — the pre-opened spinner tab is driven to the URL (popup-safe, works well);
  • without it — window.open(url, '_blank') after the awaited POST, where the user-gesture context is already gone, so the popup blocker fires almost every time and the user is left with the “Popup blocked” toast fallback.

A same-tab navigation exists only as the error fallback when the pre-opened tab handle has gone stale (window.location.href = url). There is no way for an author or a handler to ask for it: ActionSchema is strict and has no post-success navigation key (redirect / onSuccess / navigate are all rejected), and openIn applies to type:'url' only.

References: spec opensInNewTab/newTabUrl (#1787, in packages/spec/src/ui/action.zod.ts); redirectUrl convention fixed by objectui#2967 and consolidated in objectui#2904 (packages/app-shell/src/utils/consoleServerAction.ts). All shipped in spec/console@17.0.0.

Why this matters

“Server-side action completes → take the user to the resulting record” is the standard ask for copy/clone/generate-style actions. Real case: steedos-labs/os-project-titanwind-ehr#1172 — a “copy as new version” record-header button clones a v+1 draft, but the user is left on the old record and has to re-filter the list to find the new one. The new-tab form is a workable interim, but the natural UX is an in-place jump.

Ask

  1. Same-tab form for the redirectUrl convention. Let the handler (or the action metadata) express it — e.g. handler returns { redirectUrl, openIn: 'self' }, or an ActionSchema key — and have the console perform an SPA route navigation (no full page load, immune to popup blocking).
  2. (Companion) client-side routing convention. Today the handler must hand-build the console route (/apps/<appId>/<object>/…), leaking the console's URL scheme into every app's server code. A convention like “handler returns { recordId, objectName } and the client routes to that record's detail page” keeps route knowledge where it belongs.

Either half alone is not enough for the case above: (1) without (2) still hard-codes routes server-side; (2) without (1) still lands in a new tab.

Acceptance

A type:'script' action whose handler returns the designated shape navigates the CURRENT tab to the target record's detail page via the SPA router — no full reload, no popup-blocker interaction.

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage: lands in the decision box — needs-user-decision + domain:spec, type Feature. Feature-class + a public contract shape (ActionSchema key / redirectUrl convention) ⇒ manual floor. (Triage seat, session session_0185aJyu6TRnHbcVgSfGPSho.)

    ⭐ Rule together with #9474. These two cards are one design family: #9474 asks for post-success navigation for api-type actions (proposed onSuccess.navigate + ${result.*} interpolation), this card asks for a same-tab form of the script-type redirectUrl convention. Ruling them separately risks two navigation contracts for one need. The recommendation below treats them as one shape; the maintainer can still split them deliberately.

    Routing note: cross-repo feature — contract-first split applies. The spec key shape is the upstream half (this card, domain:spec); the console navigation behavior (consoleServerAction.ts SPA route hop) is a downstream objectui card once the shape is ruled, carrying Blocked-by: this one.

    Four-facet block:

    1. Platform long-term coherence — one post-success navigation contract covering both action types beats per-type conventions (redirectUrl handler convention for script, a schema key for api). A single onSuccess shape with an openIn: 'self' | 'newTab' member subsumes both cards and retires the popup-blocker workaround class instead of patching it per type.
    2. Measured business pull — two independent external pulls: steedos-labs/os-project-titanwind-ehr#1172 (this card: clone-then-jump leaves the user on the old record) and the external project feat(actions): api 型动作需要「成功后跳转到服务端返回的记录」能力(ActionSchema 跳转键 + 响应进入插值作用域) #9474's triage recorded as waiting. This is the standard ask for copy/clone/generate actions, not speculative surface.
    3. AI-agent error-resistance — ActionSchema is strict and already rejects improvised keys (redirect/onSuccess/navigate all bounce) — that's the right posture; the fix is to declare ONE closed shape, not to loosen. A closed enum openIn beats handler-side ad-hoc conventions an AI would have to guess.
    4. Startup scope discipline — smallest complete shape: one schema key + the interpolation scope + one console branch (SPA navigate instead of window.open). ⛔ No general navigation DSL, no per-surface variants.

    Recommendation: adopt one onSuccess navigation shape at the spec level answering both #9474 and this card, with same-tab as an explicit member; interim (pre-ruling) the new-tab form remains the documented workaround.


    Generated by Claude Code

  2. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    Maintainer ruling recorded — maintainer, 2026-08-18, in-session to the domain:spec PM (session session_01Fs18A2DdXLVN2h8PaaFBcP), verbatim: 「9566 插队」 then 「9566 同意」 approving the presented shape. Ruled together with #9474 per the triage recommendation (one navigation contract, not two):

    1. ActionSchema gains one CLOSED onSuccess key, applying to both api and script action types: navigate (a route/URL template) + openIn: 'self' | 'newTab' (closed enum — no general navigation DSL, no per-surface variants).
    2. The interpolation scope for the navigate template gains ${result.*} — the server response joins ${param.*} / ${ctx.*} (feat(actions): api 型动作需要「成功后跳转到服务端返回的记录」能力(ActionSchema 跳转键 + 响应进入插值作用域) #9474's companion ask).
    3. Defaults, deliberately split by surface: schema-declared onSuccess.navigate defaults openIn: 'self' (new surface, natural SPA-route UX, immune to popup blocking); the shipped handler-return convention { redirectUrl } WITHOUT openIn keeps its current new-tab behavior (17.0.0 compatibility — no silent behavior flip for existing handlers); a handler may return openIn: 'self' explicitly to opt into the same-tab jump.
    4. Contract-first split: this spec half lands first and does not wait on objectui; the console halves (SPA route navigation in consoleServerAction.ts, executeAPI navigation handling, interpolation implementation) are downstream objectui card(s) filed at this card's landing, Blocked-by: these cards.

    One spec PR delivers the contract for BOTH cards (Fixes #9566, Fixes #9474); the runtime capability reaches users when the objectui half lands (tracked there; customer ref steedos-labs/os-project-titanwind-ehr#1172).

    Claim: this seat, branch claude/issue-9566-onsuccess-navigation, fable (clause-② — new authorable key + closed enum + interpolation-scope contract); measured quota exemption → opus if refused, recorded here. Priority: p0 per the maintainer's 插队.


    Generated by Claude Code

  3. self-assigned this
    on Aug 18, 2026
  4. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator
    {
      "issue": 9566,
      "sibling": 9474,
      "outcome": "delivered",
      "pr": 9601,
      "branch": "claude/issue-9566-onsuccess-navigation",
      "head": "b53b4fb70",
      "key_shape": "ActionSchema.onSuccess: strictObject { navigate: string (route/URL template; documented scope ${param.*} + ${ctx.*} + ${result.*} NEW = server response), openIn: enum('self','newTab').default('self') } — optional, refine-scoped to type 'api'|'script' (the #4352 enforcement shape; ActionSchema is one strict object discriminated by a `type` key, not per-type schemas); InlineActionSchema deliberately does not pick it",
      "default_mechanism": "materialized .default('self') — the file's convention (type/refreshAfter/ai.exposed); parse output always carries the resolved member so the future console branch needs no fallback of its own; pinned via parse-output observable. Handler-return { redirectUrl } compat (absent openIn => new-tab, 17.0.0) recorded in the key's JSDoc, no handler code touched",
      "files": [
        "packages/spec/src/ui/action.zod.ts",
        "packages/spec/src/ui/action-on-success.test.ts",
        "packages/spec/liveness/action.json",
        "packages/spec/scripts/strictness-ledger.test.ts",
        ".changeset/action-onsuccess-navigation.md",
        "content/docs/references/ui/action.mdx",
        "docs/audits/2026-07-unknown-key-strictness-ledger.counts.md",
        "packages/spec/authorable-surface/ui.json",
        "packages/spec/liveness/state-counts.md"
      ],
      "gates": [
        "spec test 410 files / 10957 tests PASS at b53b4fb70 (union re-run at final head)",
        "spec typecheck + test-typecheck ledger PASS",
        "check:generated all 13 artifact gates PASS (--fix regenerated exactly the 3 proved stale: gen:docs, gen:strictness-ledger, gen:liveness-counts)",
        "check:liveness PASS after drilling the onSuccess container per child (navigate/openIn, planned strength, #9340/#9463 convention)",
        "dispatch-gates matched families all PASS: adr-0087-registration, changeset-no-major, empty-changeset, cross-package-test-inputs, dev-prereqs, affected-docs, changeset-gate-self-tests, doc-authoring, docs-audit-scope, docs-redirects, merge-driver, objectui-changeset, quick-reference-counts, role-word, spec-parsed-alias, type-source-resolution, doc-formula-expressions",
        "convention-triggered PASS: query-options-erasure, engine-double-contract, where-matcher, type-check-coverage, type-check-debt --re-measure (33 entries, none above), nul-bytes",
        "consumer sweep DOWNSTREAM: turbo build ./packages/* closure 70/70 PASS against rebuilt spec dist; @objectstack/example-showcase validate + typecheck PASS"
      ],
      "reverse_verification": "Predicted then observed, from committed state (git restore --source=origin/main action.zod.ts, pins kept): 17 red — all accept pins, both default pins, every message-bearing refusal pin, all 4 type-scope refusals, both top-level guidance pins, and the strictness site-count pin (9->8). 4 predicted VACUOUS survivors stayed green (pure success===false pins also satisfied by the unknown-key rejection) — reported as vacuous, not counted as coverage. Restored via git checkout HEAD; 36/36 green, clean tree.",
      "deviations": [
        "Enum spelling observation, flagged not deviated: ruled 'newTab' coexists with the shipped top-level openIn's 'new-tab' (type:'url', 17.0.0). Implemented exactly as ruled; an issue.input-keyed enum error (ActionLocationSchema precedent) makes the crossover spelling loud in both directions. Harmonizing the shipped value would be a breaking migration outside this ruling.",
        "Type-scope refinement (refuse onSuccess on url/modal/flow/form) added as ENFORCEMENT of the ruling's 'applies to api and script' — ADR-0078 posture, #4352 shape — since ActionSchema cannot express it structurally; pinned per type.",
        "strictness-ledger.test.ts site-count pin moved 8->9 for the new strictObject site, per that test's own 7->8 precedent.",
        "Liveness entry drilled per child rather than one container verdict — the gate's undeclared-container check (#4956) required it; both children planned with the same cross-repo measurement and amend-on-landing instruction."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  5. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    ACCEPT — review of record against PR #9601 (head b53b4fb70), full diff read (9 files), covering BOTH this card and #9474.

    Landing: ready + auto-merge (squash) once CI is green. On MERGE, pre-registered: this seat files the objectui downstream card — consoleServerAction.ts SPA-navigation branch honoring schema onSuccess + handler openIn: 'self', executeAPI navigation handling, interpolateTarget gains the result scope member — Blocked-by: objectstack#9566 + objectstack#9474, dispatchable when objectui's spec pin covers this landing; liveness amends to live with that half.


    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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions