ChatPanel and the M2 draft-review surface in
@stralos-co-nz/ui-assistant, plus the AppFrame integration in @stralos-co-nz/ui. All components are fully prop-injected — they contain
no API client and no domain imports.
AppFrame trailingActions
AppFrame accepts an optional trailingActions prop (ReactNode). The slot is rendered in the header’s right cluster, after the colour-mode button, immediately before the right edge.
ChatPanel
ChatPanel is a right-side Drawer that renders the full assistant conversation UI. It is responsive-modal: it behaves as a modal dialog (focus trap, backdrop, scroll lock) at the base breakpoint, and as a non-modal click-through panel at sm and above so the user can navigate the app while the panel stays open.
Props
Focus management
- When the panel opens, focus moves to the composer textarea.
- When the panel closes, focus returns to the element that had focus before the panel opened (typically the header toggle button).
- The non-modal path (desktop) performs its own focus save/restore because Chakra UI does not manage focus in non-modal drawers.
Header controls
The panel heading reads Doug, with AI assistant below it.
Context chip
Whencontext carries any populated fields, a teal badge is rendered below the heading. It shows a ·-joined string of: company name, project name, estimate label (when inside a project), and role. The badge carries an aria-label of Doug's scope: <value> for screen readers.
Message types
ChatMessage
ToolProgressLine
Rendered as a small icon + text row above the message content.CitationRef
Citations are rendered as labelled chips below message content (§5.8). Links are classified before rendering — only known app routes andhttps://help.stralos.app/ URLs become navigable.
Link safety
All links rendered inside assistant messages — both inline markdown links and citation chips — are gated by a sharedclassifyLink function.
Protocol-relative paths (
//…) are rejected by a separate url.startsWith("//") guard.
Markdown rendering
renderAssistantMarkdown is a dependency-free, safe-by-construction markdown renderer. It supports a subset of Markdown:
- Headings at
###and####only (#and##downgrade to bold text;#####and deeper are treated as plain paragraphs). - Fenced code blocks (
```). - Unordered and ordered lists.
- Inline: bold (
**), italic (*), inline code (`), and safe links.
dangerouslySetInnerHTML. Partial or streaming input degrades gracefully — an unterminated fence or emphasis falls back to plain text rather than breaking. Input is capped at 20,000 characters.
Conversation history
ChatConversationSummary carries a title (server-derived from the first user message, truncated) and an updatedAt ISO timestamp rendered as relative time (for example 5m ago, 2h ago, 3d ago). The relative formatter is built into the component group with no external date library dependency.
The history list virtualises the conversations returned by the controller’s first list request. The API is keyset-paginated (20 conversations by default), and the controller does not currently follow nextCursor, so older conversations beyond that first page are not shown in the panel.
Composer
TheComposer sub-component is a controlled auto-growing textarea:
- Enter submits; Shift+Enter inserts a newline.
- Empty or whitespace-only input is a no-op.
- The textarea grows to a maximum of ~5 visible rows, then scrolls.
- While
isTurnRunningistrue, the textarea is disabled and a Stop button replaces the default Send button. - A caveat line — Answers can be wrong — verify important details. — is rendered below the composer at all times.
DraftReviewCard
DraftReviewCard renders an assistant-proposed record mutation for review before a target service
is called. It is collapsed by default, and Accept remains disabled until the user expands and
reviews the fields at the current draft version. A version change invalidates any open review, edit,
or reject interaction immediately.
Pending drafts can expose typed inline edits and an optional rejection reason. Update drafts show a
formatted before/after diff. Admin-gated tools, including create_rate_item, display the required
role. Candidate variation lines are informational only; accepting the parent draft does not write
those lines automatically.
Follow-up notes on accepted cards
An accepted card can include a follow-up note when the underlying write succeeded but a subsequent step still needs attention. The Done — … line remains visible so the note does not imply that the write itself failed.
The shell currently uses warning notes for a failed assessment run or a baseline that changed after
the pre-assessment. It uses an information note when the assessment is still running. Cards without
a follow-up are unchanged.
The shell injects
onAcceptDraft(draftId, ifVersion, force?),
onRejectDraft(draftId, reason?), and onEditDraft(draftId, body, ifVersion). The component has no
API client and never writes directly.
Shell integration
The assistant panel and its conversation controller now live instralos-feature-assistant, not
the shell. The shell’s role is thin-host: it mounts the assistant widget remote into the
appframe-aside slot, reads the availability flag off the bridge the remote publishes, and persists
the toggle open/closed state.
The shell is responsible for:
- Loading
assistant/availabilityeagerly so the toggle can render without pulling the panel. - Mounting
RemoteWidgetSlotwithslot="appframe-aside"into the App aside, gated on first open. - Passing
AssistantToggleButtonastrailingActionsonAppFrame. The button renders only when the availability bridge reportstrue. - Reading and writing toggle state in
sessionStorageunderstralos.assistant.toggle.
ChatPanel component, its props, callbacks, and SSE turn loop are now owned by the
stralos-feature-assistant remote. The shell no longer holds AssistantController,
assistantClient, draftModel, or turnReducer.
AssistantWidget accepts two required props: open (the shell’s toggle boolean) and onOpenChange (called when the panel closes itself).
The assistant API base (resolved inside the remote) follows this order:
window.__STRALOS_API_URLS__.assistant.sameOriginFallback("http://localhost:3307").

