dock: the shell keeps its own items, sidecars contribute theirs

ALL_DOCK_ITEMS was a hardcoded list of everything, so a fresh machine offered Photos, Jellyfin,
Transmission and the rest — each leading to a screen reporting itself unavailable — and adding a sidecar
meant editing the shell. Neither survives sidecars shipping from their own repositories.

Split in two. CORE_DOCK_ITEMS is the baseline that exists on every install: chat, files, terminal, the
app's own screens, and Gitea, which is in the light profile because it fronts a remote instance.
Everything else is derived from installed sidecars' UI manifests, delivered with /capabilities.

Sent with the capability answer rather than fetched separately so the dock has ONE source. Two requests
means two moments, and a dock rendered between them shows a tile for something uninstalled or nothing
for something installed. Filtered by capability server-side too: a member is not handed the manifest of
a feature they cannot use, because "hidden in the client" is the kind of privacy that lasts until
someone opens the network tab.

Verified live. The owner — who bypasses every permission check — does not bypass this: /photos is absent
from routes and present in deniedRoutes because Photos is not installed. Flipping a row's `enabled`
makes its tile leave and return with no process touched.

Two things fell out. A manifest can declare extraTiles, because CalDAV is one sidecar presenting as
Calendar AND Contacts, and collapsing them to keep the model tidy would make the app worse. And
DEFAULT_DOCK_PATHS no longer pins /music: useDock drops a path with nothing behind it, so the default
dock came up a tile short on any machine where Music was never installed — a default that references an
optional feature is how an app looks subtly wrong on a fresh install for no stated reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 18:32:16 +00:00
co-authored by Claude Opus 5
parent 209e916343
commit 82e9fdacc2
7 changed files with 160 additions and 36 deletions
@@ -27,6 +27,29 @@ export type SelfCapabilities = {
* guard that cannot tell those apart either blanks the app or guards nothing.
*/
deniedRoutes: string[];
/** Capabilities the account holds whose sidecar is not installed, or is installed but disabled. */
unavailable?: string[];
/**
* Dock tiles and routes of the sidecars actually installed on this server.
*
* The dock is the baseline shell plus these. A feature appears when it is installed and leaves when it
* is removed, with nothing in the shell to edit — which is what lets a sidecar ship from its own
* repository and still show up.
*/
plugins?: PluginManifest[];
};
/** What an installed sidecar says about how it should appear. Mirrors UiManifest on the server. */
export type PluginManifest = {
sidecarId: string;
capability: string | null;
name: string;
icon?: string;
image?: string;
color: string;
rootRoute: string;
routes: string[];
extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>;
};
export const CAPABILITIES_QUERY_KEY = ['self-capabilities'];
@@ -80,6 +103,9 @@ export function useCapabilities() {
isOwner: data?.isOwner ?? false,
capabilities: held,
routes: data?.routes ?? [],
// Empty rather than undefined when the request has not landed or failed: the dock then renders its
// baseline, which is the honest "we do not know yet" — not an empty app.
plugins: data?.plugins ?? [],
isLoading,
isError,
can,