Deep Links — Every View Addressable by URL
A hash-based URL grammar that opens kolu at a host, a terminal, a file in the Code tab, or settings — reusing the W5 notification cold-start machinery; view-only by law; PWA link-capture via launch_handler.
User-facing description
Any place that can render a link — the orchestrator dashboard, an issue, a Slack message, a bookmark — can point into kolu: click it and the installed PWA window comes to the front already showing the right host, terminal, file, or dialog. No PWA? The same URL opens in any browser. The full grammar (v1 ships all of it):
| URL | what you land on |
|---|---|
#/h/<host> |
the host binding switched to <host> |
#/t/<host>/<terminalId> |
that host and the terminal tile focused |
#/t/<host>/<terminalId>/code?path=<p>&line=<n> |
the terminal’s right panel on the Code tab, <p> open (optionally at line <n>) |
#/t/<host>/<terminalId>/inspector |
the terminal’s right panel on the Inspector tab |
#/settings |
the settings dialog |
<host> is the canonical encoded host key (encodeHostKey — the local host is local); <terminalId> is the terminal’s UUID.The same two facts every attention-notification click already carries (attentionNotify.ts’s AttentionClick), validated the same way: a non-canonical host key or a non-UUID id is dropped, never routed.
Behavior on the edges — always loud, never silent:
- Target gone (terminal closed, host removed): a toast names what the link pointed at and that it no longer exists; you land on the nearest surviving ancestor (the host for a dead terminal; home for a dead host).
- Unknown route (a future grammar, a typo): a toast + home. Old kolu never silently ignores a new link shape.
- Links are durable: unlike a notification click (consumed once, stripped from the URL), a deep link is a bookmark — reload and it re-navigates.
What no URL can ever do: mutate. No route creates a terminal, kills one, writes a file, or sends keys. This is a law, not a review item — the router has no mutating routes to hit, so a hostile link’s worst case is a view change.The classic drive-by risk with URL handlers is a GET that acts. Kolu’s grammar is view-only by construction; any future “action URL” proposal reopens this note’s Decision, not a code review.
Architecture-level changes
- A leaf in
packages/client, no new package. Parsing a hash and calling four existing actions hides no hard volatility — no transport, no lifetime, no reconnect. The three receptacle tests all say leaf.The nearest electricity is already built: the W5 notification seam (@kolu/surface-app/notify) owns the genuinely volatile part — service-worker delivery, multi-window dedup, cold-start handoff. Deep links deliberately do NOT ride it: its payload param is consume-once by design (a click must not re-fire on reload), which is the opposite of a bookmark. Same validation pattern, different channel. - Reuse, not invention: the route actions are the exact seams W5’s notification click already drives —
setActiveHost+store.focusTerminal— plus the right panel’s per-terminalshowCode/showInspectorand the settings dialog. The router implements the switch-then-focus ordering locally inrouteToTerminal(host first, then focus — the “id routes against the wrong host” trapuseAttentionfirst named; the router does not calluseAttentionitself, whose optimistic fire-and-forget is the wrong shape for a gone-verdict route). The router adds zero new capability, only addressability. - PWA link capture is manifest configuration, not code:
launch_handler: { client_mode: "focus-existing" }+ the app’s scope makes an in-scope https link focus the already-open PWA window, handing the URL throughlaunchQueue. Custom schemes are rejected: a PWA can only registerweb+-prefixed handlers, and the browser rewrites those to an https URL anyway — pure indirection, dead on uninstalled machines, and it loses the origin (which is the “which kolu instance” fact, load-bearing since srid runs several). - Hash, not path:
#/…needs no server routing change (kolu-server keeps serving one page), works file-identically across local/remote origins, andhashchangegives live in-app navigation for free. - One authority, stated: client state (the tile store, the panel state) is the authority for “current view” — always. The URL is a command channel into it (DL1), and under DL2 additionally a derived projection of it — never the authority. A route is a request the store may refuse (gone target), not a fact the store must mirror.
- Not
@solidjs/router(the C1 question, answered): it has hash mode and the house rule prefers libraries — but its model is route→component-tree, and kolu is a single-canvas app whose routes map to actions (focus this, open that). Adopting it would bolt a page-navigation indirection onto an app with one page. The ~50-line discriminated-union parser + zod validation is the honest fit; this paragraph is the recorded verdict. - Splits are covered by the same total action: a split pane is a sub-terminal with its own
TerminalId(one id space — the grammar needs no split syntax), andstore.focusTerminal(id)writes the panel’s remembered chrome before writing that exact terminal into the one focus fact.#/t/…/<subId>uses that verb — see DL1 step 4a and its pinned test.
Implementation details
DL1 — the router + the full menu (one PR)
- Manifest: add
launch_handler: { client_mode: "focus-existing" }to the PWA manifest (packages/surface-appowns the manifest seam — confirm at the tree; if the manifest is kolu-owned, it’s a kolu file change only). packages/client/src/deepLink.ts— the grammar as a discriminated union, parse-don’t-validate: split the hash, validate<host>withisEncodedHostKeyand<terminalId>withTerminalIdSchema(theattentionNotify.tspattern, verbatim bar the shape),?line=a positive int. OutputDeepLink | { kind: "invalid"; reason }— never a partially-valid route.- Wire three entry points in
App.tsxboot: (a) parselocation.hashonce at startup; (b)hashchangelistener; (c)window.launchQueue?.setConsumerreading the launchtargetURL(Chromium; on browsers without it the plain boot parse of the same URL covers the case — one grammar, two delivery paths, no knob). - Route through the existing actions:
#/h→setActiveHost;#/t→ switch-then-focus (reuseuseAttention’s ordering — host first, thenfocusTerminal, never the reverse);/code→ right panelshowCode+ selectpath(+ scroll tolineif the Code tab exposes line targeting — groundCodeTab’sselectedPath/viewer at build time; if line-scroll needs new Code-tab capability, ship path-only and record?line=as accepted-but-inert with a pointer here, one line — and in the parsed type,linelives inside the code variant, so line-without-path has no encoding);The right panel is per-terminal state (useRightPanel, persisted via session restore) — which is why file links anchor on a terminal:#/t/…/code?path=…, never a bare#/f/…. A file link without a terminal has no panel to open in./inspector→showInspector;#/settings→ open the dialog.- 4a — sub-terminals (splits): every routed terminal id goes through
store.focusTerminal(id); for a split, the verb writes its remembered panel chrome and the one focus fact, from which the parent tile is derived. Done-criterion: an e2e deep-links to a split terminal and asserts the pane is live — the route is not done when only main tiles focus. - 4b — the cold-boot membership race: a cold-start link fires before the target host’s terminal membership has loaded; deciding “gone” at that moment would toast every bookmark spuriously. The route defers its verdict until the host’s membership settles (bounded — then the gone-toast). The reuse seam, corrected at build-time grounding: CodeTab’s settle-then-verdict pattern (
right-panel/CodeTab.tsx— thependingOpeneffect gated onallPaths.pending()), applied to the terminal list. (The originally-nameduseAttentionsequencing was checked and is the wrong precedent — it is optimistic fire-and-forget: no defer, no gone-verdict.) “Exists” is only knowable at the post-connect store — the guarantee lives there, not at parse time.
- 4a — sub-terminals (splits): every routed terminal id goes through
- Loud edges: gone-target and invalid-route toasts (the existing toast seam); after a handled route, leave the hash in place (durability); never
history.replaceState-strip it (that is the notification param’s semantic, not ours). - Tests: parser unit table (valid × each family, invalid host/id/route/line); an e2e that boots with
#/t/<host>/<id>and asserts the tile is focused, plus a livehashchangenavigation; the negative pin — grep-shaped — that no route handler calls a mutating client verb. - Dashboard integration (rides free): the orchestrator dashboard’s terminal tags become
hrefs in this grammar.
Risks, named: launchQueue is Chromium-only — the fallback is the boot parse of the same URL (safe: same truth, not a degraded mode). Host keys in URLs expose ssh targets on screenshares — accepted for v1 (they’re already visible in the host chips); revisit under DL2’s privacy note. The ?line= fork is pinned in step 4 so the implementer never stalls on it.
DL2 — the address bar follows focus (planned, gated on srid’s go)
Write the hash on focus/panel change so the URL always deep-links to the current view — every session becomes bookmarkable and shareable mid-flow. Costs to weigh at the gate: history noise (mitigate with replaceState, no history entries), and terminal IDs + host keys permanently visible in the address bar (the screenshare consideration). One design obligation named now so DL2 can’t ship without it: the hash gains two writers (user navigation and the follow-focus writer), and a naive programmatic write re-fires hashchange → re-route → focus change → write — a feedback loop. DL2 must carry an explicit echo-suppression discipline (write via replaceState and/or a self-write marker the router skips), with the authority rule above (client state owns the view; the URL never routes back its own echo) as the invariant a test pins. Not started until ruled; DL1 is complete without it.