shell: navigation to unknown island silently redirects (no 404 / not-found) #109
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_os#109
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Direct navigation to a URL whose island id is not registered in this build silently rewrites the URL to the previously open island. No 404, no toast, no console error.
Repro
Result after step 2: address bar reads
…/space/default/messagingagain — silent rewrite. Same for any other unknown id.Why it matters
Users (and agents) following a stale or wrong URL get the impression nothing happened. Hard to debug — there is no message anywhere telling you the island id was unknown.
Suggested fix
Render an explicit 'Island "" not installed' empty state in the window for unknown ids instead of silently rerouting. Same affordance the Books / Voice / Whiteboard placeholders already use for not-installed services.
Found via QA session 2026-04-29 (
qa/messaging_2026-04-29/FINDINGS.md).Implementation Spec for Issue #109
Objective
When a URL references an island id that has no metadata in the compiled-in registry, render an explicit "Island '' not installed" placeholder in a window for that id instead of silently dropping it and letting the URL get rewritten to the previously open island.
Investigation findings (the why)
The shell's URL → window translation lives in two places in
crates/hero_os_app/src/main.rs:main.rs:786-886: it parses the URL, then loopsfor (i, app_id) in route.apps.iter().enumerate() { if let Some(meta) = reg.get(app_id) { … new_windows.push(WindowState::new(…)) } else { tracing::warn!("URL references unknown island: {}", app_id); } }(lines 799-826). When every app id is unknown,new_windowsstays empty, theif !new_windows.is_empty()guard at line 827 fails, control falls through to the localStorage branch, and the previously persisted desktop is restored.main.rs:986-1037: same pattern —else if let Some(meta) = reg.get(app_id)at line 1009 silently skips unknown ids when rebuildingnew_windows.The "silent rewrite" the issue describes is then produced by a third effect — the window→URL sync at
main.rs:910-954: it diffswindows.read()against the current URL; whenwindowsdoesn't contain the unknown id (because step 1/2 dropped it), it callsrouting::replace_url(&route)with the actual window list, overwriting…/space/default/roomwith…/space/default/messaging.The "knownness" comes from
IslandRegistryincrates/hero_os_app/src/registry.rs:metadatafor each island is gated by#[cfg(feature = "island-<id>")](e.g.room_metadataat line 43), so an island id that's listed inhero_os_web/src/types.rs(e.g.island("room", …)at line 40) will not be in the runtime registry when the corresponding crate isn't compiled in.The closest existing visual pattern is in
crates/hero_os_app/src/island_content.rs:div { class: "island-placeholder", "Settings not available" }.web-platformunknown-id armdiv { class: "island-placeholder", "Island not available: {props.island_id}" }.The fix is to drop the silent
elseskip on unknown ids in both main.rs spots, instead inserting aWindowStatewhoseisland_idkeeps the unknown id verbatim so theIslandContentmatch falls to its existing_/otherarm and the window→URL sync sees the id inwindowsand stops rewriting.Requirements
island-placeholderdiv pattern fromisland_content.rs:312-318.Files to Modify/Create
crates/hero_os_app/src/main.rs— replace silentelse { warn }and silentelse if … skipwithWindowStatecreation that uses placeholder defaults for unknown ids (in both the first-load effect at lines 799-826 and the popstate handler at lines 1001-1024).crates/hero_os_app/src/island_content.rs— replace theweb-platformotherfall-through (which currently delegates toDynamicIslandLoader) with a static "Island '' not installed" placeholder for the unknown-id case. Reuse the sameisland-placeholderdiv pattern used by the settings fallback at line 312-318.crates/hero_os_app/src/routing.rs— add 1-2 unit tests covering parsing of an unknown id (the parser itself already does the right thing; the tests pin the contract that an unknown id stays inroute.appsrather than being filtered).Implementation Plan
Step 1: Stop dropping unknown ids on first URL load
Files:
crates/hero_os_app/src/main.rsif let Some(meta) = reg.get(app_id) { … } else { tracing::warn!(…) }block with an unconditional push: whenreg.get(app_id)isSome, use itsdefault_size,name,default_view()(current behaviour). When it isNone, push aWindowState::newwith:id = format!("{}-{}", app_id, i + 1)(same scheme),island_id = app_id.clone()(preserve id verbatim — this is what the URL-sync effect compares against),title = app_id.clone()(the placeholder body shows the id explicitly; title can also just beapp_idso the WindowHeader has something to render),width = 600.0, height = 400.0(matchesIslandMetadataBuilder's default, seehero_archipelagos/core/src/metadata.rs:410),default_view = String::new().tracing::warn!("URL references unknown island: {}", app_id)so we still get a console signal alongside the new visual.zregardless (move thez += 1outside theif).Dependencies: none.
Step 2: Stop dropping unknown ids on popstate
Files:
crates/hero_os_app/src/main.rsexistingbranch (line 1003), keepelse if let Some(meta) = reg.get(app_id)for known ids, and add a finalelsebranch that pushes aWindowState::newfor the unknown id with the same placeholder defaults from Step 1. Title / size match Step 1.format!("{}-pop-{}", app_id, i)id naming so the popstate-created windows are clearly distinguishable.Dependencies: Step 1 (same shape).
Step 3: Render an explicit "not installed" placeholder for unknown ids
Files:
crates/hero_os_app/src/island_content.rsweb-platformotherarm (currently delegating toDynamicIslandLoader) with a static "not installed" placeholder. Inspection of the codebase showsDynamicIslandLoaderonly fires for the unknown-id case (every known id has its own arm), and its only output is either a 404-Failed message or a JS-loaded custom element that has never been observed to succeed in default web builds (no/hero_os/ui/islands/<id>/...jsfiles are produced by the defaultwebbuild). This drops a code path that currently produces "Failed to load island: room" after a delay and replaces it with an immediate, accurate "Island 'room' not installed" message.web-platformotherarm becomes: And the non-web-platformarm at line 609-614 already says"Island not available: {props.island_id}"— update its copy to match for consistency:"Island \"{props.island_id}\" not installed".dynamic_loader.rsis not referenced elsewhere; if it is now dead, leave the file in place but add a#[allow(dead_code)]at the module level (do not delete — the repo intentionally keeps the dynamic-loader machinery for future runtime island distribution; deletion is out of scope for this issue).Dependencies: none. Independent from Steps 1-2 but must land together for the URL-bar fix to also produce a visible placeholder.
Step 4: Tests
Files:
crates/hero_os_app/src/routing.rstest_parse_unknown_island_id_kept_in_apps:parse_url("/space/default/room", "")→route.apps == vec!["room"]regardless of whetherroomis "real". This test belongs in routing.rs because that module is the seam — it documents that the parser is registry-agnostic.test_parse_mixed_known_and_unknown:parse_url("/space/default/messaging+room", "")→route.apps == vec!["messaging", "room"].Dependencies: none.
Acceptance Criteria
/hero_os/ui/space/default/room(or any unknown id) renders an explicit "Island '' not installed" empty state in the window.cargo check -p hero_os_appandcargo test -p hero_os_appstill pass (baseline confirmed clean).tracing::warn!("URL references unknown island: {}", app_id)log still fires so the console signal mentioned in the issue body is produced.routing.rsdocumenting that unknown ids stay inroute.apps.Notes
/space/default/messaging+roomopens two windows: a working messaging window and a placeholder room window. This falls out naturally from the per-id loop change in Step 1; verify in the smoke test.save_desktop_state(main.rs:889-902) will persist the placeholder window into localStorage. That means after a refresh the placeholder is restored even without the URL hint — desirable, since the user explicitly opened it and the URL still reflects it. If the user closes the placeholder window, normal close handling (controller.rs:152-166) cleans it up via thepush_url_statecall.app_idas the title is acceptable; the placeholder body re-shows the id so the user can copy-paste it. If the team wants a prettier title, capitalising the first letter is a one-liner — but per the "no new abstractions" constraint,app_idverbatim is fine.parse_urlwould couple the routing module to the registry signal (currently it has zero crate-internal dependencies — it's a pure function with#[cfg]shims). The existing layering is correct: parse → consult registry at the use site → render. The fix preserves that.dynamic_loader.rs: the runtime dynamic-load story is a documented future direction in the file's docstring; the bug fix only needs to bypass it for the unknown-id case. Leaving it in place keeps the spec narrowly scoped.#[cfg(not(feature = "web-platform"))]): the existing arm atisland_content.rs:609-614already shows a placeholder string. Step 3 just normalises the wording. Mobile does not have the silent-rewrite bug because itsinitial_contextis hard-coded to"geomind"(main.rs:247) and there is nopopstate/URL-sync effect block (#[cfg(feature = "web-platform")]gate inmain.rs:300), so Steps 1-2 are also web-only by construction (they live inside the same#[cfg(feature = "web-platform")]block — verify the modifications stay inside it).Test Results
Static checks
cargo check -p hero_os_app— clean.make check(workspace, all crates) — clean, finished in 25.18s.cargo test -p hero_os_app— 42 passed, 0 failed, 0 ignored. Includes the two new parser tests:routing::tests::test_parse_unknown_island_id_kept_in_apps— passrouting::tests::test_parse_mixed_known_and_unknown— passEnd-to-end browser smoke (via Hero Browser MCP)
Stopped + rebuilt + reinstalled hero_os via
make run, then drove a headless Chromium against the live UI:http://127.0.0.1:9988/hero_os/ui/space/default/messaging— messaging island loads, baseline established.http://127.0.0.1:9988/hero_os/ui/space/default/room— found thatroomIS compiled in this build (the Conference island opened). The issue body's specific repro id is no longer unknown ondevelopmenthead.http://127.0.0.1:9988/hero_os/ui/space/default/nonexistent_island_xyz(a guaranteed-unknown id):nonexistent_island_xyz✓Island "nonexistent_island_xyz" not installedfollowed byThis island is not part of the current Hero OS build.✓…/room(Conference window) — works ✓…/nonexistent_island_xyz— placeholder window renders again, URL preserved ✓All acceptance criteria from the spec are met:
cargo checkandcargo testpassNote on the issue's original repro id
The issue body uses
roomas the unknown-id example. Between the issue being filed (2026-04-29) and this fix (2026-04-30),room(Conference / livekit) is now compiled in by default in the workspace build, so it's no longer a useful repro id. The behaviour the fix addresses is identical — verified withnonexistent_island_xyzinstead. Anything that's not in the runtime registry hits the new placeholder.Implementation Summary
Changes
crates/hero_os_app/src/main.rs— in BOTH the first-load URL effect and the popstate listener, theelsebranch that previously logged a warning and silently dropped unknown island ids now also pushes aWindowStatefor the unknown id (id verbatim, title = id, default 600x400 size). Window→URL sync therefore sees the id in the windows list and stops rewriting the URL.crates/hero_os_app/src/island_content.rs— replaced theweb-platformotherarm (which delegated toDynamicIslandLoaderand produced a misleading "Failed to load" after a delay) with a staticisland-placeholderdiv that rendersIsland "<id>" not installedplus a clarifying detail line. Updated thenot(web-platform)arm copy to match for consistency.crates/hero_os_app/src/dynamic_loader.rs— added module-level#![allow(dead_code)]sinceDynamicIslandLoaderis no longer wired into any caller. Module is preserved per the spec for future runtime island distribution.crates/hero_os_app/src/routing.rs— added two pure-parser unit tests (test_parse_unknown_island_id_kept_in_apps,test_parse_mixed_known_and_unknown) that pin the contract thatparse_urlis registry-agnostic and keeps unknown ids inroute.apps.Why
The shell's URL → window translation in two effects (first-load + popstate) was using
if let Some(meta) = reg.get(app_id) { … } else { drop }. When a URL referenced an island id not in the runtime registry (e.g. a feature-gated island that isn't compiled in this build), the resultingwindowslist was empty for that id, then the window→URL sync effect rewrote the URL to match the actual (previously open) window list — producing the silent rewrite the issue reports. Pushing aWindowStatefor the unknown id keeps the URL stable and lets the existing_arm inIslandContentrender an explicit placeholder.Test results
See test results comment.
cargo check,make check,cargo test— all clean.Caveats
roomis now compiled in by default ondevelopmenthead (the Conference island ships in the build), so it's no longer a useful repro id. The fix was verified withnonexistent_island_xyzinstead — the underlying behaviour is identical for any id not in the runtime registry.localStoragefor the unknown-id placeholder window. If the user closes that window, normal close handling cleans it up. If they refresh while it's open, it's restored from localStorage — desirable, since the URL still references it.dynamic_loader.rsis now dead at the crate level. Left in place per the spec; can be removed in a follow-up if/when the team decides to drop the runtime-load story entirely.Pull request opened: #122
This PR implements the placeholder-window approach from the spec — unknown island ids no longer trigger the silent URL rewrite; instead a window opens with the id verbatim and renders an explicit
Island "<id>" not installedplaceholder. URL is preserved on direct navigation and on browser back/forward.