Skip to main content
stralos-feature-assistant is the Module Federation widget remote that owns Doug’s assistant panel. It is a manifest v2 kind: widget remote with slot: appframe-aside. The shell keeps the toggle button and renders the aside slot; this remote exposes the panel, the controller, and the availability bridge. The remote is built independently of the shell. Current shell source implements the widget host contract, and stralos-core includes the assistant descriptor in its schema-v2 manifest seed and dev reconciliation set.

What this remote owns

The shell retains only the header toggle and user-menu entry, the availability-bridge consumer, and toggle-state persistence. The remote owns the panel, controller, API calls, conversations, and draft state.

Exposed entries

The remote exposes two modules from one container:
  • ./widget — the full panel. Importing this entry also publishes the availability bridge.
  • ./availability — the availability bridge alone. The shell loads this eagerly so the toggle can read the kill switch without loading the panel.
Both entries share one availability store, so it does not matter which arrives first.

Host contract

The current shell implements these five integration boundaries.

1. Render the widget in the aside slot

Inside the authenticated layout, the shell mounts the assistant as an AppFrame sibling rather than a route. RemoteWidgetSlot loads assistant/widget, applies the exported RemoteProvider, and passes the required open and onOpenChange props.
These props are required. open is the host’s toggle state. onOpenChange is called when the panel closes itself (close button, Escape, outside click at the modal breakpoint). A Router must be present in the tree — the panel calls router hooks against the host’s Router context. Three additional props wire the conversation history loader into ChatPanel: Two optional props, pillHidden (boolean) and onPillHiddenChange ((hidden: boolean) => void), let a host that renders its own floating “Talk with Doug” button offer a hide/show control in the panel header. The panel shows the control only when onPillHiddenChange is supplied. The current shell has no floating button and passes neither prop, so no control appears. No pagination button is added to the product. The producer drives loading by scroll position; this remote responds to each scroll-to-sentinel event. Pass insetTop as a CSS length (for example "76px" or "var(--app-bar-height)") to shift the panel’s top edge below the host’s app bar on non-modal, sm+ breakpoints. This keeps the project switcher, organisation, and user menu visible and usable while the panel is open. The modal arm on phones always fills the full screen regardless of insetTop. Wrap the mount in Suspense and an ErrorBoundary with a per-feature fallback. Mount it once and keep it mounted for the lifetime of the authenticated session. End sessions with a hard reload rather than an in-place organisation change — the controller initialises once and does not reset on an organisation change. Render nothing while available is undefined. The widget renders null on its own; this applies to the toggle button. When the panel opens on an empty conversation, Doug greets the user in an assistant bubble, with example prompts below it. The greeting is drawn client-side only; it is never sent or stored.

2. Read window.__STRALOS_ASSISTANT__ for the toggle

  • available === undefined — not yet known; render no toggle.
  • available === true — show the toggle. A failed config read resolves to true; the service’s 503 on the turn path is the authoritative kill.
  • getSnapshot() returns a referentially stable object until something changes.
  • subscribe(listener) replays the current snapshot synchronously and triggers the first config read.
  • Check version === 1 before use.
Import assistant/availability eagerly to publish the bridge before the panel loads.

3. Add isKnownRoute to window.__STRALOS_NAV__

The shell publishes isKnownRoute() before mounting remotes. If an older or standalone host does not expose it, the remote fails closed: no accepted-draft deep link or server-authored ui_action{navigate} is allowed through unchecked. Fields the remote reads on window.__STRALOS_NAV__: When the current URL matches /projects/:projectId/estimates/:estimateId[/...], the remote reads estimateId (and projectId, when the host matched none) directly from the path. A path-sourced estimateId takes precedence over one published via setActiveContext, because the path names the estimate open in this tab and survives a tab switch. The setActiveContext-published value is still used on any path that does not match the canonical template. The paths this remote can emit via resolveDeepLink are a closed set. The shell asserts each against its route catalogue through isKnownRoute:
For work-item drafts, resolveDeepLink first tries the canonical path (/projects/:projectId/estimates/:estimateId) when the draft names an estimate and isKnownRoute confirms the shell knows it. If the shell does not yet publish isKnownRoute, or if the canonical path is not in the catalogue, it falls back to the bare /projects/:projectId/estimate, which opens the project’s primary estimate.

4. Split the stralos.assistant.panel sessionStorage key

The shell writes to stralos.assistant.toggle rather than the old combined key. The remote reads stralos.assistant.panel once as a migration for mid-conversation sessions that pre-date the split, but only after the widget chunk loads on first open. A host that writes { orgId, open } over the old key at first render destroys the conversationId before the migration can run.

5. Keep publishing the existing auth bridges

The remote reads stralos.auth.accessToken, stralos.auth.user, window.__STRALOS_AUTH__.refreshAccessToken(), and window.__STRALOS_API_URLS__. None of these changed. The shell publishes window.__STRALOS_AUTH__.clearSession(). The remote calls it before redirecting after an unrecoverable second 401; a standalone host without the bridge falls back to removing the three documented session keys.

Browser event contract

The remote dispatches two window events as part of its integration boundary with the host workspace. Neither event carries bearer tokens, session data, or payload content.

stralos:assistant-access-refused

Fired by observeProjectRequest (in src/projectRequestEvents.ts) when any /api/assistant/* request returns 403 or 404. The AssistantController listens for this event and immediately clears the panel: the active conversation, turn state, conversation list, and project name cache are all reset and any in-flight SSE stream is aborted.
The event has no payload. It is safe to listen for it without loading the panel; assistant/availability publishes the bridge that gates the toggle, and the panel clears itself on hearing the event. The host workspace does not need to handle this event directly — the remote handles the clear internally. However, a workspace that shows its own UI around the assistant (for example, a loading indicator or a side-panel chrome) may listen to reset that state.

stralos:project-request

observeProjectRequest classifies responses on the same page load that made the request. It dispatches this event for a successful GET or HEAD (excluding GET /api/global/projects/:id and GET /api/global/project-directory), a successful non-OPTIONS mutation, or a 403/404 response; other outcomes do not emit it. The owner-lookup paths are excluded because a successful read of those endpoints is the same request the workspace’s own access check makes — reporting it would only queue a redundant re-check. Refusals and writes on those URLs still report. This event is the remote’s half of an additive contract: the host workspace is responsible for revalidating cached data; the assistant remote reports eligible outcomes. The event detail has version: 1 and the following fields:
The workspace uses this event to revalidate local caches after an assistant write or access denial without requiring the panel to know about workspace-level caching. Most revalidation logic will filter for "edited" and "refused".

Availability bridge contract

window.__STRALOS_ASSISTANT__ is installed by the remote on import (either entry). The bridge has version: 1:
  • getSnapshot() — returns { available: boolean | undefined; revision: number }. The object is referentially stable between changes and can be passed directly to useSyncExternalStore.
  • subscribe(listener) — replays the current snapshot synchronously before any later event and triggers the first availability read. Returns an unsubscribe function. Two subscriptions of the same callback are independent; each must be released separately.
  • refresh() — triggers a single-flight availability read and resolves when it completes.
revision is monotonically increasing. A single shared poll services all subscribers.

Progress, approvals and refreshing data

Conversation hydration restores saved tool progress alongside answers and draft cards. A confirmed acceptance (an accepted outcome, in the open panel) queues an automatic continuation using the draft ID (resumeDraftId); the API validates and consumes that continuation once. It runs after the current turn ends, while this conversation is open and the assistant is enabled, and the composer stays busy while it is queued. Reopening a conversation never resumes one, even when a hydrated draft still says continuationPending. Stop, new conversation, switching conversation, and refused access all clear the queue. The queue entry leaves only when the server answers. A continuation_unavailable drops it quietly. That covers already continued or no longer pending, whether it arrives as a 409 before the stream or in-stream after a lost late-claim race. The server claims just before the model call, and its SSE continuation_claimed {draftId} event is the definitive accepted signal. It removes the queue entry at once, and any later failure’s Retry is an ordinary message. For an older API that never sends it, the first assistant content or tool frame counts instead. A 409 turn_in_progress re-queues the continuation, at most three launches in all, 1.5 s apart, then shows an error. Any other failure before the server accepts it shows an error at once. In both error cases Retry re-sends with resumeDraftId. Once the server has accepted (claimed) the continuation, a Retry after any later failure is an ordinary message. Failed and ambiguous writes stay on their review card and never trigger a continuation. Stopping during a tool step cancels the tool-anchored message on the turn state. The panel shows Stopped with a Retry option. Whenever a write may have committed, the panel emits stralos:assistant-data-changed with { version: 1, draftId }. That covers an accepted or ambiguous applying outcome, a conflict whose re-read shows the draft accepted or applying, and a lost accept response. A refused discovered-operation redirect comes back as ambiguous applying, so it refreshes too. Nothing is written on a 409 operation_blocked (card shows rejected with the server’s reason) or on a 503 api_contract_unavailable (card stays pending with a retryable error). Neither refreshes or continues. This is an invalidation signal only; it carries no records or credentials. The estimate feature owns the corresponding in-place reads for estimate data, calculator lists, project rates, and pricing percentages. Other page owners must subscribe before they can refresh; there is no forced browser reload. The API still requires separate approval for every subsequent write.

Build and CI pipeline

The remote follows federation contract v2. The host provides the React-family singletons; UI, assistant UI, and domain packages are bundled from exact pins in the repository lockfile. Fonts belong to the shell, and build:remote rejects font assets in the remote output. build-remote.yml verifies pushes and pull requests. When called with deploy_dev: true, it injects PostHog chunk IDs with CLI 0.14.0 in event mode and retains hidden source maps. A merge to main runs deploy-dev.yml, publishes the by-SHA archive, and nudges the core reconciler. Publication does not activate the remote. The central release calls deploy-test.yml with from_dev: true to reuse the archived dev bundle for the release SHA. Manual test production can build instead. The workflow checks the write-once completion marker and by-commit pointer before production; allow_second_version_for_commit permits another version without rewriting the original pointer. For production, release-create.yml promotes the test bytes without rebuilding. Its advisory source-map job uploads maps to the selected environment’s PostHog project using the same CLI version and release mode as injection. Maps are excluded from the public serving copy. The per-repository release.yml workflow and archive-remote.mjs release command are retired. The size gate reads .ci/remote-size-baseline.json; a build more than 10% above it fails. A baseline increase above 10% requires remote-size-baseline: <new number> in the PR body.