…console-mount resolver
`AiChatPage` read the injected `<base href>` itself and assembled
`${origin}${base}/s` — a third independent copy of the mount resolution
`resolveConsoleUrl` centralizes (objectui#4472 deleted the other two).
Correct today for every shape the console is served in, and exactly the
shape that drifts once mount semantics change.
The `/s` resolution moves next to its siblings as `resolvePublicShareBase()`,
which keeps the no-DOM guard a bare `resolveConsoleUrl('s')` would drop.
Pins drive a real injected `<base>` element rather than `vi.stubEnv('BASE_URL')`,
which is inlined at transform time and would test nothing production reads.
Fixes #4482
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
Fixes #4482
AiChatPagebuiltpublicShareBaseitself — read the injected<base href>, take its pathname, trim trailing slashes, concatenate${origin}${base}/s. That was the third independent implementation of the console-mount resolutionresolveConsoleUrlcentralizes; #4472 had just deleted the other two on the one-resolver rule, and this was the surviving sibling.The
/sresolution now lives beside its three siblings inresolveHomeUrl.tsasresolvePublicShareBase(), which delegates toresolveConsoleUrl('s')and keeps the one thing a bare call would drop: with no DOM it returnsundefinedrather than a URL built from an origin that does not exist, soShareDialogapplies its own fallback.Parameter shape, measured at fix time (the card asked for this, not assumed)
Old builder vs
resolveConsoleUrl('s')over the base-href matrix:<base href>resolveConsoleUrl('s')/_console/https://host/_console/shttps://host/_console/s/https://host/shttps://host/s<base>taghttps://host/shttps://host/s./https://host/shttps://host/s/a/b/https://host/a/b/shttps://host/a/b/s/_console(no trailing slash)https://host/_console/shttps://host/shttps://cdn.example/_console/https://host/_console/shttps://cdn.example/_console/sNo currently-reachable input changes. The only injector in the platform is
packages/cli/src/utils/console.ts, which writes`<base href="${CONSOLE_PATH}/">`— always trailing-slashed — and objectui ships no<base>tag in any HTML of its own (grepoverapps/**,packages/**). The remaining reachable shapes are "no<base>tag" (standalone /os dev) and/(root mount), both identical either way.The two divergent shapes are unreachable, and in both the shared resolver follows the HTML base-URL semantics the router basename and the SPA's own relative asset URLs already live by, while the deleted copy treated the href as a directory prefix and forced the document origin. A third, same-family difference: at origin
"null"(afile://or sandboxed-iframe document) the shared resolver throws where the copy produced the garbage stringnull/s— the console's other full-page navigation helpers (resolveRootUrlinWorkspaceSwitcher/OrganizationsPage) already throw there, so this aligns rather than regresses. Nothing here is changed inconsoleRoot(): that would move/homeand org-switch navigation too, which is outside this card. Filed as #5678 instead.Tests
resolvePublicShareBase.browser.test.tsx— six behavioural cases driven by a real injected<base>element, nevervi.stubEnv('BASE_URL', …). That trap is recorded on the card: Vite inlinesimport.meta.envat transform time, so a test steering it is permanently green while testing nothing the production path reads. Plus three structural cases: no app-shell file outside the resolver reads the<base>tag, the scan is reading a real tree (over 200 files), and the scan's regex actually matches a hand-rolled read.Since the card is explicitly "correct today", a green suite proves nothing on its own — the discriminating evidence is the ablation. Each leg's direction was predicted before running; each mutation was confirmed on disk (anchor gone, injected text present, counted with
grep -o | wc -l) by a helper that raises on a zero match, restored under atrap … EXIT INT TERM, and the restore verified by sha256.consoleRoot()ignores the<base>tagTypeError: Cannot read properties of undefined (reading 'location')AiChatPageexpected [ 'console/ai/AiChatPage.tsx' ] to deeply equal []<base>fallback →document.baseURIexpected 'http://localhost:3000/ai/support_agen…' to be 'http://localhost:3000/s'Leg A missed its prediction and the miss is recorded in the test header rather than papered over. The two mount cases reddened as predicted (
/_console/s→/s) and the root-mount, no-<base>, deep-route and SSR cases stayed green — that split is the point, since it shows the mount cases measure the mechanism and not a constant. The unpredicted third failure is the scan self-check, which asserts the resolver still contains a<base>read; leg A deletes exactly that. The coupling is correct but real, so the header now says so.One void leg, disclosed: leg B's first run was voided by my own confirmation step, not by the mutation.
grep -o -Fwith a multi-line anchor matches each line separately, and the injected line still contained the second anchor line as a substring, so "anchor occurrences after" read 1 instead of 0. The mutation had in fact been applied and was restored unread; the leg was re-run with a single-line anchor and the count then meant what it said.No rebuild leg was needed and that is measured, not assumed: the pin imports the subject through a relative source specifier (
from '../resolveHomeUrl'), andpackages/app-shell/distdoes not exist in this worktree at all, so the green run cannot have been reading a stale build.Local gates — all at
867124423, the final commitpnpm --filter '@object-ui/app-shell^...' build(upstream closure)BUILD_EXIT=0pnpm --filter '@object-ui/app-shell' type-checkTYPECHECK_EXIT=0(tsc --noEmit && tsc -p tsconfig.test.json)pnpm exec vitest run packages/app-shell/Test Files 493 passed (493)·Tests 4843 passed, 1 skipped (4844)check:control-bytes✅ check-control-bytes: OK (scanned 4724 tracked text file(s); skipped 85 binary)check:esm-specifiersno un-ledgered package emits an extensionless relative specifiercheck:self-import✅ No package names itself inside its own src/ESLint was narrowed to the changed files, and the narrowing is a measurement: the population comes from eslint's own config (
files: ['**/*.{ts,tsx}'],eslint.config.js:28), which excludes the.mdchangeset;--format jsonreported 3 file entries for the 3 lintable changed files, none of them ignored; andeslint.config.jssets noparserOptions.project/projectService, so linting is not type-aware and this diff cannot move the verdict of any file it does not touch. Result: 0 errors, exit 0.Worth flagging in the other direction: the warning count on
AiChatPage.tsxgoes 26 → 28. The two extra diagnostics arereact-hooks/refsat line 1004 (thestaleNewTargetRefrender-phase write, deliberate and documented in place) andreact-hooks/set-state-in-effectat line 1082 (an existing title-hint effect) — both pre-existing code I did not author, of rule classes already firing 9× and 2× in this file. Shrinking theuseMemobody changed what the compiler-based rules could analyse before bailing out, surfacing two more instances of the same patterns..github/workflows/lint.ymldeliberately sets no--max-warnings, so nothing fails on it.Note for the next holder of the verify lock
The app-shell suite alone runs 686s; this PR's heavy leg held the shared lock 927s with three sibling agents queued behind it. Saying so on the card as the lock's own advice asks: for a change this narrow, the resolver directory would have been the honest scope, and the full package suite was wider than the diff warranted.
Generated by Claude Code
Generated by Claude Code