NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Shell, navigation and operation dock
Developer reference

Shell, navigation and operation dock

Shell and navigation

web/components/shell/Shell.tsx mounts one shell. SidebarNav.tsx uses the shared shadcn Sidebar primitive from web/components/ui/shadcn/sidebar.tsx. A skin changes the existing shell through tokens and CSS; it does not mount a second navigation tree.

The ruled TopBar remains an opaque position: sticky; top: 0 edge even though the shell mounts it above the inner page scroller. Safari uses a fixed or sticky edge to extend its solid background behind browser chrome; changing the bar to relative removes that hint and exposes Safari's scroll-edge blur. Its fill is the existing bg-background token, so both Studio skins keep their own canvas. No extra layer, browser detection or scroll listener is needed. /chat portals its toolbar into the same bar; viewport.e2e.ts checks its computed paint, edge geometry and stability while the page and transcript scroll. See WebKit's explanation.

The navigation model is assembled by useShellNavigation.ts from core worlds and the live plugin UI listing. Plugin destinations appear only when the plugin is installed, enabled, compatible, and visible to the account. The untouched default order is defined in navOrder.ts; grouping into Primary, Work, and Instance is defined in navGroups.ts. A user's hidden and ordered entries are stored as one layout and resolved by web/lib/navLayout.ts. The account's layout is read from and saved to /auth/me/nav-settings (query key my-nav-settings, saved with a revision for conflict detection). The last confirmed layout is mirrored into localStorage under elowen.nav.layout.<userId> so the menu does not repaint in registry order on load.

The sidebar supports a full column, icon rail, and drawer. The choice is based on measured space after the advisor dock, with the shell profile and breakpoints supplied by web/lib/breakpoints.ts. Desktop pointer input can reorder entries. Touch and the drawer use the menu actions instead. The command palette is opened by the sidebar search row, the top-bar search control, or Ctrl/Command+K. The two buttons dispatch the elowen:open-command-palette window event; the Ctrl/Command+K key toggles the palette. The palette is implemented in web/components/shell/CommandPalette.tsx, and its rows come from web/components/shell/siteSearch.ts. The sidebar collapse shortcut is Ctrl/Command+\.

The Chat row stays the link to /chat and carries the live count of the reader's working conversations. In the full column and the drawer it also lists the five most recently active conversations from the same useBrainSessions() cache, ordered by newestFirst from web/lib/conversationTree.ts, the default order and tie-break of both conversation registers. Each row leads with ProgramStatusGlyph, the same state glyph and label ProgramStatusIndicator draws in the conversation history, without the failure tooltip, because the whole row is the button. A row opens through openBrainSessionPage(id) (web/lib/brainDock.ts): the same open request as openBrainSession(id, true) with page: true, which the shell answers by routing to the full /chat page instead of revealing the dock, on every viewport; the active conversation is marked only while /chat is open. The foot of the list, labelled like the chat toolbar's switcher, calls useBrainChat().openHistory() and so opens the one ConversationSwitcherModal the shell mounts. In the drawer both actions close the sheet themselves, since neither is a route change. The list is disclosed by a chevron of its own, a SidebarMenuAction (the upstream shadcn part, added to web/components/ui/shadcn/sidebar.tsx) laid over the row's trailing end and wired as the Radix CollapsibleTrigger, so the row stays the link and the chevron never navigates. Its open state is the chat key of useOpenSubMenus, the same per-account, per-browser fold store every sub-menu uses (localStorage elowen.nav.submenus.<userId>); an account that never chose sees it folded, like every other disclosure. While the list is loading, failed or empty nothing is drawn under Chat and there is no chevron, and the icon rail withholds the list as it withholds sub-menus. No request, setting or count is added.

useNavDragReorder.ts owns the sidebar's row refs, drag threshold, captured-pointer lifecycle, row transforms, cancellation on lost capture, release-click suppression and window listener cleanup. SidebarNav spreads entryProps(entry, group, slot) on each row and surfaceProps on the navigation surface, then converts the hook's group slot to the persisted visible-order index in its onReorder callback. Dragging stays within one group and samples its row centers on press; touch and drawer presses create no drag state. A press below the threshold remains an ordinary click. This hook does not persist layouts or own drawer focus, sub-menus or the collapse shortcut.

Both SidebarNav and TopBar use lib/shortcuts.usePaletteShortcutHint() for the palette button's visible hint and title. Server markup and the first client render use Ctrl K; one mount effect reads navigator.userAgentData.platform or navigator.platform and changes Apple hardware to ⌘K. The binding and aria-keyshortcuts="Control+K Meta+K" still accept both modifiers. The hook has no listeners or network requests. Search groups contain only produced page, settings, account and plugin entries; the empty query launches pages and the two decks, while typing reveals rows and plugin pages.

Palette and conversation-snippet highlighting use lib/normalizeText.findNormalizedRange with the shared normalizeText rules. Its offsets are original UTF-16 spans, mapping every normalized UTF-16 unit to its complete original character so supplementary Unicode characters cannot split or duplicate a label.

The palette searches in three passes: the local lexical filter, POST /search/rank by meaning when fewer than three rows matched, and Ask AI. Ask AI is the last row for any query of three or more characters, and for a shorter one that matched nothing; it runs only on a click or Enter. POST /search/ask sends the query, the palette's own index as candidates and the UI locale; the daemon grounds a two-to-four sentence answer in the manual through the docs plugin's docs control and returns it with the sections the model says the answer relies on (excerpts it does not cite are not listed, and sources is omitted when it cites none) and the candidate ids it recommends. The palette shows the answer first under its own heading, the recommended rows below it as ordinary palette rows (cmdk values prefixed __ask-ai__:), and moves the cursor onto the first of them. The model always answers when it has excerpts: partly, or by saying the manual does not cover the question and naming the closest page. The answer group ends with Ask in chat, which closes the palette and opens the conversation with the question in its composer through openBrainComposer (web/lib/brainDock.ts, the seam the dashboard's quick actions use). Without the docs plugin there is no answer text, only the rows; with neither, or on failure, a muted line says no answer was found and the same Ask in chat row follows it. Retyping clears the answer. Below sm the route hint column is hidden so row titles keep their width.

Settings and Account are each one sidebar destination. Their section navigation is owned by the deck itself. Plugin-owned personal sections are placed in Account, while plugin administrator settings are owned by the plugin's own page or detail surface according to its manifest registration.

Core version display uses web/lib/productVersion.ts: call productLine(fullVersion) wherever a reader should see the major.minor product line. Without a full numeric artifact version it returns the input unchanged; it never changes API values, comparisons or plugin versions. The sidebar displays this line from /health, with the exact artifact version in its title. Settings → System shows the exact artifact version in its version metric, because that is where support reads it.

The account row index and command palette use buildAccountSearchEntries and buildSearchIndex with the server-derived skillLearningAvailable boolean from the shared account-settings query. Both omit the skill-learning anchor while unavailable or still loading, so search cannot link to a hidden record. No plugin or tool permission logic is repeated in the browser. This projection adds no inference; the palette's account read shares the existing query cache with Account. AccountMemorySection is the real row consumer and uses the same availability value.

The personal and instance conversation registers normalize local title, model, owner and scheduled-job name matches through lib/normalizeText, so pamet finds Paměť. findNormalizedRange highlights the original accented span in transcript snippets. Transcript lookup still sends the original query to the daemon; local normalization changes neither that endpoint nor its matching contract.

Background operations dock

OperationDockProvider and OperationDock are mounted once in the shell, including Chat. The dock restores the signed-in person's hand-started work after a page reload. Starting work with dock.open(id) tracks it in the minimized dock, without opening a progress view or moving focus. Clicking a dock icon or its overflow-list entry explicitly opens the shared progress view through the shell-only showProgress action. Plugins use UI API 54:

const dock = hooks.useOperationDock();
dock.open(operation.id);

The environment adapter intentionally binds to Sandbox, the owner of these operation rows and HTTP routes; the plugin-name contract records that exact binding and its absence behavior. QUERY_KEYS.environmentOperations owns the cache prefix shared by registration, account-scoped refreshes and the reconnect bridge.

Environment and plugin dock adapters use createDockRowReconciler for account/UI/acknowledgement eligibility and row replacement. Each adapter creates its own instance and keeps its own transport and query keys. read(key, accountId, fetchRows) captures durable rows pushed during every concurrent read of that key; put(key, accountId, rows, row) records the row in those captures before replacing or removing it from the cache. HTTP environment acknowledgements are validated once by elowenClient and enter the same path with the server's acknowledgedAt. Without a push or acknowledgement, the server list remains authoritative; a failed read preserves the existing query result. Captures live only until their reads settle and do not persist tombstones. OperationDockProvider is the environment consumer, and usePluginDockOperations is the plugin consumer. Memory receipts use the existing useAcknowledgeMemoryMaintenance mutation; update receipts pass their stored requestId directly to the client.

OperationProgressDialog is mounted only while its host shows the environment progress window; it has no open prop. The caller owns mounting and dismissal, while Modal owns focus and overlay lifecycle. Unknown progress keeps its null value and accessibility semantics and uses the internal operation-progress-indeterminate class, shared with plugin, update and memory progress. The resolved root effects policy owns pulse animation: full pulses, explicit reduced slows it, and off or auto with OS reduced motion stays static. No additional Progress prop or public plugin component is introduced. EnvironmentDockProgressDialog is the mounted environment consumer.

The operation dock follows the app's EffectsProvider and global effects CSS. Explicit full, reduced and off settings take precedence over the operating system; auto follows the OS preference. There is no dock-specific unconditional prefers-reduced-motion override. The published --fab-clearance token remains available for plugins and skins even though the shell no longer consumes it.

Core environment consumers use elowenClient.environmentAction(projectId, action, expectedGeneration?), environmentOperations(), environmentOperation(operationId, projectId?, signal?) and acknowledgeEnvironmentOperation(operationId) through the shared JSON transport. Operation responses are validated by the existing isEnvironmentOperation boundary; detail may be null, while the provider filters list rows by account, UI initiation and acknowledgement. Pushed rows received during the list or detail seed take precedence over the older HTTP response. The bounded account list completes into the shared query cache when its observer unmounts, so a shell remount reuses that read without an aborted request or duplicate fetch. The per-operation follower cancels its seed read when its operation changes or unmounts. These methods add no polling or persistent browser state, preserve the generation precondition and replay only demonstrably undelivered writes after health recovery. A possibly accepted create or acknowledgement remains unconfirmed and reaches the shared toast adapter with its original error; failed acknowledgement retains the dock item.

Component tests use the shell context fixture supplied by web/tests/test-utils.tsx's createWrapper(). It provides the existing OperationDockContext, returns the typed dock value with assertable action spies, and starts no operation HTTP reads. This also works in suites that mock the dock hook. Provider-focused tests mount the real OperationDockProvider inside that wrapper, where it remains the nearest owner. The production hook still rejects a consumer without context; ProjectPicker, the new-conversation modal and ProjectsView all mount beneath the shell provider.

Sandbox snapshot creation is the first consumer; all environment lifecycle and snapshot actions use this same path. dock.operations exposes environment rows for Project busy state. The old per-screen windows and chat reopen chip are removed.

The Sandbox operation row owns initiation, progress and acknowledgement. The list returns only the caller's UI-initiated pending/running work plus settled, unacknowledged results from the last 24 hours, bounded to twenty items; administrators see only their own. Current Project access is rechecked. Agent and automatic work are excluded. One HTTP read seeds the list on page load and reconnect; plugin frames update the shared query without a refetch. Opening progress seeds the existing single-operation read, then follows frames for live logs. The shared dock row owns dialog state whenever present, including the reconnect refresh; the single-operation seed supplies state only before that row is available. There are no requests per animation frame and no localStorage copy. Without Sandbox there are no environment items or list reads; during restart the last successful query stays visible without inventing an outcome.

Core and plugin update requests use the existing shared useSystemUpdateStatus query and coordinator queue/journal/report. Request identity and actor survive the daemon restart, and the per-request report owns the final outcome and acknowledgement. The shared query polls only while downloads or coordinator work are active and refetches on reconnect; the dock adds no second update store. Install preparation appears as downloading before the initial POST returns, using the same request id that later enters the queue. The owner-scoped, content-free update SSE nudge refreshes only this query, including after leaving Settings. Preparation-only reports do not refresh installed-plugin views or claim that a core check succeeded. updateDockOperations exposes the plugin op through preparation, queue, journal and result; the shared progress dialog uses separate install and repair titles, with cs/sk/en wording. Repairs keep their existing coordinator-owned download path. A failed or interrupted pre-queue preparation remains an acknowledgeable result; successful queue handoff removes only that preparation record.

The advisor launcher (web/modules/advisor/AdvisorLauncher.tsx) is one round --fab-size button, .overlay-fab on --z-fab, in the bottom-right corner of the measured page column and inset by the shared safe-area tokens. From 48rem it is present on every route, including Chat and while the advisor panel is docked on any side, and never lies over that panel because the page column already excludes it. Below 48rem, the shell's phone breakpoint (PHONE_MAX_WIDTH), primitives.css does not display it: a phone reaches the chat through the navigation's Chat row and the conversation switcher, and the launcher's attention list and per-row Stop exist only from 48rem. A click always opens the shared shadcn DropdownMenu, rendered in place, opening upward and leftward and capped at min(22rem, 100vw - 2rem). Its first row, "Open assistant", calls the shell's open action: with no chat host on screen it opens the advisor exactly as before (docked panel on desktop, /chat below Studio's dock width); with Chat or the docked panel already showing it focuses that host's composer through openBrainComposer(). Below it come the attention conversations from useOperationDock().conversations; their count sits bottom right on the button, absent at zero, in the destructive tone when one failed and the warning tone when one waits for an answer or approval. With no conversation the menu shows only the first row and the shared EmptyState the former Chats list used. Rows reuse the chat activity labels; Stop is a DropdownMenuItem with size="icon" and variant="destructive" carrying the composer's filled square; the count is the former Chats dock badge, coloured by tone because the brand icon cannot be. A chosen row runs after Radix has closed the menu and returned focus to the launcher, so a composer that takes focus keeps it. The launcher reserves no footer on ordinary pages: the scroller and sidebar reach the page column floor, and the page ending padding on .shell-content (owned by primitives.css, absent on Chat) lets the last row scroll above it. Chat cannot scroll its composer away, so at 48rem and wider the full chat's .chat-composer-dock keeps a right lane of the launcher's width, inset and gap beside the composer, leaving the transcript and the centred chat frame at their measure (a non-empty operation column's gutter on main already clears the launcher, so the lane then drops). Every reservation for the launcher applies from 48rem only: on a phone pages end at the plain 2rem, the composer dock keeps only its safe-area padding, and while the phone keyboard is open the operation column is hidden so the visible band belongs to the composer. The launcher is hidden while an overlay makes it inert.

OperationDock hosts only operation items. Its fixed column sits directly above the launcher, one 0.5rem gap away, shares the launcher's right edge and grows upward; on a phone, which has no launcher, it takes that bottom-right corner itself: the first item is nearest the launcher and +N is farthest, and the DOM runs top to bottom in that same visual order. At most three round controls appear. The page scroller reserves the measured column width, right inset and gap, so no page control runs under the column at any scroll position; the top bar keeps its full width, no upper gutter exists, and an empty cluster reserves nothing. Controls use --fab-size; failure dismissal stays inside each item's footprint, included in the measured width. The cluster renders in place without joining an overlay stack; inert isolation hides it and pauses acknowledgement timers. The item whose dialog is open keeps its control, so the reserved width does not change and the page does not reflow under the dialog.

ShellLayout publishes --shell-operation-right, --shell-operation-bottom, --shell-operation-available-width and --shell-operation-width on the document root so the launcher and the column consume one measurement. One ResizeObserver measures the viewport, outer page column and cluster; physical bounds are converted through the existing UI-scale preference, while the cluster's layout width is already in CSS pixels. The outer page edges exclude navigation, telemetry and advisor panels on every side, including a panel docked below, but not the column's inner content inset, avoiding a resize feedback loop. Rail resizing, dock-side changes, viewport and UI-scale changes update geometry without reload. Cleanup disconnects the observer and removes its properties.

At most three round buttons are visible; +N opens the remaining list through the shared Modal. A known percentage uses the shared MetricDonut, lazy-loaded when a determinate item appears, with a React-node centre for the action icon; unknown progress draws a spinner. The first appearance of a running operation and its settled result announce through a polite live region. Completion shows a done icon for the operator's toast duration, pausing on hover, focus, hidden tabs or overlay isolation before server acknowledgement. Failures stay minimized with a destructive-tone alert icon and a failure label until opened and closed or explicitly dismissed; they never expire or automatically interrupt the current view. If a failure is beyond the three visible icons, the +N button also carries a destructive-tone alert icon and names the failed operation in its accessible label. A failed acknowledgement keeps the item and reports the error. Minimizing keeps work running and returns focus to its dock button through Modal's onClosed; if closing the final failed item has already removed that button, focus returns to the separate advisor launcher when it is not inert. Reduced motion suppresses dock animation.

The provider's DockOperation discriminated union is the single item model: environment, update, memory-maintenance and generic plugin operations. Every DockOperation supplies title and renderProgress(props) from its source adapter. The provider requires renderMemoryProgress(operation, props) at composition: ShellOperationDockProvider passes the feature-owned MemoryMaintenanceProgressDialog, so web/lib has no upward memory-module import. This uses the same close/focus/acknowledgement presentation contract, adds no operation state and is invoked only for an inspected memory row. With no eligible memory jobs, no renderer is invoked. The renderer receives shell close/focus callbacks, the success delay and the existing acceptance/acknowledgement actions. An optional translated statusLabel preserves a source's lifecycle meaning, including interruption. The shell uses only this presentation, percentage and common status for labels, inspection, overflow and completion timers; adding a source requires no operation-kind branch in the shell. Derive rows from the existing shared query and supply its renderer in the provider, then route acknowledgement to the source's durable endpoint. Environment inspection retains the existing follower for logs and retries. There is no registry, second dock, browser store or per-item transport. The first three items share the same overflow list and fixed cluster.

A plugin declares web.operationDock: { desktopOnly: true } and contributes registration.operationDock through the existing plugin UI loader. The shell loads only declared, compatible sources and withholds desktop-only bundles at mobile or unknown width. With no declared source there is no source load, operation request or item. No feature-specific branch belongs in the shell. The first three items share the same overflow list and fixed cluster.

PluginOperationDockSource supplies eventKind, list(strings), get(id, strings), decodeEvent(data, strings) and acknowledge(id) returning the server's { acknowledgedAt }. Its plugin validates raw replies and normalizes a PluginDockOperation: own account and initiation, pending/running/succeeded/failed status, translated title/status label, optional detail and percentage, update/acknowledgement timestamps, and exact history URL. useOperationDock().open(id, pluginName) fetches the accepted id before SSE arrives and tracks it minimized. usePluginDockOperations maintains one existing React Query cache per source, account and locale, seeds a bounded list on mount and reconnect, and follows the existing shared plugin-event subscription. Frames or acknowledgements received during a seed read win over the older response. Failed reads retain the last known rows; foreign, non-UI and acknowledged rows never render. There is no polling, second persistent browser store or per-run connection. Shared inspection uses the plugin's labels and history link, without interpreting its domain statuses. Opening history for a settled result awaits its durable acknowledgement before navigating; a rejected acknowledgement retains the view and reports the error. Cronjob is the first consumer and maps interruption to failed while retaining its explicit Interrupted label.

Memory maintenance uses the existing owner-scoped useMemoryMaintenance query, enabled by the dock only for a desktop administrator. Its server-retained runs array supplies UI initiation, counts, interruption and acknowledgement. Polling continues only while a current maintenance slot is running; the existing memory SSE and reconnect bridge invalidate the same query. MemoryMaintenanceProgress is shared by the maintenance page and dock inspection, with real processed/total progress and success/failure counts. Interrupted and error runs, and runs containing failed items, retain the failure icon. Inspection links to /memory?maintenance=1, and dismissal acknowledges the exact run id. Empty, loading or initially failed reads invent no operation; refresh failures retain the last successful data. The dock adds no maintenance connection, store or separate poll.

The launcher's attention list is the same provider's conversations, wherever the launcher is shown (from 48rem). Environment, core updates and plugin updates remain available on phones. Marketplace installation, repair and memory maintenance are visible only to the current administrator on desktop; unknown first-paint width withholds them too. Filtering removes their open inspection if the width or administrator role changes. useBrainSessions() is the existing server-owned personal list, excluding shared, foreign and delegated sessions. Only an explicit activity.automation === null enters this list. Durable working state, backgroundWorking and native blocked/question or blocked/permission state include active rows. Settled rows require durable unread state and expire from the list after 24 hours, using one next-expiry timer without marking them read. Waiting decisions and failures come first, then running work and unread completion, in a bounded scrollable list. No transcript bodies or per-chat connections are fetched for this list; the existing conversations SSE invalidates the shared query. Refetch failures retain the last successful rows.

ShellOperationDockProvider supplies the displayed session id only while the full chat page or actual advisor panel is open, and the menu leaves that conversation out. A selected conversation in a closed panel is therefore eligible again. Opening the menu performs no read acknowledgement. Row selection uses openBrainSession(id, true) after the menu's focus restoration, which opens it in the advisor panel on desktop and on the chat page on phones; Stop is a separate icon row per running or waiting conversation that keeps the menu open and uses the existing brainAbort(id) command, never inventing a terminal result. Chat results are read only by BrainChatProvider through the already rendered snapshot/terminal activity sequence. Its registerSurface(latestVisible) seam owns the mounted reader predicates, and notifyActivityVisibility() wakes acknowledgement when scroll, geometry or overlay isolation changes. The real BrainChatSurface reports its transcript tail within the visible scroller and excludes inert or hidden panels. A list refresh cannot acknowledge a newer unseen sequence. Closing a chat or covering it with an overlay leaves later answers unread.

Toast entrance animations use the shared effects CSS policy. data-effects-mode="auto" follows prefers-reduced-motion, explicit full keeps the entrance animation even when the OS requests reduced motion, and off disables it through the common animation rule. Before any preference is available the existing preference initialization owns the resolved mode. Toast does not install a separate media-query policy or JavaScript motion observer.

Overlay and portal rule

The app has one overlay policy in web/components/ui/overlayStack.ts and web/components/ui/overlayDepth.tsx. It owns stack order, inert isolation, body scroll locking, focus return, nesting depth, safe-area geometry, and responsive presentation. Modal.tsx binds that policy to the app's dialog surface.

Radix owns Escape layer selection and dismissal. The shared shadcn DialogContent and AlertDialogContent consume that native Escape before it can bubble through a portal's React ancestors or global bubble handlers, including when a save blocks dismissal; caller onEscapeKeyDown handlers still run and may prevent dismissal. No dialog means no additional keyboard listener. For example, provider statistics raised from the Settings page overlay close without forwarding their key to the underlying surface. This is constant work on the selected layer, not a second keyboard stack.

The shadcn dialog wrapper deliberately does not use Dialog.Portal or Radix Dialog.Overlay. The app portals the complete overlay surface, including its scrim, once. Portaling only dialog content would separate it from the positioning backdrop and give the overlay stack two body children to manage. Menus and selects opened inside an overlay are not independently portaled, so they stay inside the dialog's focus scope.

The expanded desktop view is not an exception: DesktopPreview raises its surface through the bare Modal (chrome="bare"), so a plugin-produced overlay joins the same stack, isolation, focus trap, Escape and scroll lock as every other dialog. No plugin portals its own surface.

A flow that continues from one dialog into a second one opens the second from the first's onClosed (also on ManageSelectionModal and RowPicker), which runs after the first has unmounted and handed focus back to its opener. A dialog remembers the element focused when it first renders as the one to return to, so one mounted in the commit that closes its predecessor would remember a control that is already gone and leave focus on the page when it closes. onClosed is optional: when it is omitted, focus still returns to the opener and nothing else runs. It fires once, a tick after the dialog has unmounted, whichever way it went: the close control, Escape, a backdrop click, or the parent unmounting it. It does not fire while closeDisabled blocks the close, and it does not fire for a dialog that never mounted. RowPicker's onClosed is the same hand-off for its dropdown: after a pick it fires with focus on the trigger. The plugin timezone field's "Custom…" entry is the call site, opening its custom dialog from there.

The background operation dock is shell chrome rendered in place on --z-fab; it joins no overlay stack, and its progress/list views use the existing Modal policy.

Use Modal, ConfirmDialog, ActionMenu, ContextMenu, and the shadcn primitives through their existing wrappers. Do not add a second portal or overlay stack, hard-code z-index bands, or reimplement focus and scroll behavior.