Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Shell, navigation and operation dock

Tento článek zatím není přeložen. Je dostupný v angličtině.

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. Every route keeps the workspace in ChatRailSplit and its content panel; only the telemetry dock is added on desktop /chat. Switching Chat and Home therefore preserves the sidebar, top bar and measured region instead of replacing them and replaying their first paint. Without a telemetry dock, the group's and content wrapper's overflow is visible: the shell itself owns clipping and scrolling, and the Studio ASK card's shadow and outer ring must escape its immediate wrappers. nav.route-stability.e2e.ts asserts workspace and desktop sidebar DOM identity across both directions, full workspace width after removing an expanded rail, and unclipped ASK wrappers in both Studio skins.

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. viewport.e2e.ts checks the bar's computed paint, edge geometry and stability on /chat and on the register while the page and transcript scroll. See WebKit's explanation.

A page talks to the TopBar through one context, web/lib/pageHeader.tsx. ModuleHeader publishes the page title, count and icon; PageTopBarPortal paints route-owned controls into the bar's page slot (PageTopBarHost) while their state stays in the page, and renders them in place where the shell publishes no host; chatControlsNarrow is the bar's own measured verdict that the chat's wide picker group folds behind its ⋯ menu. secondaryActionsHidden goes the other way: a page that needs the bar's room sets it while mounted and clears it on unmount, and the TopBar then does not render its command search, skin switcher and language switcher, so they also leave the focus order. Notifications, the account link and the hamburger always stay. Unset, which is every page that does not set it, means the full set. The one consumer is the full chat bar on a phone (ChatConversationBar, mobile === true from useMobileViewport): by the owner's decision of 13 Oct 2026 it trades those three for its own one-tap reasoning and telemetry controls, so the conversation title keeps room. A phone still reaches search and skin from the navigation drawer, and the language from every other page. In the same phone bar page-bar.css gives every icon control a compact 2rem width while coarse pointers keep the 44px height; at 320px the title text gets 38px instead of 0px, and 108px at 390px. BrainChatMobileControls.test.tsx and chat.mobile-menu.e2e.ts pin both.

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 one command geometry: the measured navigation region becomes a drawer below 1024 CSS pixels; the full/rail pin uses elowen.nav.command.pin. The advisor dock begins at a 1440px viewport and stores advisor:dock:command, defaulting to 336px wide and 420px high with a 320px minimum width. PHONE_MAX_WIDTH stays 767px. Skins change paint and tokens, not navigation geometry. Retired spatial keys are neither read, migrated nor cleared. 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 lists only conversations with a non-null server-owned pinnedAt from the same useBrainSessions() cache. There is no row limit; pins sort by pin time descending and conversation id ascending, independently of activity. An account with no pins has no submenu or chevron. Each row leads with the shared ConversationIconPicker: the saved identity glyph and current-state dots, also used in conversation history and the chat header. Its icon button opens the shared picker without navigating; the separate title button opens the conversation. Failure detail uses the existing tooltip. The sidebar entrance animation uses animation-fill-mode: backwards so its transform is released after the entrance and the in-place picker is not trapped beneath later navigation groups. 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. Navigation search groups contain only produced page, settings, account and plugin entries; the empty query launches pages and the two decks, while typing reveals rows, plugin pages and the separate server-returned Guides group. The palette does not search conversation transcripts.

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 core-owned docs host 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). A successful response with no answer or destination says the documentation does not cover the question. Refused or unavailable inference has its own error and retry, followed by the same Ask in chat row. Query, locale or account changes clear 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. From the same query it renders the optional skillReview status as one muted line under the switch, without any second read (see Personal skill learning).

The shell mounts one ConversationSwitcherModal beside the shared chat controller, so sidebar, dock and full-chat entry points open the same surface. It uses the shared page-sized centered frame (fullscreen on phones), not a route or a locally resized dialog. Closing always discards view interaction state; reopening starts in Mine. Administrators alone can switch to All, and demotion immediately returns to Mine. Both views use one list panel, keyed by view; the phone panel keeps its dashboard exit.

The unified conversation register normalizes 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. After two trimmed characters and a 300 ms debounce, transcript lookup sends the original query to the existing search route: caller-only for Mine, explicit administrator-only instance scope for All. Results remain bounded. This is separate from command-palette search, which never searches transcripts. Project/status/model/type and All's hosting-owner facets combine with search; the All unread facet applies only to the reader's own conversations. See the conversation list contract for state lifetime, grouping, root paging, selection and plugin branch ownership.

The chat's Agents rail shows live facts for each running sub-agent: elapsed time counted from its run start, the measured generation speed, its token count, the latest status note, its model mark and delegation brief. A figure with no measurement is left out rather than dashed. Rows expose Stop through the existing child-stop action. In the collapsed rail, the same rows open in a popover so their controls remain usable. Status notes occupy a second line in both densities; the name and model mark remain pointer-accessible, and an open hover card is raised above neighboring rows. The row uses no competing native title tooltip.

The conversation register's Model, Status and Type filter options reuse the row marks: model brand marks, conversation state dots and platform marks respectively.

Guides is a host module (web/modules/guides) and a BookOpen world for every signed-in account, placed beside the optional What's new destination in Instance. Registry metadata, navGroups and navOrder extend the existing navigation model; hidden/order preferences remain untouched. Nothing about this placement grants feature or model access.

web/lib/docsQueries.ts is the browser retrieval owner for both GuideSearch and CommandPalette. useDocsSearch(query, group?) debounces for 300 ms and uses the existing TanStack cache, keyed by account, UI locale, catalog revision, category and bounded query. It calls elowenClient.docsSearch (POST /search/docs, eight user-projection results), never rankCandidates with article bodies. The palette retains its separate navigation index and /search/rank pass. Query changes and unmount cancel requests through query signals; a replacement query never displays the previous hits. Catalog reads revalidate on mount/focus, and a changed revision gives articles/search a new cache identity. Failures stay visible with a retry of the failed read, distinct from a successful empty result. There is no browser index, persistent article copy or background inference.

useDocsAsk owns explicit, abortable one-shot /search/ask calls in both surfaces. The page sends no navigation candidates; the palette still sends its own destinations. An answer belongs to its query, locale and account. Changing any identity field aborts the request and resets its stored state to idle, so returning to an earlier question cannot revive that abandoned pending state. A cancelled request cannot publish after cancellation or unmount. Citations are real links to the server-validated article/section hrefs, with source language exposed. Model absence, refusal, rate limiting and request failure have distinct localized states without erasing search results. openBrainComposer(question) remains the only continuation into chat.

Home composes SpatialWorkspaceLayout, ControlSurfaceDocument and category CardGrid; category/related lists use full-width CardGrid layout="list", and the article page sits in WorkspacePage so it shares the shell gutter and measure. Every guide, category, search-result and previous/next card is ONE stretched link (GUIDE_CARD and GUIDE_CARD_LINK in GuideCards.tsx): the title anchor's ::after covers the card, so the whole card keeps native new-tab and modifier-click behaviour and the card, not the title, draws the keyboard ring. A list card shows the category glyph only when the list mixes categories. Category glyphs come from the catalog: DocsCategory.icon is the closed name authored in packages/plugin-shared/documentCategories.mjs, mapped by the closed CATEGORY_ICONS table in GuideCards.tsx; a name the table lacks fails the web typecheck through DocsCategoryIcon. Category cards also show the guide count.

Article HTML and TOC come from the documentation service, not another browser Markdown parser. The module renders the title, summary, review date, administrator audience and explicit English fallback marker (lang="en") as badges, one header copy-link Button (useCopyFeedback, including the existing failure toast) and a breadcrumb (GuideBreadcrumbs). The outline is a sticky column of native section anchors from 60rem of container width and the shared Collapsible below it; the entry of the section being read carries aria-current="location", derived from scroll position only, so hash navigation and history stay with the anchors. Previous/next/related articles are service-owned catalog summaries. Search-result and palette excerpts are plain text through markdownSnippet (web/lib/markdown.ts: the host Markdown pipeline read back from an inert DOMParser document); the wire DocsPassage.text keeps its Markdown for Ask AI.

Reading styles live in web/modules/guides/guides.module.css and use only the type-role variables and skin tokens: section headings, numbered steps in tinted discs, bordered code, tables that scroll inside a rounded frame, blockquotes as tinted callouts, framed screenshots. Guides draws no underlined link anywhere (owner, 20 Oct 2026): chrome links are cards, breadcrumbs and outline entries without the content-link class, and links inside article prose and answer citations use the module's .link look, the brand colour with a deeper hover and the global focus ring. This is the one documented exception to the shared underlined .inline-link look of app/styles/components/primitives.css; web/tests/modules/guides.test.tsx checks that no Guides surface wears the content-link classes.

GuideSearch.onNavigate is an optional pre-navigation callback on both result and citation links. GuideArticleView uses it to synchronously clear its local search and remount the article before Next follows a same-path section link. Next's native Link.onNavigate owns the activation decision: modified/new-tab clicks keep the current search untouched, and normal navigation keeps the link's href, history and anchor behavior. Without the callback, home/category links behave as ordinary Next links; this adds no request or history write.

Related updates use the live changelog listing through usePluginPresent, then only GET /plugins/changelog/api/entries?guide=<slug>&lang=<locale>. Its response is parsed at the module boundary, cached per account/locale/slug, and linked to the exact release item. No request is made without the plugin, no read acknowledgement is written, an empty successful section is omitted, and a failure gets an independent retry without hiding the guide.

Terminal plugin windows

The Terminal bundle requires UI API 63 and contributes projectRows.cardAction and operationDock.view. Its card action creates a session, then calls dock.open(sessionId, 'terminal', { show: true }). The source reads the account's session list for both list and single-row lookup; terminal-session events carry { session: TerminalSessionDescriptor }, scoped by both Project and owner. Creation and exit update the row; deletion, acknowledgement and expiry publish a row with acknowledgedAt before removal. The source validates the descriptor and uses the shared dock reconciliation, without polling or a browser-owned session list. Captions are Project labels allocated by the server, with a numeric suffix for multiple sessions.

plugins/terminal/web-src/TerminalWindow.tsx composes the runtime Modal with bare chrome and TerminalSurface. The content frame opts into pointer events because the shared bare modal lets the backdrop receive presses outside that frame. The manifest declares the events mutation and uses the existing capability-consent flow. Minimize and Escape only unmount the view; X calls DELETE and releases terminal:<sessionId>. A removed-row event releases the cached surface in other tabs too. Dismissing an exited dock row calls the acknowledgement endpoint. Mobile uses fullscreen; desktop uses the page-sized centered frame. Refusals stay in the plugin's cs/sk/en strings. Binary PTY bytes and typed control frames share the contracts in src/shared/wireContract.ts; a resumed ticket carries its requested absolute offset into the socket payload. Exit frames and session rows preserve the channel's signal name as a string, such as SIGTERM, or null. Without the plugin grant, the host mount predicate loads neither contribution.

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 that use it need UI API 54 or later:

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. The shared isEnvironmentOperation HTTP/event parser accepts a null accountUserId only for initiator: 'system', as emitted by Sandbox's core-owned Project storage deletion; UI and agent operations still require an integer account. System rows are valid progress, not malformed frames, but never enter the personal dock: both its event subscriber and createDockRowReconciler require the signed-in account's unacknowledged UI work.

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 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 and ProjectsView 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, followed by the run's time in the chat's formatDuration ladder: a row whose durable activity is working (also while it waits for an answer, approval or background work) ticks from activity.at, the working transition's own stamp, on the shared useNow heartbeat; a settled row shows activity.durationMs, the chat work summary's measure from the daemon listing, and nothing when that is null. 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. 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. Like the launcher, the column floats over the page and reserves no gutter: an item arriving, settling or being dismissed never moves the page (owner, 13 Oct 2026, after the earlier measured gutter on main made the whole page jump whenever the column appeared, emptied or changed width). Controls use --fab-size; failure dismissal stays inside each item's footprint. 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.

ShellLayout publishes --shell-operation-right, --shell-operation-bottom and --shell-operation-available-width on the document root so the launcher and the column consume one measurement. One ResizeObserver measures the viewport and the outer page column; physical bounds are converted through the existing UI-scale preference. 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, memory-dreaming 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.

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.

A row may also be a WINDOW rather than a timed operation. A PluginDockOperation carries optional icon (a closed lucide name, drawn in the dock control instead of the progress glyph while the row is running) and caption (a short visible label under the control, also carried into the control's accessible name so three open terminals are three distinguishable buttons). A PluginOperationDockSource may supply view, a component drawn instead of the shared PluginOperationProgressDialog with { operation, onClose, onClosed, acknowledge }; the operation it receives is the source's OWN row, so operation.id is the plugin's id while the dock keeps its namespaced key. The view's acknowledge(id) takes the source's own row id and is scoped to that plugin, even when another source uses the same id. Each view renders inside the host's plugin error boundary. onClose means MINIMIZE, not dismiss: the dock's close control only puts the row back into the column, and the settled-failure auto-acknowledgement the shared progress dialog performs on close is deliberately not applied to a view. Two rules follow, both enforced in OperationDock.tsx: a row with a view is not auto-dismissed after the toast duration (the reader closes a window, not a timer), and a source that ends a session releases whatever that window cached from its own acknowledge. useOperationDock().open(id, pluginName, { show: true }) tracks the row and then opens its view straight away, for work that IS a window (an opened terminal) rather than a progress bar the reader can wait out in the dock.

Core memory dreaming is a separate source of the same DockOperation union, kind memory-dreaming. It reads the authenticated /memory/dreaming/operations list under the shared memory-dreaming query prefix, so existing memory SSE and reconnect invalidation refresh start and terminal outcomes. Both automatic and manual personal runs appear on desktop and phones. Each completed-batch checkpoint refreshes reviewed/initial-total progress and its percentage; a zero initial total stays indeterminate. The source maps completed to succeeded and other terminal states to failed, keeping the translated lifecycle label. Inspection reuses the shared operation dialog and links to /account?cat=memory#accountMemory.dreaming.overview; acknowledgement writes the run's existing table. The list is bounded to 25 own live or recent unacknowledged runs. No result means no item, and a failed refresh retains the last successful list. No additional poll, connection or browser persistence exists.

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. Its progress ring is a lazy chunk. A lazy import in shell chrome must carry its own Suspense boundary (next/dynamic with loading or ssr: false, or React.lazy inside <Suspense>): the app-wide boundary in LoginGate has a null fallback and hides every open overlay while it waits, which is what a bare dynamic() ring did when Run now started a pass with Account open.

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.

Shared preferences and navigation

web/lib/effectsPreference.ts owns the importless effects types, storage key, validation, default, resolution and NO_FLASH_EFFECTS script used before hydration. Auto follows OS reduced motion; explicit full/reduced/off win. Unavailable storage uses auto. EffectsProvider uses usePersistentState and preserves the bootstrap resolution until hydration completes. General quiet CSS reads resolved data-effects; shared sheet/card/sidebar/table/composer rules add no competing OS override.

usePersistentState returns value, setter and hydration-complete flag. SSR fallback survives until the mount read; completion is reported even without accessible storage. Effects validates its enum there; UI scale validates/clamps numeric input at its own boundary and owns root zoom and --ui-scale.

entryIsActive highlights live navigation rows; useSidebarRoute selects pathname and supported cat-query address on arrival and announced search changes. Fragments are stripped, with row anchors still owned by useRowAnchor. Drag/keyboard moves share reorderNavEntry through reorderTo; the context menu has pointer, keyboard and hidden-entry recovery entry points, without host long press. NAVIGATION_WORLDS provides live data directly; the document-title matcher remains separate. SidebarNav and TopBar share the search shortcut hint.