> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Doug assistant frontend

> stralos-feature-assistant widget remote — architecture, host contract, and build pipeline

`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

| Module | Responsibility |
| - | - |
| `AssistantController.tsx` | The panel component — renders the shared `ChatPanel` from `@stralos-co-nz/ui` and wires the turn state machine, conversation state, scope context, draft callbacks, and conversation history into its props. Header controls and scope display are delegated to `ChatPanel`. |
| `appBarInset.ts` | Measures the bottom edge of the host's app bar and tracks it via `ResizeObserver` while the panel is open, so the panel opens below the bar rather than over it (F-SHELL-28). |
| `assistantClient.ts` | All network calls to `/api/assistant/*`. Takes the session through `stralos.auth.*` and `window.__STRALOS_AUTH__`. |
| `useConversationHistory.ts` | Cursor-based conversation list loader. Connects producer scroll-position requests to `listConversations(cursor?)` and manages in-flight deduplication, refresh invalidation, and access-refusal clearing. |
| `draftModel.ts` | Converts wire shapes into `DraftReviewCardModel`. Pure helpers with no network. |
| `turnReducer.ts` | Immutable turn state machine — converts SSE events into `ChatMessage[]`. |
| `conversationState.ts` | The remote half of the sessionStorage key split (owns `stralos.assistant.conversation`). |
| `availability.ts` | Availability store published on `window.__STRALOS_ASSISTANT__` for the shell toggle. |

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.

```tsx theme={null}
<RemoteWidgetSlot
  slot="appframe-aside"
  manifest={remotes.manifest}
  degraded={remotes.degraded}
  widgetProps={{ open: assistantOpen, onOpenChange: setAssistantOpenPersisted }}
/>
```

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`:

| Prop | Type | Description |
| - | - | - |
| `hasMoreConversations` | `boolean` | Whether a further page is available. The producer uses this to decide whether to request more. |
| `isLoadingConversations` | `boolean` | Whether a history page request is in flight. |
| `onLoadMoreConversations` | `() => void` | Called by the producer when the conversation list is scrolled to its sentinel. The remote appends the next cursor page, deduplicates rows by ID, and updates both props. |

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

```ts theme={null}
type StralosAssistantBridge = {
  version: 1;
  getSnapshot(): { available: boolean | undefined; revision: number };
  subscribe(listener: (snapshot: { available: boolean | undefined; revision: number }) => void): () => void;
  refresh(): Promise<void>;
};
```

* `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__`:

| Member | Status | Used for |
| - | - | - |
| `currentRoute(): { path, routeId?, projectId?, estimateId? }` | Published | Per-turn context and the context chip |
| `navigate(path)` | Published | Server `ui_action{navigate}` and accepted-draft deep links |
| `isKnownRoute(path): boolean` | Published | Gates every rendered link and every server navigation |

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`:

```
/rates
/projects/:projectId/estimates/:estimateId
/projects/:projectId/estimate
/projects/:projectId/delivery/schedule
/scheduler
/projects/:appliedRecordId
```

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

| Key | Owner | Shape |
| - | - | - |
| `stralos.assistant.toggle` | Shell — **new key** | `{ orgId, open }` |
| `stralos.assistant.conversation` | This remote | `{ orgId, conversationId }` |
| `stralos.assistant.panel` | Legacy, read-only | The old `{ orgId, open, conversationId }` |

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.

```ts theme={null}
window.addEventListener("stralos:assistant-access-refused", () => {
  // The panel has self-cleared. Re-evaluate whether to render the toggle.
});
```

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:

| Field | Type | Description |
| - | - | - |
| `version` | `1` | Schema version. Check before reading other fields. |
| `path` | string | `window.location.pathname` at the time of the request. Only fired when the page has not navigated away since the request was issued. |
| `kind` | `"read"` \| `"edited"` \| `"refused"` | `"read"` for a successful `GET` or `HEAD` (except `GET /api/global/projects/:id` and `GET /api/global/project-directory`); `"edited"` for a successful mutating method; `"refused"` for a `403` or `404`. |

```ts theme={null}
window.addEventListener("stralos:project-request", (event) => {
  const { version, path, kind } = (event as CustomEvent).detail;
  if (version !== 1) return;
  if (kind === "edited" || kind === "refused") {
    // Revalidate cached assistant data for `path`.
  }
});
```

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.