The Apps Home
The default user home is, top to bottom: a heading welcoming the owner by first name
("Welcome back, Jane"), the message box (start a thread), the Apps grid, then the type
sections — one per kind of content the viewer has that no app of theirs catalogs — and nothing
else (policy home-is-composer-apps-types). The welcome template is that
heading, @@("area/Composer"), then @@("area/Catalog"); there is no contributed card, no
searchable content list and no footer note.
| Band | Contents | Order |
|---|---|---|
| Apps | the APP DIRECTORY (the apps the viewer can see through access, {owner}/_Apps) — every app exactly once, as an ICON grid, grouped and rearrangeable by drag and drop; a Hidden filter lists the apps the viewer hid. The viewer's own {owner}/_App records only under the explicit Home:AppSource = Records compatibility path. Fixed: always first, below the message box |
last used |
| Type sections | one titled icon grid per node TYPE the viewer has content of — the spaces they can reach (User.SpacePaths), the things in their own home, what was shared with them (User.SharedPaths, #385) — leaving out every type an installed app CATALOGS. Spaces are one of these types, not a band of their own. Only non-empty sections render. The viewer hides, shows and reorders them |
the viewer's order, then newest change first |
The Apps band is a launcher (no search box, painted from the query rows, no per-tile hub), built by
UserActivityLayoutAreas.BuildHome → BuildAppsBand (src/MeshWeaver.Graph), pure and
unit-tested (HomeTabsTest); the reactive shell is CatalogAreaView. The type sections are their
own area on the user hub (HomeTypeSections, area HomeSections), embedded by the Catalog region
so their discovery never holds up the Apps grid. The presentation screen (#1803) drops a marked
space or grant before any section query is built. The regions that left the default
(area/HomeCards, area/Threads) stay registered, so an authored home Body that embeds them
keeps working.
Type sections (2026-11-08)
Maintainer, 2026-11-08, on a home whose band below Apps read "Spaces" over a filter "All (0)", a "Filter apps…" box, three view toggles and "No apps yet — install apps from the Store.": "can we generalize the filters below? apps is good, we want it fixed. below, we can have filters by type, customizable to the user. maybe we bring all types which are not excluded (typically the ones who are shown in some catalog app, which is clearly preferred design: you create your own catalog app. we do this for emails with inbox, for threads with threads app, … crm for clients, ...) unstructured rest gets added below only if count > 0".
The old Spaces band was an icon grid that navigated to each row's main node, which every renderer
treats as a LAUNCHER — so it carried the launcher's filter, its "Filter apps…" box, its view switch
and the launcher's empty copy, about apps, on a band about spaces. It is gone (BuildSpacesBand and
SpacesQuery were removed); spaces are a type section like any other.
| Rule | Mechanism |
|---|---|
| A type an installed app catalogs gets no section. The preferred design is a catalog app per kind of content (mail → Inbox, threads → Threads, CRM clients → the CRM app); that app is where its types are browsed | the app DECLARES them — see Declaring "my catalog app owns type X" below. AppDirectory.CatalogedTypes() is the live, process-wide union over the INSTALLED roots (a hostedIn section included — its host is then where the type is browsed) and the installed modules' AppCatalogDeclarations |
| The home's own plumbing gets no section | HomeTypeSections.NeverSectioned: User, InstalledApp (an app record), LauncherArrangement, Store/Plugin, Store/Catalog (store items are apps, shown once, on Apps). Not a list of content types — those opt out by being catalogued |
| Only non-empty sections render | the sections are DISCOVERED from rows: HomeTypeSections.Discover groups the content rows by NodeType and a type with no row has no section. Nothing at all renders when there is nothing (no title, no empty band, no Customize link); each section is ShowEmptyMessage = false, and a renderer hides a titled band whose query answers empty (MeshWeaver.Plugins#5142) |
| No wrong copy | a section is titled by its OWN type — nav.section.{type} when the catalog has it (Space → "Spaces", Markdown → "Documents"), else the type's last segment made plural (IndexSections.Label, the side index's own rule). It is a plain icon grid, never a launcher (NavigateToMainNode unset): no app filter, no view switch, no search box, no "No apps yet" |
| Content, anchored | HomeTypeSections.ContentLegs: the profile's space manifest (Space roots only), namespace:{owner} is:main is:content, and the shared paths — never a mesh-wide read. Discovery reads them projected (select:path,id,namespace,nodeType,lastModified, newest first, limit:500 per leg); each section's query is the same legs narrowed to nodeType:{type} |
| Default order | newest change first (the newest row of each type) |
The viewer's arrangement — hide, show, reorder
The owner's sections end with a quiet Customize sections link to /{owner}/CustomizeHome
(area HomeTypeSections.CustomizeArea, owner only — anyone else gets access denied): every
discovered type with its count, the shown ones in order with Hide, ↑ and ↓, then the
hidden ones with Show. The page is built from the platform's own controls (stack, label,
button, markdown link) and wired by server click actions, so the web client, Blazor and the native
app render the same editor from the same config — no renderer carries a section editor of its own.
| Piece | What it does |
|---|---|
LauncherArrangement.Sections (HomeSections) |
stored in the viewer's launcher arrangement, {user}/_Settings/Launcher → sections: { hidden: [type…], order: [type…] } — the home's other per-viewer arrangement, written the same way |
HomeSections.SetHidden / Move |
pure; a move writes the whole VISIBLE order, so it survives a later change of the defaults; past either end it is a no-op |
HomeTypeSections.Arrange |
hidden types apart; then the viewer's order; then the rest newest first. A preference for a type with no content changes nothing — it waits for content |
LauncherArrangementSource.UpdateSections(owner, change) |
a merge patch on the arrangement node when it exists, a create-if-absent (as the owner) when it does not; an "already exists" race falls back to the patch. Existence is read from the live listing, never a point read of a possibly absent node |
Hiding is a home preference and nothing else: the content stays where it is, readable and searchable.
Declaring "my catalog app owns type X"
A package whose app is the catalog of some node types declares them on its ROOT, beside app: true:
{
"nodeType": "Store/Plugin",
"content": {
"$type": "PluginContent",
"app": true,
"entryPoint": "Crm/Clients",
"catalogs": ["Crm/Client", "Crm/Contact"]
}
}
When the app browsing the types is no package root of its own — the built-in Inbox browses the Essentials mailboxes and mail; the Threads app on a device mesh runs from the AI engine without its package — the MODULE that serves the types declares it in code:
builder.AddAppCatalog(AppDirectory.InboxId, "Essentials/Mailbox", "Essentials/Email");
builder.AddAppCatalog(ThreadsLauncherApp.Id, "Thread");
(AppCatalogDeclaration, MeshWeaver.Graph/Apps, read absence-aware like DeclaredApp, re-read on
every module swap.) Either way the declaration lives with what serves the catalog, so it holds
exactly where that is installed: uninstall the app or the module and its types come back to the home
as sections. Read shape-tolerantly (AppRoot.Catalogs — a string array; blanks dropped,
entries trimmed) and compared as a SET, so an unchanged re-read is quiet.
Pinned by HomeTypeSectionsTest (excluded types never appear, empty types never appear, each section
titled by its type and free of launcher chrome, the arrangement applied, the customize page) and
HomeTypeSectionsMeshTest (the catalogued types come from installed apps only; the customization
persists in the arrangement and applies), both in MeshWeaver.Graph.Test.
Installed apps — `
Compatibility path. Since MeshWeaver#6448 the Apps grid's default source is the app directory (below). Everything in this section describes the
Home:AppSource = Recordsescape hatch, which reads these records while they still exist.
One node per icon, nodeType InstalledApp, stored at {user}/_App/{appId} as an ordinary
mesh_nodes row: deliberately not a satellite (no IsSatelliteType, no SatelliteTableMapping
entry — an unmapped _ segment routes to the partition table, and the _ prefix keeps it out of
the search context). This is the same shape as {user}/_Memex/AiSettings.
The record carries the whole tile. The node's Name/Icon are the tile's display identity and
its MainNode is the navigation target — the app's path (Plugin) or an area on the viewer's own
hub (OpenPath). All three are stamped at materialization (the Store's install flow refreshes them
on (re)install); the App content carries the wiring (Plugin/OpenPath/Order/Source). The
Apps scope renders with MeshSearchRenderMode.Icons + NavigateToMainNode: a phone-home icon
grid painted ENTIRELY from the query rows — no per-record layout area, no hub activation per tile,
no content load — and a click opens the app, never the record.
🚨 Why records, not cover nodes. The first grid queried the Store plugin COVER nodes via a top-level path alternation (
path:(Store OR Doc OR …)). A top-level path has no partition hint, so that query fanned out across EVERY partition schema — a multi-second home load. The records query names ONE partition (path:{owner}/_App scope:children) and paints instantly. When a tile needs data of the target node, copy it onto the record at write time — never join at render time.
🚨 Why no per-record tile area. The second grid rendered each record through its own
AppTilelayout area — one hub activation PER RESULT, the exact per-tile cost the record model exists to avoid. If a search result can be painted from the row, paint it from the row.
🚨 Why the nodeType is
InstalledApp, notApp. A built-in NodeType's definition node claims the TOP-LEVEL PATH of its name (AddMeshNodes), andApp/appis a name real content uses. A static claim atApprefused node creation atapp, broke path resolution forapp/…with routing loops, and refused installing any package named App (the static/durable claim collision, #1209). Pick collision-improbable names for built-in NodeTypes.
Who writes a record — the Store, not the home
The app lifecycle belongs to the STORE: it creates the record when a viewer installs an app
(stamping the real Name, Icon and MainNode), refreshes it on reinstall, and deletes it on
uninstall. The home does not participate. Core holds exactly one write, EnsureDefaultApps, and it
is a bootstrap, not a sync: when a viewer's grid is EMPTY, the platform defaults from
Admin/HomeConfig.DefaultApps are created once — otherwise a brand-new home would be a blank
screen with no icon to reach the Store by. A viewer who has any record at all never triggers it.
🚨 Why the home stopped materializing. It used to read the Store's install manifests (
{owner}/_Install/{slug}) on every render, diff them against the records, back-fill what was missing and heal names/icons from plugin cover nodes. That made every home render a write path, put the Store's model in two places, and cost a cross-schema cover query to learn things the Store already knew at install time. Rendering the home is a READ.
🚨 A bootstrap acts only on a REAL records snapshot. The records observable starts with a
nullsentinel — never[]— because "not loaded yet" and "no records" must differ: the first shipped materializer synthesized an empty start and every fresh home render fired ~20 doomedCreateNodecalls against records that already existed ("Node already exists" storms in Loki — and the home lag). A create that still loses a race is logged at Debug, not Warning.
Order — most recently used first
The grid is ordered the way a phone orders apps: what you opened last comes first, with
never-opened apps keeping the query's order behind them. That ordering is applied at paint
(MeshSearchScopeTab.SortByAccess), from the viewer's own {viewer}/_UserActivity satellites —
one cheap single-partition read whose ids are the visited path with / replaced by _, so a
tile's target maps to its access time by a forward computation and never a reverse lookup.
🚨 Why not
source:accessed. It is an INNER JOIN on the access log keyed by the row's OWN path, and it would fail twice here: it drops every never-opened app (a freshly installed app would be invisible), and it matches nothing anyway — opening an app records a visit to the APP, never to the_Apprecord that points at it. Ordering is a sort key, so it must never be expressed as a join that also filters.
The access snapshot arrives after the tiles have painted and re-orders them — deliberately the second pass: ordering never gates the first paint.
Threads is an ordinary app. The ~/Chat config entry seeds the record
{owner}/_App/Chat (name Threads, OpenPath = {owner}/Chat, MainNode = {owner}/Chat) — a
normal tile among the others, not a special dock, and it REPLACES the old open-threads band on the
default home template (the area/Threads area stays registered for authored bodies that embed it).
A ~/-prefixed DefaultApps entry always means "an area on the viewer's own hub" rather than a
node path.
Groups and manual order — drag and drop, iPhone-style
The grid is rearrangeable: a viewer drags a tile to a new position, into another group, onto the New group zone (which asks for a name), and renames a group from its header. The arrangement is per user and is stored nowhere but on the records themselves:
| Field | Meaning |
|---|---|
App.Group |
the section the tile sits in. null = never grouped (the Store stamps the package's category at install); "" = the viewer deliberately ungrouped the tile — a value no heal may overwrite. A group exists exactly while a tile carries its name; renaming a group rewrites its members. The group is a stamped COPY of the category, like the tile's name and icon, and converges the same way (Plugins, 2026-09-14): the Store's tile refresh fills a missing group AND moves a machine-stamped one when the package is re-categorized; a group the viewer chose is declared by content.customGroup: true, which every drag, drop and rename writes — declared, never inferred. |
App.Order |
the position inside the group, 1..n. 0 = never placed: such tiles paint behind the placed ones, in the grid's own most-recently-used order, so a freshly installed app lands at the end of its group the way a phone appends a new icon. |
This repo holds the CONTRACT: BuildAppsBand declares it — WithGroupBy(nameof(App.Group)) +
WithSortable() on the MeshSearchControl (and Sortable = true on its one scope). The GUI that
honours it lives with the Blazor layer in MeshWeaver.Plugins: its MeshSearchView renders a
Sortable Icons grid through the reusable SortableTileGrid component — native HTML5 drag events
handled by Blazor (dragstart / dragenter / drop), no JS interop, so there is no module
to dispose and nothing that can throw Cannot access a disposed object: JSObjectReference when a
circuit goes away mid-drag. A platform without that GUI module simply paints the plain Icons grid;
the declaration is inert until a view reads it.
The view has two LEVELS (Plugins, 2026-09-14). With a dozen groups the sectioned grid was a
wall of icons and no overview, so the band opens on categories first: the overview is one
folder tile per group (a 2×2 miniature of its first apps, its name, its count), an All folder, and
the ungrouped tiles loose beside them; a folder opens to that group's tiles alone; All is the
full sectioned grid where tiles move between groups. A path above the grid (Apps › Daily work) is
the way up, and the last place is remembered per launcher in the browser (localStorage,
keyed by the band's query so two users on one browser never share it) — read on first render,
before the tiles' query answers, so the viewer returns to where they were with no jump. Nothing of
this is stored on the mesh, and the contract above is unchanged: the folders are the same groups,
the same records, the same writes. The rules and the category taxonomy that motivated it (Daily
work, Administration) are in the Plugins doc Store/HomeSurface.
What the contract asks of any such view: a drop computes the target group's new sequence, renumbers
it 1..n, and writes only the records whose group or order changed through
IMeshNodeStreamCache.Update(recordPath, …) — the ONE mutation API, a field-level merge patch per
record, so a concurrent heal of the same record's icon or name never collides with the viewer's
arrangement. There is no layout document, no /data replica and no save button: the grid re-paints
from the same reactive query that painted it, and the records ARE the state. Because the
arrangement rides on the rows, a Sortable grid's projection must include content (the row-only
select the plain Icons grid uses would drop it).
🚨 Why not one layout node per user. A
{user}/_Homedocument holdinggroups[].apps[]would need an existence gate before every read (a point read of an absent node trips the storm-breaker), a seed for every existing user, and a reconciliation pass whenever a tile is installed or removed. The records already have all three properties: they exist exactly when the tile does, they are per user, and the Store's install/uninstall lifecycle keeps them right.
The app directory — Home:AppSource = Directory (the default)
The Apps band's default source (MeshWeaver#6448; MeshWeaver.Plugins Store/AppsOnTheInstance,
phase 1a): the apps a viewer can see, computed from the instance's installed app roots and the
access check, with no per-user records. Home:AppSource is a closed two-value deployment setting:
| Value | The Apps band reads |
|---|---|
absent, empty or whitespace-only, or Directory |
the app directory (this section) — the image default, so the record-driven fleet, which renders no Home__AppSource, runs it with the roll |
Records |
the viewer's own {user}/_App records (the section above) — an explicit escape hatch while those records still exist |
| anything else | a configuration ERROR: the home logs it at error level, naming the key, and serves the default; the page never fails on a typo |
The Store's, Edu's and the AI engine's per-user home writers read the same key with the same rule, and follow the RUNNING image's default for an absent value (MeshWeaver.Plugins#3321; the writers' switch itself landed in #3316), so the launcher and its writers switch together.
| Piece | Where | What it does |
|---|---|---|
AppDirectory |
MeshWeaver.Graph/Apps |
ONE process-wide query, namespace: is:main content.app:true partitions:all (an enumeration, id $app-roots), shared by every viewer through IMeshNodeStreamCache and read as System. Per root, CheckPermission(viewer, entryPoint, Read) — the ENTRY POINT, because a gated root is everybody's storefront; an X/area/Y entry point is probed at X. Plan coverage and audience reach it as ordinary grant/deny rows, so Read is the whole ACCESS check. Before it, a root must be a TILE on this instance (AppDirectory.IsTile): its package INSTALLED here — three more process-wide System queries ($app-install-records = Plugins/{id}; $app-sync-sources = {id}/_GitSync, the evidence for a git-provisioned package, which writes no record; $app-uninstall-requests = Admin/_PackageUninstall past phase 1), so an app uninstalled with its data retained loses its tile — and no hostedIn other than the person app (a hosted package is a section of its host, Doc/Architecture/InAppExtensions §4). Live: a grant, a revoke, an install, an uninstall or an admin change re-emits. |
| Built-ins | AppDirectory.BuiltIns |
Settings ({viewer}/Settings) and Inbox ({viewer}/Inbox) for everyone, Administration (Admin) for a global admin. Labels are catalog keys (common.settings, inbox.title, adminApp.title) — except the person app's tile once the viewer's own node is read (PersonTile, live, never blocking the list): it carries the viewer's name and picture (the user node's Name/Icon, else the settings icon) and the description launcher.personSettingsDescription in their language, so it reads as their settings rather than a generic "Settings". Beside them, the apps modules declare — Documentation, the Threads fallback (Declared apps below). |
AppDirectoryCache |
MeshWeaver.Graph/Apps |
Per viewer, the Replay(1) of the live stream. Shared by concurrent subscribers; when the last leaves, kept warm for 5 minutes, sliding (a returning subscriber resets the clock), then disposed. A faulted computation is evicted at once, never replayed. Its own scheduler; no IMemoryCache. |
AppDirectoryQueryProvider |
MeshWeaver.Graph/Apps |
Answers the RESERVED virtual namespace {user}/_Apps (path:{user}/_Apps scope:children nodeType:InstalledApp) with synthetic rows of the same shape as the _App records — content App (Plugin = app id, OpenPath = entry point, Group, Order), MainNode = entry point, name, icon and description from the root. The full query predicate applies (IMeshNodeQueryMatcher, the in-memory evaluator), and both query surfaces stay live — a revoked app leaves the snapshot surface too. Only the owner is answered and only the owner's own request may seed; the System synced-query channel is side-effect free. Nothing is stored under _Apps. |
LauncherArrangement |
{user}/_Settings/Launcher |
The viewer's placements, entries: { appId: { group, order, customGroup, hidden } } (hidden absent = visible, so every arrangement written before the flag existed shows every tile). Group comes from here, else the root's Category. Seeded ONCE from the viewer's _App records the first time the directory renders for them — the rows wait for it, one seed in flight per viewer, never repeated after success, retried after a failure, create-if-absent, as the viewer — then written only when the viewer rearranges. A rearrangement over unreadable content fails loudly rather than starting fresh. |
Declared apps — Documentation and the Threads fallback (2026-11-06)
Maintainer, 2026-11-06, on the desktop app: "can you add documentation app by default? … also the
inbox and the threads". The directory listed only package roots with app: true plus the
built-ins, and two surfaces every viewer should have are no package root: the platform
documentation (Doc is served by the Documentation module, not installed as a package) and the
viewer's Threads on an instance that runs the AI engine without the AI package (the device
mesh). HomeConfig.DefaultApps still names both, but it feeds only the Records launcher — so in
directory mode Documentation was missing on EVERY instance, and Threads on the device mesh.
A module now declares the tile for what it serves (DeclaredApp, MeshWeaver.Graph/Apps,
registered with builder.AddDeclaredApp(…)), and the directory composes the declarations with the
roots:
| Declaration | Declared by | Opens | Read probe |
|---|---|---|---|
Docs (Doc, label launcher.documentation — "Docs" / "Doku", short enough for one line of an 88px tile; top-level) |
AddDocumentation (core) |
Doc |
Doc (public-read) |
Threads (id AI, label menu.threads, top-level) |
the AI engine module (MeshWeaver.Plugins) | {user}/Chat |
none — the viewer's own partition |
| Rule | Why |
|---|---|
| The module that SERVES the target declares the tile | the tile exists exactly where its target does — a host without the module has neither; no list in core says which apps exist |
| A visible app ROOT wins over a declaration it covers — its path is the declared id, or it opens the same target | a portal shows the AI package's Threads tile (AI/AiThreads) and never the engine's fallback beside it; the fallback carries the id AI so the viewer's arrangement (group, order, hidden) is one key whichever is shown |
Coverage compares id with id and target with target, never across: a root Handbook opening Doc does not hide a declaration Doc opening Doc/Reference |
two distinct tiles stay two tiles |
A declaration never repeats a built-in (by id or target), and two declarations of one id keep the FIRST registration — decided before any probe or AdminOnly filter |
a later declaration can never stand in for a first one the viewer may not see |
Declarations are re-read on every module swap (ModuleContexts.VersionChanged) and the probes switched to the fresh ones — subscribed BEFORE the initial read, reads serialized |
an OPEN launcher follows a live module replacement, including one that lands during the first read (the signal does not replay) |
The set is read ABSENCE-AWARE ([AbsentWhenModuleGone] on DeclaredApp, InstalledModules.CurrentSet<T>), never GetServices<DeclaredApp>() |
a module withdrawn at boot leaves a root forwarder that can only throw; one throwing member failed every viewer's whole directory, Settings and Inbox included. Now its tile is simply absent |
ProbePath is the same Read check an entry point gets; null = no check |
an undetermined check FAILS the computation (evicted, recomputed) rather than latching the tile off |
{user} in OpenPath / ProbePath is the viewer |
one declaration serves every viewer |
The rows are ordinary directory rows (BuiltIn, so Plugin = ""): hideable, placeable, grouped like
any tile. Pinned by AppDirectoryPureTest (DeclaredApps_…, AnAppRoot_WinsOverTheDeclaredAppItCovers_…),
DeclaredAppDirectoryTest (a real mesh, probe and no probe) and DocumentationIsALauncherAppTest
(AddDocumentation alone puts the tile on a launcher).
🚨 The desktop app's "Documentation flashes up, then vanishes" was NOT this tile. The JS shell (MeshWeaver.Plugins
app/react-native) served same-origin by the device mesh opened the docs home (/Doc) as a provisional landing while/api/mesh/whoamiwas in flight, then switched to the viewer's home — the breadcrumb readHome › Docfor that moment. The shell now waits for the answer before painting a home (awaitsViewer); the docs home remains the landing for an anonymous viewer.
Rearranging a directory tile writes the arrangement node, never the virtual row:
LauncherArrangementPaths.Place(cache, rowPath, group, order, options). The launcher view routes a
drop on a {user}/_Apps/… row there. A move keeps the tile's hidden flag.
Top-level tiles and the Settings group
Maintainer, 2026-10-18: "let's then put the Threads app also top level" and "all the settings at
bottom under Settings". Both are DATA on the app root, read by the directory shape-tolerantly
(AppRoot), so core carries no list of which apps are top-level:
| Content field | Effect |
|---|---|
launcherGroup |
The launcher group, overriding the store category. "" is a real value: a TOP-LEVEL tile, in no folder, like the Inbox. Absent = the store category. |
launcherGroupOrder |
Where the tile's group sorts among the groups (App.GroupOrder, lower first; 0 = no preference). A group sorts by the greatest value among its tiles. |
The platform's own Settings (the person app) and Administration tiles declare the group
Settings with AppDirectory.LastGroupOrder, so one Settings group sits at the bottom of the
launcher. The Inbox stays top-level. Ungrouped tiles still paint first, then groups by GroupOrder,
then by the position of their first tile, then by name.
A declared group (AppDirectoryEntry.GroupDeclared) beats a viewer placement that is only the
legacy seed's copy of an old category (customGroup false); a group the viewer chose
(customGroup: true, written by every drag, drop and rename) always wins and then drops the declared
sort preference.
Hiding a tile (2026-10-13)
A viewer can hide an app tile they have access to, and show it again. The tile's right-click menu offers Hide from launcher; the launcher's filter row then lists the tile under Hidden (a third scope of the Apps band, directory mode only), whose menu offers Show on launcher.
| Piece | What it does |
|---|---|
LauncherEntry.Hidden |
The per-viewer flag, in the same arrangement node ({user}/_Settings/Launcher, entries.{appId}.hidden). Hiding KEEPS the entry's group/order, so showing the tile again puts it back where it was; an app never placed gets an entry that carries only the flag (its category still applies). |
LauncherArrangementPaths.SetHidden(cache, rowPath, hidden, options) |
The one write, beside Place: a merge patch on the arrangement node touching only that entry, never the virtual row. Errors when the path is not a {user}/_Apps/… row. |
App.Hidden on the rows |
AppDirectoryQueryProvider.Rows stamps the flag onto each synthetic row; the row is still emitted, the QUERY decides where it shows. |
| The band's queries | The apps scope is path:{user}/_Apps scope:children nodeType:InstalledApp -content.hidden:true sort:Name-asc; the Hidden scope (home.hiddenApps) is the same with content.hidden:true, not sortable. Both are evaluated in memory by the directory provider, where an absent flag is visible. |
🚨 Hiding is a launcher preference and nothing else. It touches no grant, no entitlement and no
install: the app stays installed and readable (the directory — which IS the access check — still
lists it), it still opens from search, a link or the Store, and nothing is uninstalled. The legacy
Home:AppSource = Records band has no Hidden scope and its menu offers no Hide.
To set it for a viewer without the GUI, write the flag onto their arrangement — e.g. for the app
rooted at Parties: {user}/_Settings/Launcher → entries.Parties.hidden = true (the entry key is
the app id: the app root's path, or a built-in's id). Pinned by AppDirectoryPureTest (the pure
arrangement and query split) and AppDirectoryTest.HidingATile_… (end to end, access untouched).
The seeders (SeedDefaultAppsLogonAction, SeedInboxAppLogonAction) still run; the directory does
not depend on them. Pinned by AppDirectoryTest / AppDirectoryPureTest (MeshWeaver.Graph.Test).
The config — Admin/HomeConfig
The admin-editable platform node (HomeConfigNodeType, public-read, live-reloading):
Style—Tabs(default) orCatalog, the escape hatch back to the legacy single flat list.DefaultApps— the entries a viewer's Apps grid is BOOTSTRAPPED with (shipped:Store,Doc,~/Chat). Read only when the grid is empty, so editing it changes what new viewers get, not what existing viewers have.Recordslauncher only — the default directory launcher ignores it; there Documentation and Threads are declared apps (above). Until a list-capable edit-form field ships it is[Browsable(false)]on the generic content editor — admins edit the node content directly.Scope,Render,DefaultSort— apply to the legacy single list (Style: Catalog).- The legacy list is ONE flat list in LOCATION order by default (policy
explorer-navigation). The default order isSortBy, an open string value (HomeSortBy:Location,LastAccessed,LastModified,Alphabetical). It ships UNSET, which meansLocation(sort:path-asc), so each space's content stays together. The olderDefaultSortenum keeps its signature and applies only whenSortByis empty and an admin moved it offLastAccessed(HomeConfig.EffectiveSort).RendershipsFlat: the Tabs home no longer fans its entries out into per-type sections, which put every top-level entry into one "Space (N)" group;Render: Groupedstays the admin's explicit choice. Location order stays in the Sort control either way.
The dedup rule: an app is represented exactly once. The type sections' invitation leg
excludes -nodeType:Store/Plugin -nodeType:Store/Catalog and -nodeType:User — a grant that resolves to another user's home partition must not list that
person's space as shared content. The Store itself is an app (a config default); only the
anonymous header keeps a Store link.
The platform's own partitions are not spaces. A space is a place people work together. The
partitions the platform itself runs on carry a Space root for plumbing reasons, so they would
otherwise sit beside the real workspaces: WellKnownPartitions.Platform (Admin, Auth, …) and
WellKnownPartitions.Fleet (Doc, Ops, Plugins, Agent, Skill, Provider, Governance, Store, …). The
home lists neither as a space, and a grant on one of them (every global admin holds some) is not
listed as shared with the viewer. Each is reached through its app: Documentation, Threads, Settings,
Administration. The filter is RefreshSpacePathsLogonAction.IsPlatformOwnedPartition, applied when
the viewer's space and shared manifests are written at logon.
Sort semantics — why last-accessed is a two-leg union
source:accessed is an INNER join on the caller's access log: a pure accessed-sorted query
HIDES anything never opened. The Shared-with-me band's query is therefore a newline-joined,
path-keyed UNION — the accessed-ranked leg first, a plain leg as completeness fallback. On the Apps
scope, source:accessed is meaningless for records, so that option sorts by last modified instead.
The Threads app — the chat surface with its native side menu
/{user}/Chat (the ChatArea) is ONE ThreadChatControl in node-less compact mode with
ShowThreadNav on (BuildThreadsApp / ThreadsAppComposer): a centered start-a-conversation
hero above the compact composer, beside the collapsible THREADS side menu — the agentic-app
default view, rendered again on every thread's full page so the navigation never collapses.
The side menu is NATIVE to the Blazor chat view and bound through the synced GetQuery cache
(ThreadQueries.MyOpenThreads — full thread nodes, content included): New chat, a filter box
("find the thread which does XYZ"; the global mesh search covers semantic lookups), the thread's
hierarchy (ancestors · current · delegation sub-threads), and the viewer's open threads — each
row with its LIVE activity (ThreadActivity: evaluating while a round runs, a queued
badge when input waits in PendingUserMessages, awaiting input at rest) and an ✕ that closes
through the canonical MarkThreadDone. The menu collapses to a slim edge toggle — the same
affordance as the multi-part doc-index rail.
🚨 Never render a search result through an item area on a foreign hub. The first Threads app
was an MDI shell whose rail rows delegated to a RailItem area on each THREAD's own hub — one
hub activation PER ROW, resolving an area on a hub the page does not own. That shape passes in a
monolith and fails in the distributed portal ("area cannot be found" — the AppTile failure), which
is why the shell, ThreadRailItem, and the RailItem area were deleted. The menu paints from the
query snapshot; nothing on it resolves a foreign area.
🚨 Never stretch the compact composer. The .no-messages CSS fill chain is scoped
:not(.compact-mode): unscoped, any page that gives the container a definite height stretched
the compact input into a viewport-height empty box (the old shell's "giant gray void").
Closing a thread goes through the canonical MarkThreadDone (the row's ✕ or the thread page's
Mark Done); the menu's query excludes content.status:Done, so a closed thread leaves the list
reactively while staying searchable and reopenable. Closing never deletes.
Presentation mode (#1803)
The Pinned scope query and the Shared-with-me band interpolate viewer paths, so the screen filters
them BEFORE the query is built. The Apps records query is generic — no app path can reach a query
string or URL — so the screen filters at paint, keyed by each tile's navigation target (the
record's MainNode): a marked app's tile is simply not drawn. The type sections drop a marked space
or grant before their legs are built.
See also
- Configurable Home & Space Pages — the
Body+@@-region model the home is built on - Mesh Search & Catalogs — the search control behind every scope
- Thread Operations — the canonical thread mutation surface (
MarkThreadDoneet al.) - Localization — why every tab label and sort option resolves off
AccessContext.Locale