You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b0ef25f
Browse filesBrowse the repository at this point in the historyBrowse files
fix(console): a native push route is hosted under an app, as the inbox hosts it (#29810)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} -->
_Requested by **Steedos** · [project
thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbRCP9FRcztKQfRmd9KXSgR1)_Fixes#29750
Clause-②: no
Metadata-protocol: none
## 维护者速读
- **改了什么**:手机 App 收到推送、用户点开时,通知里的链接(如
`/opportunity/123`)现在会像收件箱一样放进对应的应用里打开记录,不再落到首页;直接打开这种链接作为首个网址时也一样。
- **为什么改**:推送契约(#29218)规定点开推送要「按收件箱的方式解析,不能原样跳转」,而 Console
之前原样跳转,找不到路由就回首页,推送一上线(#29041)就会打不开记录。
- **风险与回滚**:只改 Console 前端路由;一个副作用是浏览器里打开一个不存在的、首段不是 Console 页面的网址(如
`/foo/bar`),现在进入默认应用并显示「页面未找到」,而不是回首页。无服务端、无数据改动,回滚即还原本 PR。
-
**先看**:`ui/apps/console/src/components/UnmatchedRoute.tsx`(兜底路由何时等应用列表、何时仍回首页)、`ui/apps/console/src/App.tsx`
的 `NativeShellRouting`(推送监听)、`ui/apps/console/src/nativeShellRoute.ts`。
- **两分钟验证**:`pnpm --dir ui exec vitest run
apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx
apps/console/src/__tests__/nativeShellRoute-29750.test.ts`,应全部通过(15 +
8)。
- **需要你做的**:无,席位落地。
## What changes
The push contract (`packages/spec/src/api/push-delivery.zod.ts`, #29218)
says a push `route` is the notification's `actionUrl`, app-relative
(`/opportunity/123`) or a Console route (`/apps/crm/opportunity/123`),
and that the tap resolves it "as the inbox does, never verbatim". The
Console's native navigate listener navigated it verbatim, so
`/opportunity/123` matched no route and the catch-all sent the person to
Home. Two legs now host it, both through the inbox's own resolver,
`resolveNotificationTarget`, with `resolveHostAppSegment`. Nothing is
re-implemented.
**Leg 1, the listener** (`NativeShellRouting` in
`ui/apps/console/src/App.tsx`). It hands `useNativeHostRouting` a
wrapped `navigate` (pure logic in the new
`ui/apps/console/src/nativeShellRoute.ts`, `nativeShellRouteTarget`):
- Inside an app, it hosts the route under the app the URL names
(`/apps/:appName/*`, already the app's route segment).
`/opportunity/123` on `/apps/crm/...` goes to
`/apps/crm/opportunity/123`; `/apps/x/y/1` passes through.
- Anywhere else (Home, or a cold start before any app opened), it hands
the route to the router as given, and leg 2 hosts it. Naming the app
there needs the app list, and the listener renders beside the routes,
outside every `MetadataProvider`. The shell's published `currentAppName`
is an app NAME; only the list turns it into its route segment. Measured
live: hosting under the name (`/apps/crm_app/...` for an app addressed
as `/apps/com.example.crm`) made the app route re-address the page four
times before it settled.
- The resolver's `null` (blank url) and `external` (scheme or `//host`)
answers navigate nothing, and the event stays cancelled. Neither is
reachable through the listener by construction: `isInAppRoute` in
`@object-ui/mobile` refuses a blank route, a scheme and a second leading
slash before the Console sees the event. Both are pinned at unit level.
**Leg 2, the catch-all** (`path="*"`, new element
`ui/apps/console/src/components/UnmatchedRoute.tsx`):
- A path whose first segment is a Console route served outside an app
(`CONSOLE_ROOT_SEGMENTS`, the table `hostInConsolePath` already keeps
and the agent-chat route-table test pins against `App.tsx`) or `apps`
keeps today's redirect to `/`.
- Any other unmatched path goes under `ProtectedRoute
requireOrganization={false}` (the same guard as `/`), waits until the
app list has settled (`isAppListConclusive`, the landing route's own
reading), and redirects to `resolveNotificationTarget(path + query +
hash, resolveHostAppSegment(apps, currentAppName))`: the app the person
last had open while it is still active, else the first active app. A
failed app list keeps the old redirect to `/`, whose landing resolver
owns the retry. A signed-out visitor goes through login and comes back
to the same path.
- This covers the three ways the mobile shell delivers a route as the
page's FIRST URL (read on objectstack-ai/mobile `main` `ddcf2d5`,
`lib/shell/console-load.ts` and `lib/shell/bridge/scripts.ts`): a
re-created WebView starts at the held route; the 8 s ready timeout on a
Console whose listener is not mounted falls back to `location.assign` of
the route; a server switch starts at its route. A warm tap is held until
`ready` and dispatched as the event, which the listener takes.
**`@object-ui/app-shell`** exports `resolveNotificationTarget`, `type
NotificationTarget` and `CONSOLE_ROOT_SEGMENTS` from the package root
(they were on `utils/` only). The package is private, so nothing is
published. `ui/packages/mobile` is unchanged.
## Why
From day one of push (#29041), every notification whose link is
app-relative (the synthesized `/OBJECT/ID` shape) would open Home
instead of its record, which is the defect objectui#5179 fixed for the
inbox doors.
## Tests
- `App.nativeShellMode-29039.test.tsx` (real `App` under the real
`BrowserRouter`, fake bridge), two new top-level `describe` blocks.
Listener: hosted under the URL's app with query and hash kept, event
cancelled; `/apps/x/y/1` passes through; with no app open, the route is
handed on and the catch-all hosts it under the default app; away from an
app, it is hosted under the app last open, by its route segment
(`_packageId`). Catch-all: first active app wins over an inactive one;
last-open app re-checked; waits while `loading`; waits while a
session-cached list is still re-read (status `loading`, measured live);
a failed list keeps `/`; controls `/dev/nope`, `/home/nope`, `/apps`
keep `/`; a real `/home` is rendered by its own route and never
rewritten.
- New `nativeShellRoute-29750.test.ts` (8 cases): the listener's pure
answer, including the unreachable `null` / `external` branches, and the
unmatched-path predicate's table.
- `App.agentChatLink-10640.test.tsx`: its `RootLandingRedirect` mock now
spreads `importOriginal` (one line, no assertion change), because its
control URL reaches the catch-all, which reads `isAppListConclusive`
from that module.
- Ablations (each with `scripts/ablation-replace.mjs`, mutation landed
by anchor count and blob, restored to the HEAD blob with `git diff HEAD`
empty): the wrapper navigating verbatim reds "an app-relative route is
hosted under the app in the URL"; the catch-all always redirecting to
`/` reds the six hosting and waiting cases, controls stay green.
## Acceptance notes
- **Behaviour change on the web (scope ruled by the seat):** an unknown
deep link whose first segment names no Console route (`/foo/bar`) now
opens `/apps/APP/foo/bar` instead of Home. Measured live on app-crm: the
app shows its own "Page not found. The URL you followed does not match
any view in this app. Go back", inside the app's chrome, no loop, no
page error.
- **Deviation from the ruled leg 1, reported to the seat before the
PR:** the listener's host hint is the URL's `:appName` only, not
`currentAppName ?? :appName`. `currentAppName` is an app name; it
reaches the catch-all instead, which re-checks it against the app list
exactly as the inbox does.
- **Observation, not filed (carrier: none):** an `/apps/NAME/...` URL
that addresses an app by its name rather than its `_packageId` segment
lands correctly but re-addresses the page several times on the way (live
history: alternating `crm_app` and `com.example.crm` replaces). Nothing
here produces that form any more.
- Cold-start reading is from the public mobile repository's `main`
(`ddcf2d5`); nothing in that repository changes.
## Verification
Final head `b1ed0110` (main merged twice; neither merge touched this
PR's files). On that head: closure rebuild (70 tasks),
`@object-ui/console` and `@object-ui/app-shell` `type-check` Done, and
the 13 console test files that mock the root landing or use
`@object-ui/mobile` plus the new unit file: 13 files, 62 tests, all
passed. `eslint` on every touched file: exit 0.
<details><summary>Derived gate sweep (dispatch-gates, 35 families, run
at 93b08ba, reconciled)</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 35 commands; every one exited 0,
including `node scripts/check-sdui-manifest.mjs`, `pnpm --dir ui exec
vitest run scripts/`, `pnpm check:type-check-debt`, `pnpm
check:test-source-alias`, `pnpm check:cross-package-test-inputs` and the
changeset gates. `dispatch-gates --ran`: 35 derived, 35 run, 0
NOT-MEASURED. The changeset and manifest gates were re-run on `b1ed0110`
before the PR opened.
</details>
<details><summary>Live run (app-crm --fresh plus the Vite Console on
this branch, Playwright, fake bridge)</summary>
- `/apps/com.example.crm/foo/bar`: the app's "Page not found" state.
- First URL `/foo/bar`: `/apps/com.example.crm/foo/bar`. First URL
`/crm_opportunity/ID`:
`/apps/com.example.crm/crm_opportunity/record/ID`, the record.
`/dev/nope`: Home, as before.
- Push tap `/crm_opportunity/ID` from a fresh Home, from inside the app,
and from Home after visiting the app: all land on
`/apps/com.example.crm/crm_opportunity/record/ID`, event cancelled.
Inside the app the history is one push plus the app's own record
replace.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (eslint on the touched
files; CI runs the full lint).
- [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
(`.changeset/29750-native-push-route-hosted.md`).
- [x] 6. `packages/spec` touched: n/a.
- [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 维护者速读 section is
the maintainer's Chinese summary).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QsR2jVL7fzYGjxa3ani6k8)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(console): a push route from the mobile shell opens the record, hosted under an app the way the inbox hosts it
7
+
8
+
Clause-②: no
9
+
Metadata-protocol: none
10
+
11
+
A push delivery carries the notification's link as `route`: app-relative (`/opportunity/123`) or already a Console route (`/apps/crm/opportunity/123`). The Console's listener for the mobile shell's navigate event moved its router to that route as written, so `/opportunity/123` matched no route and the person landed on Home instead of the record.
12
+
13
+
What changes:
14
+
15
+
- The listener resolves the route with the same resolver the inbox bell and Home use for that notification. An app-relative route is hosted under the app the person is in or last had open; an `/apps/...` route passes through unchanged.
16
+
- With no app open yet (a tap that starts the app), the route goes to the router as given, and the Console's unmatched-route page hosts it under the default app once the app list has loaded: the app the person last had open while it is still active, else the first active app. The same page handles an app-relative path that arrives as the page's first URL, which the shell sends when it re-creates its web view at a held route or when no listener answered.
17
+
- A path whose first segment names a Console route served outside an app (`/home`, `/dev`, `/studio`, ...) still redirects to `/`, as before. A path such as `/foo/bar` that names no Console route now opens `/apps/APP/foo/bar` (APP being that default app) instead of Home.
18
+
-`@object-ui/app-shell` exports `resolveNotificationTarget`, its `NotificationTarget` type and `CONSOLE_ROOT_SEGMENTS` from the package root.
0 commit comments