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.
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.
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 totrue; the service’s503on 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 === 1before use.
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:
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 readsstralos.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 twowindow 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.
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:
"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 touseSyncExternalStore.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 (anaccepted 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, andbuild: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.
