kruze-RFC-002 — Mosaic tile-tabs + the `koder_tiling` SDK
- Status: proposed (owner-initiated 2026-06-12;
/k-arch→/k-gopath b; both open questions resolved 2026-06-12 — OQ1 web-only Mosaic + 3-layer architecture, OQ2 split gestures; ready to implement from slice 1) - Driver:
products/horizontal/kruze(Mosaic tab); createsengines/sdk/koder_tiling; touchesproducts/dev/grid+products/dev/kterm(consumer migration — both already grew their own tile trees) - Related: kruze#245 (2-pane split view — subsumed), kruze#240 (live-browser ceiling — interacts), kruze#246 (F6 pane focus — distinct),
koder_terminal(the extraction precedent this mirrors),koder_webview(the web-tile content contract), kterm-RFC-002 (kterm's in-flight layout tree — a 3rd koder_tiling consumer) - Specs/policies consulted:
reuse-first.kmd(Q1Q2 + promotion pipeline),naming-aliases.kmd+ `registriescomponent-names.md,architecture-quality.kmd(D1/D2/D3/D10/D11),content-location.kmd`
Problem
Chrome recently shipped a 2-pane "split view"; Vivaldi has long had N-tile tiling where the user opens new tiles to the right of or below any existing tile. The owner wants something Vivaldi-like in Kruze, with a twist: a new kind of tab — call it a Mosaic — that contains one or more tiles, each tile an independent webview with its own address bar, splittable right or down, with the tile layout persisted across restarts. A normal tab stays a normal tab; a Mosaic is a distinct tab type.
Additional owner requirement (the architectural linchpin): when the Kruze side panel is open (a Kode AI chat session, an extension, or any other component) and the active tab is a Mosaic, the side-panel component's actions must always apply to the tile in focus at that moment — not to "the tab" abstractly.
We did something close in Koder Grid (a recursive tile tree of terminalsSSHbrowser panes). The question is how to build the Mosaic in Kruze correctly for the long term, not how to ship it fastest.
Decisions
D1 — The tiling primitive is extracted to a Stack SDK (koder_tiling), not reimplemented
Koder Grid already grew the exact primitive: a recursive binary tree (TileNode = TileSplit{axis, fraction, first, second} + TileLeaf{type, state}) with operations (splitNode, closeNode that promotes the surviving sibling, updateFraction, equalizeFractions) and recursive toJson/fromJson. It lives today inside Grid's app-internal package koder_grid_shared (which also carries xterm and Grid-specific deps).
Per reuse-first.kmd (Q1: it exists, but in a product-internal package, not a Stack SDK; Q2: ≥3 plausible consumers — Grid, Kruze, and any future tile UI), the tree is promoted to a new Stack SDK engines/sdk/koder_tiling, Grid is migrated to consume it, and Kruze becomes the second consumer. This mirrors the already-ratified precedent koder_terminal ("Reuse-first promotion of Grid's terminal stack. Consumers: KoverGridkterm"), which itself mirrors koder_webview.
Rejected alternatives: (a) reimplement tiling locally in Kruze → two divergent tilings, a bug fixed in one never reaches the other (violates reuse-first, D10); (c) Kruze depends directly on koder_grid_shared → couples a product to another product's internal package and pulls xterm/terminal deps Kruze does not want (D2, RFC-003 product→product violation).
The contract (D1 of architecture-quality — correct abstraction): the SDK owns topology + operations + serialization over a generic leaf payload. Today TileLeaf.type is a hardcoded Grid enum (console|ssh|rdp|vnc|remote|explorer|
browser); the SDK generalizes the leaf so each consumer defines its own leaf schema and rendering. Grid keeps its terminalsshbrowser leaves; Kruze defines a webview leaf. The SDK never knows what a tile contains — only the tree shape, the split ratios, the focus, and how to (de)serialize an opaque leaf blob.
koder_tiling (SDK) consumer-owned
───────────────────────────── ───────────────────────────
TileNode<L> (sealed) L = leaf payload type
TileSplit<L>{axis, fraction, Grid: GridTileLeaf { TileType, state }
first, second} Kruze: WebTileLeaf { url, controller? }
TileLeaf<L>{ leaf: L }
ops: split / close / updateFraction / equalize / findLeaf / focus
serialize: toJson(L→json) / fromJson(json→L) ← consumer supplies leaf codec
KoderTileView<L>(node, leafBuilder, onFocus, onSplit, onClose, onResize)D2 — Mosaic subsumes the kruze#245 split-view; one mechanism, not two
The 2-pane "split view" shipped/started in kruze#245 is a peer-overlay: a single peer tab rendered beside the primary, remapped by computeSplitLayout. A Mosaic with exactly one split is that, generalized. Keeping both is two parallel split mechanisms and two mental models (D2 coupling, D3 evolvability).
Decision: build the Mosaic as the general primitive and re-express the #245 split-view on top of it (a Mosaic with one split + the two tabs as leaves), then retire the peer-overlay path. Quality > Speed: do not keep the limited mechanism once the general one subsumes it.
D3 — "Active web context" becomes the focused tile, systemically
Today every feature reads BrowserProvider.activeTab?.controller directly (main_screen.dart: find bar, address bar, capture, zoom, devtools, reader…, ~6 sites). The owner's side-panel requirement is the forcing function for the correct abstraction: the single source of truth shifts from "active tab" to "focused tile."
Decision: introduce focusedTile / activeWebContext (= active tab → focused leaf → its webview controller) as the one getter that all consumers route through — the side panel (Kode/extensions), find bar, address bar, zoom, devtools, reader, capture. For a flat (non-Mosaic) tab it resolves trivially to that tab's single controller, so non-Mosaic behaviour is unchanged. Grid already does this (WorkspaceProvider._activeTileId). Special-casing only the side panel would let the address bar target one tile while the side panel targets another — incoherent (D1/D11).
D4 — Live-browser ceiling (#240) is Mosaic-aware
Each tile is one live CEF browser, against Kruze's hard ceiling of 25 live browsers (the kruze#240 anti-crash invariant — webview_cef destabilizes under N instances). A Mosaic of N tiles consumes N of the 25 budget.
Decision: tiles in the active Mosaic are always live; tiles in background Mosaics (and background normal tabs) hibernate via the existing snapshot/rehydrate path (kruze#207). Without this, several heavy Mosaics reproduce exactly the crash #240 fixed. The ceiling-enforcement (enforceLiveBrowserCeiling) counts tiles, not tabs.
Naming (ratified here)
No registry collision (grid is the product; mosaic/tiling are free). A clean three-level vocabulary, distinguishing the whole from the parts:
| Term | Meaning | Surface |
|---|---|---|
| Mosaic | the new tab type — a tab composed of tiles | user-facing en-US ("New Mosaic Tab", "Convert to Mosaic") |
| tile | a leaf: one independent webview + its own address bar | shared SDK vocabulary (with Grid) |
| split | an internal node joining two children, axis: right | down |
shared SDK vocabulary |
- SDK component name:
koder_tiling(engines/sdk/koder_tiling) — registeredin
component-names.md(SDK, not user-typed), kindengine. - "Mosaic" is a Kruze-internal feature concept (compound, non-product) → no
component-names.mdrow needed pernaming-aliases.kmd §1, unless it ever becomes a CLI flag (e.g.--new-mosaic).
Persistence (v2)
Kruze's session save (browser_session_v1 in SharedPreferences, JSON, 1s debounce) evolves to _v2: each BrowserTab gains an optional root: TileNode (normal tab = absent/single-leaf; Mosaic = tree) and the snapshot stores activeTileId. The SDK's toJson/fromJson (with Kruze's leaf codec) serializes the tree; v1→v2 migration is tolerant (a v1 tab loads as a single-leaf/normal tab).
Slices (tracked as the kruze umbrella + the SDK extraction ticket)
koder_tilingSDK extraction — promote Grid's tree to the SDK with ageneric leaf; migrate Grid to consume it (the SDK's own backlog #001, mirroring
koder_terminal #001). Foundation; unblocks the rest.- Mosaic model in Kruze —
BrowserTabadopts an optionalTileNode<WebTileLeaf>root;
BrowserProvidertracks_activeTileId. focusedTile/ active-web-context refactor (D3) — route all consumersthrough one getter; flat tab trivial-resolves.
- Mosaic rendering + interactions —
KoderTileViewin the content area;per-tile address bar; split right/down; close→promote-sibling; drag-resize.
- #245 subsumption (D2) — re-express split-view on the Mosaic; retire peer-overlay.
- Ceiling policy (D4) — Mosaic-aware hibernation; tile-counted ceiling.
- Persistence v2 — tree (de)serialize + v1→v2 migration.
- Side-panelKodeextensions wiring — confirm every side-panel component
targets
activeWebContext(falls out of slice 3, verified here).
Resolution (2026-06-12, owner /k-arch → /k-go)
The owner reflected that Koder Grid was always designed as the general tiled workspace (desktopwebmobile, many tile categories — terminal, web, RDP, file manager, … — in one tab), not a dev-only tool. That reframes the architecture and resolves open question 1, and surfaces a Stack-wide truth.
The 3-layer architecture (the real shape)
Tiling is not a Kruze-vs-Grid question — it is a shared substrate with per-product curation:
koder_tiling(the tree) — splitcloseresize/persist over a generic leaf.Shared by ≥3 consumers, confirmed: Grid (
tile_node.dart), kterm (049-web-splitnode-tiling-tree+ kterm-RFC-002's in-flight layout-tree serializer), and Kruze (Mosaic). Three independent, divergent tile trees exist today — exactly the duplicationreuse-firstexists to kill.- Tile-content components — each tile type is a reusable embeddable surface,
built once: terminal =
koder_terminal(already extracted); web = the browser surface (koder_webview+ the per-tile omnibox/services bundle); RDP / file manager / … later. - Products = curated tile-type menus + their own chrome/identity:
- Grid = the general workspace — offers all tile types (the owner's
original vision).
- kterm = terminal-focused — terminal tiles (+ web).
- Kruze = the browser — web tiles with full browser citizenship
(per-tile omnibox, extensions, password autofill, the Kode AI side panel acting on the focused page, browsing session/sync).
- Grid = the general workspace — offers all tile types (the owner's
OQ1 resolved — Kruze Mosaic tiles are web-only; the Mosaic stays
- Kruze Mosaic leaf = web-only (
WebTileLeaf). Heterogeneous tiles(terminalRDPfile-manager) are Grid's domain — Grid is the general workspace; a browser growing terminal tiles would blur the product boundary (D2) and dissolve into Grid. Because
koder_tiling's leaf is generic, adding a non-web leaf later is purely additive (D9 two-way door) — deferring costs zero rework, and the gate is known need (web) vs hypothetical (terminal in a browser) per the YAGNI frontier ofarchitecture-quality.kmd. - The Mosaic is worth building because *rowser citizenship is the
differentiator: tiled web pages that are first-class browser surfaces (omnibox, extensions, password, AI-on-page, session) — a value Grid's generic web tile does not have (unless Grid reimplements the browser, collapsing into Kruze). The owner's own requirements (per-tile address bar + side panel acts on the focused tile) are*browser-citizenship features.
New decision D5 — the web tile is the SAME component in Grid and Kruze
To dissolve any "Grid web tile vs Kruze Mosaic tile" duplication: the web-tile content in Grid and the Mosaic tile in Kruze MUST be the same browser-surface component (koder_webview + the per-tile omnibox/services), not two implementations. Grid hosts it as one tile type among many; Kruze hosts it as the only tile type, wrapped in full browser chrome. The products then differ only in their tile-type menu + product chrome — never in duplicated web-tile code (reuse-first). (New umbrella slice; the per-tile browser-surface bundle's exact seam vs koder_webview is the slice's first task.)
Cross-consumer coordination (time-sensitive)
A concurrent session is building kterm's layout-tree serializer right now (kterm-RFC-002 slice 1, lock advisory). A courtesy notice points it at koder_tiling so it consumes the substrate instead of cementing a 3rd divergent tree. Non-invasive (this RFC does not touch kterm code); the migration is tracked in koder_tiling#001.
OQ2 resolved — split gestures (2026-06-12, owner /k-go)
The trigger is decoupled from the operation (splitNode(tileId, axis: right|down) in koder_tiling), so multiple triggers cost nothing extra and cheap/robust ones ship first, the delightful/expensive one is additive later (D9 two-way door).
Kruze constraint (the gating fact): a tile is a CEF webview that captures the right-click (→ the page's own context menu) and most keystrokes — the exact class fixed in kruze#238. Therefore:
- keyboard split shortcuts MUST go through the global
HardwareKeyboardhandler(the #238 path), not the focus tree;
- a split context menu cannot live on the page area — it lives on the *ile
chrome*(the per-tile address bar / a tile header).
Slice-4 primary set (ship together):
- (b) Affordance button in the per-tile chrome — a split icon (⊞ right / ⊟ down)
in each tile's address bar. Always visible, discoverable, no right-click conflict. Primary for discoverability.
- (a) Keyboard —
split-right/split-downshortcuts via the global handler(free
Ctrl+Shift+<letter>pair, collision-checked against the existing keymap). Primary for power users. - (d) Command palette — "split right/down" as actions in the existing Ctrl+K
quick switcher. ~Zero cost; the "I forgot the shortcut" path.
New tile content (default): a blank new-tab page in the new tile.
Follow-up sub-slice (4b, additive — NOT slice-4):
- (c) Drag-to-edge — drag a tabtile toward a tile's rightbottom edge → drop-zone
overlay → release splits there (the Vivaldi gesture). Highest implementation cost (an overlay layer that intercepts the drag over webviews, which capture the mouse) — deferred to its own sub-slice.
- Move-an-existing-tab-into-the-split rides the same overlay machinery as (c)
(drag a tab from the strip into a tile position → "tile these two tabs").
The split-gesture interaction (keyboard + button) should live in KoderTileView (the SDK) so GridktermKruze share it; only the per-tile-omnibox button placement is Kruze-specific.
Open questions still for the owner
*(None blocking. Slice-4 detail — exact Ctrl+Shift+<letter> pair for split — is an implementation choice resolved against the live keymap when slice 4 is built.)*