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
@@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
@@ -14,12 +14,17 @@ type DashboardLayoutProps = {
children?: React.ReactNode;
};
export function DashboardLayout({ children }: DashboardLayoutProps) {
const { canVisit } = useCapabilities();
const { canVisit, plugins } = useCapabilities();
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
// either way — every one of these routes is refused server-side too — but an app that offers a door it
// will then slam is worse than one that never showed it.
const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]);
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
const permitted = useMemo(
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
[canVisit, plugins],
);
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
const isTouch = useIsTouch();
const { pathname } = useLocation();
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev';
import type { PluginManifest } from 'hooks/useCapabilities';
export type DockItem = {
label: string;
@@ -149,21 +151,21 @@ import {
GitBranch,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
/**
* The dock items that belong to the SHELL — present on every install, with no sidecar behind them.
*
* Everything else is contributed by an installed sidecar's UI manifest and arrives from
* `/capabilities` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be
* installed and uninstalled must not be hardcoded here, or the dock would list things this server does
* not have and the shell would need editing every time a sidecar is added.
*
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
* the light profile because it fronts a remote instance and installs nothing locally.
*/
export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
{ label: 'Calendar', to: '/calendar', icon: CalendarDays, color: '#3b82f6' },
{ label: 'Contacts', to: '/contacts', icon: Contact, color: '#0ea5e9' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
{ label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' },
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
{ label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' },
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
@@ -171,10 +173,40 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
];
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/dashboards', '/chat'];
/**
* Turn the manifests of installed sidecars into dock tiles.
*
* `resolveIcon` maps a NAME to a glyph, which is why manifests carry names rather than imports — they
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
*/
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
return plugins.flatMap((plugin) => {
const tile = (t: { name: string; icon?: string; image?: string; color: string; route: string }): DockItem => ({
label: t.name,
to: t.route,
color: t.color,
...(t.image ? { image: t.image } : { icon: resolveIcon(t.icon ?? 'Box') }),
});
return [
tile({ name: plugin.name, icon: plugin.icon, image: plugin.image, color: plugin.color, route: plugin.rootRoute }),
...(plugin.extraTiles ?? []).map(tile),
];
});
}
/**
* What is pinned before anyone has chosen. Deliberately drawn only from CORE_DOCK_ITEMS.
*
* This used to pin `/music`, which is now an installable sidecar. `useDock` drops a path with no item
* behind it, so nothing breaks — the default dock just quietly comes up one tile short on a machine
* where Music was never installed. Defaults that reference optional features are how an app ends up
* looking subtly wrong on a fresh install for no stated reason.
*/
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat'];
@@ -1,8 +1,9 @@
import { useState, useCallback, type DragEvent } from 'react';
import { useMemo, useState, useCallback, type DragEvent } from 'react';
import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev';
import { ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
import { useCapabilities } from 'hooks/useCapabilities';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
type DockPillProps = {
label: string;
@@ -18,14 +19,32 @@ type DockPillProps = {
};
const DockPill = ({
label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill,
label,
path,
color,
visible,
onAction,
onDragStart,
onDropOnPill,
dropIndicator,
onDragOverPill,
onDragLeavePill,
}: DockPillProps) => (
<span
draggable
onDragStart={(ev) => onDragStart(ev, path)}
onDragOver={onDragOverPill}
onDragLeave={onDragLeavePill}
onDrop={onDropOnPill ? (ev) => { ev.preventDefault(); ev.stopPropagation(); const p = ev.dataTransfer.getData('text/plain'); if (p) onDropOnPill(p); } : undefined}
onDrop={
onDropOnPill
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const p = ev.dataTransfer.getData('text/plain');
if (p) onDropOnPill(p);
}
: undefined
}
className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`}
>
{dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
@@ -69,7 +88,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
return (
<div className="grid gap-1.5">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span>
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
{label}
</span>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -83,7 +104,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
};
export const DockSettings = () => {
const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken.
const { plugins } = useCapabilities();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS);
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
const visiblePaths = new Set(items.map((i) => i.to));
@@ -135,7 +160,10 @@ export const DockSettings = () => {
const handlePillDrop = useCallback(
(draggedPath: string, targetPath: string) => {
if (draggedPath === targetPath) { setDropTarget(null); return; }
if (draggedPath === targetPath) {
setDropTarget(null);
return;
}
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
insertAt(draggedPath, targetPath, side);
},
@@ -163,7 +191,11 @@ export const DockSettings = () => {
return (
<div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}>
{items.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag items here to show in dock</span>}
{items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock
</span>
)}
{items.map((item) => (
<DockPill
key={item.to}
@@ -182,7 +214,9 @@ export const DockSettings = () => {
</DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}>
{hiddenItems.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>}
{hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
)}
{hiddenItems.map((item) => (
<DockPill
key={item.to}
@@ -196,12 +230,7 @@ export const DockSettings = () => {
))}
</DropZone>
<Button
type="button"
variant="outline"
onClick={reset}
className="w-full h-9 text-sm cursor-pointer"
>
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults
</Button>
+9 -1
View File
@@ -32,7 +32,7 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
// What EXISTS on this server, which is a different question from what this account may use. A
// capability the owner holds unconditionally still means nothing if its sidecar was never installed,
// and the owner is as subject to that as a member — see app-store/availability.ts.
const { unavailable } = await capabilityAvailability();
const { unavailable, manifests } = await capabilityAvailability();
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to
// remember to special-case. One shape for both audiences means one code path in the UI.
@@ -50,6 +50,14 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
capabilities: held,
/** Capabilities the account holds whose sidecar is not installed or is disabled. */
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
/**
* Dock tiles and routes belonging to installed sidecars the account may reach.
*
* Filtered by capability here rather than in the client: a member must not be handed the manifest
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
* privacy that lasts until someone opens the network tab.
*/
plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)),
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
+19 -3
View File
@@ -1,5 +1,5 @@
import { listSidecarInstalls } from 'officerdb';
import { CATALOGUE } from './catalogue';
import { CATALOGUE, type CatalogueEntry } from './catalogue';
// Which features actually EXIST on this server right now — as opposed to which the account is permitted
// to use.
@@ -25,6 +25,14 @@ import { CATALOGUE } from './catalogue';
const CAPABILITY_TO_SIDECAR = new Map(CATALOGUE.filter((e) => e.capability).map((e) => [e.capability as string, e.id]));
export type Availability = {
/**
* UI manifests of the sidecars that ARE usable — what the dock should show beyond the baseline.
*
* Sent with the capability answer rather than fetched separately so the dock has one source. Two
* requests would mean two moments, and a dock rendered between them shows either a tile for something
* uninstalled or nothing for something installed.
*/
manifests: Array<{ sidecarId: string; capability: string | null } & NonNullable<CatalogueEntry['ui']>>;
/** Capability keys whose sidecar is not installed, or is installed but disabled. */
unavailable: Set<string>;
/**
@@ -51,7 +59,9 @@ export async function capabilityAvailability(): Promise<Availability> {
try {
installs = await listSidecarInstalls();
} catch {
return { unavailable, degraded: true };
// Degraded: subtract nothing, and offer no manifests. The dock keeps its baseline rather than
// guessing, which is the same fail-open posture as useCapabilities.
return { unavailable, manifests: [], degraded: true };
}
const usable = new Set(
@@ -62,5 +72,11 @@ export async function capabilityAvailability(): Promise<Availability> {
if (!usable.has(sidecarId)) unavailable.add(capability);
}
return { unavailable, degraded: false };
const manifests = CATALOGUE.filter((e) => e.ui && usable.has(e.id)).map((e) => ({
sidecarId: e.id,
capability: e.capability,
...e.ui!,
}));
return { unavailable, manifests, degraded: false };
}
+8
View File
@@ -79,6 +79,14 @@ export type UiManifest = {
* exactly one.
*/
routes: string[];
/**
* Additional dock tiles, for the rare sidecar that presents as more than one thing.
*
* CalDAV is the only case today: one sidecar, but Calendar and Contacts are separate features to
* anyone using them, and collapsing them into one tile to keep the model tidy would make the app
* worse. Every route here must still appear in `routes`.
*/
extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>;
};
export type CatalogueEntry = {
@@ -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,