diff --git a/bin/design-tokens.mjs b/bin/design-tokens.mjs
index 090a021ff..3ffadf3c2 100644
--- a/bin/design-tokens.mjs
+++ b/bin/design-tokens.mjs
@@ -32,6 +32,7 @@ export function designTokenSources(root) {
return [
...sources,
'packages/chat-ui/src/styles.ts',
+ 'packages/chat-ui/src/markdown-styles.ts',
'index.html',
'editor.html',
'history.html',
@@ -51,13 +52,15 @@ export function auditDesignTokens(root, generated = []) {
const foundation = read('public/ran-tokens.css');
const application = read('public/app-tokens.css');
const sharedChat = read('packages/chat-ui/src/styles.ts');
+ const markdownStyles = 'packages/chat-ui/src/markdown-styles.ts';
const errors = [];
const inspect = (file, source) => {
// Only CSS in HTML participates: text, SVG artwork, meta colours and script
// strings are not stylesheet consumers.
const css = authoredStyles(file, source);
const dependencies = file.endsWith('.html') ? [] : [foundation];
- if (!file.endsWith('.html') && file !== 'packages/chat-ui/src/styles.ts') dependencies.push(application);
+ if (!file.endsWith('.html') && file !== 'packages/chat-ui/src/styles.ts' && file !== markdownStyles)
+ dependencies.push(application);
if (file === 'styles/assistant.css') dependencies.push(sharedChat);
if (file.endsWith('.html')) {
for (const [, path] of source.matchAll(/]*href="\/([^"?]+\.css)"/g)) {
@@ -67,7 +70,13 @@ export function auditDesignTokens(root, generated = []) {
}
// The resizer writes this geometry onto the host at runtime. It is not a
// colour/spacing role and has a local fallback before the panel mounts.
- const external = file === 'styles/assistant.css' ? ['--agent-panel-width'] : [];
+ const external =
+ file === 'styles/assistant.css'
+ ? ['--agent-panel-width']
+ : // Shiki token colors are written onto safe spans at render time.
+ file === markdownStyles
+ ? ['--cui-token-light', '--cui-token-dark']
+ : [];
const missing = unresolvedTokens(css, dependencies, external);
if (missing.length) errors.push({ file, missing });
};
diff --git a/docs/design-system.md b/docs/design-system.md
index a61449f03..d18957122 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -213,90 +213,158 @@ Two rules are mechanical because both fail silently in review:
## Assistant application layout contract
-The canonical composition rules are owned by
-[ranui DESIGN section 11](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/DESIGN.md#11-composition--the-page-not-the-component).
-This section supplies the application's geometry and browser acceptance; the site's marketing
-column widths do not apply inside the assistant. `styles/assistant.css` owns the shared values.
-
-| Geometry | Product contract | Acceptance subject |
-| --------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
-| Content rail | 16px from the panel's inside edge | Header title text, onboarding copy, unframed copy, section labels, field/list outer frames |
-| Header title | Text-sized target with 8px inline padding; leading hit area extends into the gutter | DOM Range text left, actual button width, hover/focus area and neighboring actions |
-| Back navigation | Center a 16px SVG viewport in the 32px fine / 44px coarse pointer target; the viewport returns to the content rail | SVG viewport and full target bounds, not painted path bounds |
-| Subpage heading | An explicit navigation column followed by an 8px gap; shared by every subpage and nested artifact state | Repeated title rail: 48px fine / 54px coarse, relative to the panel's inside edge |
-| Framed fields | Outer frame follows the content rail; internal text retains its control inset | Native frame and real ranui shadow parts are separate subjects |
-| Vertical groups | 8px internal, 16px group and 24px section spacing | Actual related-region edges; empty status paragraphs reserve no anonymous margins |
-
-The title's flexible trailing filler is layout space, not a clickable/hovering title surface.
-At panel widths up to 360px, the runtime header gives the readable title its own row above
-the five action slots. Coarse input retains 44px targets without constraining the title to
-an icon's flex basis. Back focus outlines use zero offset so their full 2px ring remains
-inside the scrolling view. Populated result lists keep a 16px boundary before Import.
-Back navigation is a declared hierarchy, not an unexplained extra body inset. Centered empty
-states remain centered within their content frame. Frame, text, icon viewport and hit area
-must not be substituted for each other during review. CSS token compliance alone cannot prove
-these relationships. Do not nudge individual pages or SVG paths to compensate for a shared
-layout error.
-
-Apply this contract to onboarding/chat, Models, Conversations, Sources and Artifacts, including
-list/import/editor, empty/populated states, statuses and disclosures. Layout owners retain
-callbacks, focus return, busy guards and drafts. New sibling views must join the same checks.
-
-`test/e2e/agent-layout-rails.spec.ts` and `test/e2e/lib/layout-geometry.ts` read actual text
-Ranges and layout frames after fonts and async view content settle. They cover 432px desktop
-panels, 360px/320px windows, English/Portuguese, both themes and coarse input, with nested
-artifact import/editor/return paths. The existing sidebar test also measures title text rather
-than its padded button rectangle. Relevant focus, navigation, workspace and source/artifact
-suites remain required alongside the geometry assertions.
-
-Acceptance must reject a text-only 8px drift even when the outer frame stays fixed, a title
-target expanded into free toolbar space, and a sibling view shifted off its content rail.
-Review screenshots with these measured relationships; a stored screenshot without assertions
-or inspection is not acceptance evidence.
-
-### Settings command groups
-
-History & data is already a named section inside the assistant pane. Restore, Import and
-Export use content-sized native secondary buttons in one column: each button has a restrained
-resting surface and functional inset stroke, with no enclosing group frame, group fill,
-raised shadow or row separators. Keep 8px between buttons and 16px before deletion commands.
-A recognizable button shape must be visible before hover in both themes and on touch screens;
-verb labels and hover feedback alone do not establish initial discoverability.
-
-The declared columns are 16px from the panel’s inner edge for both checkbox and 16px icon
-viewports, and 40px for checkbox/action text Ranges. Each command target extends 8px into the
-owned inline-start gutter, with 8px inline padding and icon/label gap; target frames need not
-match visible columns. Keep targets inside the pane, at least 44px high, and non-overlapping.
-Long translations wrap without shifting the columns or overflowing the pane. The save-history
-checkbox uses the monochrome primary role.
-
-Deletion commands share the secondary control shape with readable danger text/icons; only
-explicit confirmation has a solid danger fill. Hover, press, disabled and keyboard focus
-remain distinct. Draft, confirmation, busy and focus-return behavior remain owned by the
-existing controls. Acceptance checks actual renders as well as shared checkbox/icon/text
-geometry, resting surface difference from the canvas, text contrast and real interactions.
-
-Review boundaries in the full pane. The panel/editor edge owns the independent sidebar;
-the search/input frames identify editable values; the single History & data section divider
-separates task regions; button surfaces identify action targets; keyboard outlines identify focus.
-An inner card or more row rules
-would repeat those relationships. Models picker frames, Sources search/attachment controls
-and Artifacts form fields keep their functional control boundaries. Do not remove every
-border globally, add transparent borders to preserve obsolete card insets, or require a
-decorative group outline in acceptance.
-
-### Models, Sources and Artifacts
-
-Apply the same boundary review to each complete view and its expanded states:
-
-| Surface | Flat composition | Functional boundaries retained |
-| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Models | Device/service containers, cached entries and preferences use headings and the existing group gap; no extra disclosure/row rules | Model/source pickers, endpoint/custom fields, focus and explicit load/connect/delete states |
-| Sources | Separate the attached list from search by 16px; keep removal beside the title while the excerpt expands; no result card or remove-button outline | Search input, native disclosure and bounded excerpt scrolling; Attach file is the primary entry action |
-| Artifacts | Full-width saved-result rows; quiet ordinary review/export/page/revision controls; no blanket button outlines | Import/Save/Use in draft primary roles, editable fields, structured table row relationships, revision input/diff, focus and explicit destructive confirmation |
-
-Use application danger foreground roles for quiet destructive labels and their hover/active
-states. Disabled commands keep their own disabled role and do not inherit an enabled fill.
-Do not infer that every control is a secondary outlined button from its ancestor view class.
-Role-specific styling belongs to the actual action. The full state inventory, renders and
-verification are recorded in [the workspace boundary audit](explorations/2026-10-20-workspace-boundary-audit.md).
+ranui DESIGN (composition), INTERACTION and ACCEPTANCE supply the component and interaction
+rules. This section owns Document's assistant geometry and task acceptance; marketing column
+widths do not apply inside the editor. Shared application values belong in `styles/assistant.css`;
+ChatView and artifact presentation keep their existing owners.
+
+| Geometry | Product contract | Acceptance subject |
+| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| Dock and expansion | Default dock 480px, minimum 320px; temporary expansion up to 1040px while retaining at least 360px for Office on desktop | Actual panel/editor bounds, resize separator, saved dock preference and narrow viewport clamping |
+| Content rail | 24px inside normal desktop surfaces; 16px on narrow 320px/360px layouts | Header title text, unframed copy, section labels and field/list outer frames within each surface |
+| Header | One 48px fine-input / 52px coarse-input row: content-sized Chat/home, Artifacts, Conversations, New, Expand/Collapse and Close | Text/icon rail, target bounds, current context, accessible names and overflow |
+| Split workbench | Only the selected Artifacts route at assistant width >=840px: Chat left, artifact list/detail right; minima 320px/480px, one 1px divider, extra width distributed 2:3 | Independent scroll regions, column bounds, focused surface and preserved artifact state |
+| Reading measure | Single-surface Chat centers a column capped at 680px; prose caps at 68ch, with 16px body copy on ordinary desktop panes | Actual text measure, heading/list rhythm, wide code/table containment and long localized content |
+| Nested return | Artifact-owned guarded Back lives in the artifact region in split mode and the single shell return slot in compact mode | One operation/control, destination, Escape, dirty/busy guards and focus restoration |
+| Quiet commands | Visible leading content follows the surface rail; padded hit frames may extend into its gutter | Text/icon/disclosure starts, wrapped rows and separators measured separately from hit frames |
+| Fields and groups | Field frames follow the surface rail and retain internal control insets; 8px internal, 16px group, 24px section gaps | Real frames/shadow parts and painted region edges; empty statuses reserve no anonymous margins |
+
+Expansion never overwrites the dock preference. Manual pointer/keyboard resize exits expansion
+and deliberately updates that preference; window resize clamps presentation only. At narrow
+widths use the viewport when a 360px document remainder cannot fit. Hide Expand when it cannot
+increase useful space. At 320px touch the remaining four icon commands and content-sized title
+must fit with non-overlapping 44px targets. Fine-input header commands use 32px targets; coarse
+input uses at least 44px. The title's trailing filler is layout space, not a clickable surface.
+Keep focus separate from route selection; there is no second destination strip.
+
+Artifacts remains the selected route while Chat is its visible companion. Selecting Chat
+explicitly returns to the centered single-surface reading view. Below the split threshold show
+the selected surface. Width-only transitions do not run route-leave cancellation or artifact
+refresh: retain the mounted owner, selected artifact/version, mode, drafts, text selection and
+reading position. If collapse hides the focused Chat surface, move focus to a visible destination
+without discarding its saved selection. Sources, Models and Conversations retain their routes.
+Returning to Chat restores the displayed conversation and draft rather than switching sessions.
+Open Chat without a configured model; loading/downloading/connecting remains explicit and does
+not precede unrelated views. Preserve source/consent and request/session lifetimes.
+
+An artifact list returns to displayed Chat. Artifact detail/import returns to its list using the
+existing artifact-owned button, relocated rather than cloned. In split mode this local Back does
+not replace the global Chat identity; in compact mode it occupies the shell return slot and
+suppresses the competing generic return. Its localized label, busy registry and dirty confirmation
+stay with the artifact owner. Escape invokes the same guarded operation and consumes a disabled
+or pending return. Cancel restores its initiator; discard returns to the list heading. Nested
+artifacts show their object identity without a redundant collection heading. Keep user-authored
+body headings intact. Focus outlines remain inside their targets.
+
+### Composer and commands
+
+The composer is one framed draft with attachment context, Sources, microphone and stable Send.
+Stop is independently reachable, and the reader can prepare the next draft during generation.
+Sending clears only the submitted draft. Preserve IME, selection, queue promotion, cancellation
+and focus. The model/settings trigger belongs beside composition; narrow Sources may become an
+icon with the same accessible name. Do not add redundant setup/helper rows or hide recovery.
+
+Voice options extends the privacy disclosure to include language. Its trigger has a meaningful
+accessible name, and the language picker works without dismissing its parent. Keep live recording
+status and Cancel outside the disclosure. Permission errors and privacy guidance appear at the
+point of need; keyboard focus, Escape, nested popup selection and active dictation remain usable.
+The permanent language/privacy strip is replaced by this disclosure. Narrow/coarse layouts retain
+readable feedback and at least 44px targets without moving Send/Stop unpredictably.
+
+History & data remains a named command-list context. Restore, Import, Export and deletion use
+equal-width rows with visible verbs and leading icons. Resting rows use the pane canvas; enabled
+hover/press and keyboard focus provide feedback. Each command's 16px leading glyph follows the
+surface rail, with an 8px gap to its label; padded hit areas extend into the gutter. Keep 8px
+between commands and 16px before destructive actions. Long labels wrap within their targets.
+Use the actual pane canvas for resting text-contrast checks and the painted surface for interaction
+states. Disabled commands retain their disabled role.
+
+Import and Attach open workflows with secondary affordance. Save version commits a local version;
+accepting a refinement changes the draft; Review in document opens native review. Their labels
+and feedback must communicate these different outcomes. Primary fill marks a form commit or
+reviewed acceptance. Quiet destructive labels/icons use application danger roles; only explicit
+confirmation has a solid danger fill. Deletion reveals and focuses Cancel; cancellation returns
+to Delete. Confirmation actions retain an 8px gap, including when wrapped.
+
+Model pickers, endpoint/custom-model fields, Sources search/attachment controls and artifact
+fields keep their functional boundaries. Device/service containers, cached entries and preferences
+use headings and group gaps rather than nested decorative cards. Related model/attachment helper
+copy sits 8px from its control; separate model actions/custom fields use 16px. Block-layout select
+hosts avoid anonymous baseline space. Sources keeps a 16px gap between attachment list and search;
+removal stays beside the filename while excerpts expand in a bounded region. Removing a source
+focuses the next Remove, then the previous one, then Attach if empty. Filename/Remove share their
+first-line center and label role. Populated artifact lists keep 16px before Import; empty states
+remain centered within their content frame. Do not remove boundaries globally or add transparent
+borders to reproduce obsolete card insets.
+
+### Conversation, public reasoning and artifact canvas
+
+Use compact user messages, quiet answer actions and coherent heading/list/table rhythm. Never
+replay received tokens as decoration. Render only provider-exposed public reasoning in native
+`r-reasoning`; keep it separate from answers, copy, saved history/artifacts, document review and
+subsequent requests. Do not enable extra provider thinking by default. Cap transient reasoning
+at 64KiB UTF-8 per run, preserving Unicode chunk boundaries. Reader disclosure choice takes
+precedence over streaming updates. Expanded content does not repeat its summary in the header;
+collapsed content may show the latest summary. End reasoning on answer/settlement/Stop under
+the same generation and conversation guards as answer delivery.
+
+Chat and artifacts share one bounded safe Markdown table/code presentation. Preserve safe-link
+and literal-media policy: no passive remote media or invented execution capability. Tables use
+quiet headers, restrained row separators and tabular numerals, preserving explicit column
+alignment. Wide tables/code stay in named keyboard-scroll regions without document-wide overflow.
+Code uses a compact language/Copy toolbar and readable mono viewport. Preserve tabs, blank lines,
+trailing newline, native selection and scroll across streaming. Clipboard feedback reports actual
+success/failure and belongs to its source snapshot; a delayed result cannot mark changed content
+as copied. Only completed supported blocks up to 8000 characters are lazily highlighted; unknown
+or unfinished code remains literal. Theme changes preserve font metrics and selection. Release
+copy timers/late token work on unmount and update localized controls in place.
+
+Artifacts open in Preview with a compact object/title/version/mode toolbar, readable canvas and
+coherent command/footer region. Structured content retains a readable presentation and disclosed
+source JSON. Explicit Edit reveals canonical input/textarea. Preview may reflect an unsaved draft
+without saving it. Save version creates an explicit version and returns to Preview. Preserve
+version conflicts, dirty navigation/version guards, late-save cancellation and raw source data.
+Multiline AI comparisons use labeled Original/Proposed blocks with line breaks, bounded reading
+regions and explicit expansion; short changes retain inline differences. Refinement produces an
+editable draft; applying it still requires native document review. Export discloses real formats.
+Keep deletion confirmation nearby and preserve native Review/Apply/Undo guards.
+
+Use existing ran fast/base motion roles for meaningful state changes. Native reasoning disclosure
+owns interruption/reversal; expansion and press feedback remain interruptible. Reduced motion
+preserves every state/action without interpolation. Focus/content never wait for decoration.
+Do not animate palette properties on theme change, shimmer streamed text or transform ancestors
+of portaled overlays.
+
+### Browser and task acceptance
+
+Apply the contract across Chat/onboarding, Models, Conversations, Sources and artifact list/import/
+editor, including empty/populated, busy, error, confirmation and disclosure states. Keep independent
+geometry expectations ahead of implementation-derived assertions. The geometry helpers in
+`test/e2e/agent-layout-rails.spec.ts` and `test/e2e/lib/layout-geometry.ts` measure actual text Ranges
+and frames after fonts and async content settle. Update obsolete 432px/40px expectations against
+this contract. Cover 480px dock, expanded Chat/Artifacts, resize across 840px, 320px/360px touch,
+English/Chinese/Portuguese, both themes, keyboard/focus and reduced motion. Preserve source,
+security, streaming, reasoning, queue, dictation, navigation and artifact behavioral regressions.
+
+Acceptance must reject text-only 8px drift while its frame stays fixed, a title target consuming
+free toolbar space, sibling rail drift, overflow and stale asynchronous copy feedback. Measure
+text, glyph, frame and hit region as different subjects. Token compliance or sibling equality
+alone is insufficient. Inspect full-editor and pane screenshots and continuous recordings of
+waiting/streaming/Stop/error, disclosure, preview/edit/dirty/save, refinement, export and native
+review. Bind evidence to source/dependency/build/media hashes and viewport/theme/locale/state.
+Disclosed transport fixtures prove real UI behavior and isolation, not real-model quality.
+Screenshots stored without inspection do not establish acceptance.
+
+Use task walkthroughs informed by _Don't Make Me Think_: identify the current conversation/
+artifact/version, find the next destination, resume a draft, choose voice language, distinguish
+Save version from refinement acceptance and document review, and recover from cancellation/error.
+Observe whether context, destinations and action outcomes are understandable without explanation;
+put guidance beside the decision that needs it. Report the top three observed task problems with
+the triggering task, evidence, user impact and correction; report fewer if fewer were observed.
+Do not substitute arbitrary click budgets or counts of visual elements for task evidence.
+
+Label agent/automation walkthroughs as such. They can expose navigation, wording and state
+problems but do not constitute human usability tests or user acceptance. Record human test results
+only when real participants performed the tasks; distinguish them from automated checks and agent
+visual/code review. Retain relevant test/build/lint gates and head-specific CI inspection alongside
+media review, without inferring human approval from passing automation.
diff --git a/docs/explorations/2026-10-20-assistant-layout-acceptance.md b/docs/explorations/2026-10-20-assistant-layout-acceptance.md
index 738eb39d0..288253426 100644
--- a/docs/explorations/2026-10-20-assistant-layout-acceptance.md
+++ b/docs/explorations/2026-10-20-assistant-layout-acceptance.md
@@ -2,6 +2,10 @@
Date: 2026-10-20
+> Historical record. The owner rejected the history density and top-level Back hierarchy.
+> The [October 21 correction and actual rendered acceptance](2026-10-21-assistant-navigation-and-density-acceptance.md)
+> supersede the heading lane, history target extension and command sizing below.
+
## Result and scope
The assistant now shares a 16px content rail across its header, onboarding, Models,
diff --git a/docs/explorations/2026-10-21-assistant-conversation-craft.md b/docs/explorations/2026-10-21-assistant-conversation-craft.md
new file mode 100644
index 000000000..f13dc22a0
--- /dev/null
+++ b/docs/explorations/2026-10-21-assistant-conversation-craft.md
@@ -0,0 +1,47 @@
+# Assistant conversation and artifact craft acceptance
+
+Historical runtime source: `d6c0411372e168a5a1c124c82408b7499e7e3cc2`.
+Build SHA-256: `23a96f4182123707557f63f43762b563cdfcd91f25f9a9aaccb20561081123b0`.
+Upstream ran source: `72c44840707ee5dc88a3e60e5a4906f73a7fc98d` ([PR436](https://github.com/chaxus/ran/pull/436)).
+This visual approval is superseded: it missed duplicate artifact return controls and an 8px visible command-text offset. The original media and executed checks remain historical evidence. See the [corrective acceptance](2026-10-22-assistant-craft-correction.md).
+
+## Result and ownership
+
+One compact header owns Chat/home, Artifacts, Conversations, New and Close. Sources belongs beside composition; selected model/settings belongs below it. Returning preserves the displayed conversation, draft, selection and reading position. The content spine is 16px. Fine-input header height is 40px; coarse-input height is 52px with 44px targets. Narrow touch composition separates tools and execution into stable rows, keeping voice language readable while Stop and Send coexist.
+
+Only explicitly returned public reasoning enters native `r-reasoning`. It stays transient and bounded at 64 KiB UTF-8 per run, respects reader disclosure, and is excluded from answer copy, history/artifacts, document review and later provider context. No provider thinking defaults, model defaults or storage formats changed. The upstream correction keeps the open header quiet; a documented pnpm patch consumes it locally.
+
+Artifacts open in Preview. Explicit Edit owns the canonical draft; Preview does not save it. Multiline AI comparisons show separately labeled Original/Proposed text, bounded keyboard-scrollable regions and Show full. Short replacements retain inline differences. Acceptance updates an unsaved draft; Save version persists it. Dirty confirmation, stale results, cancellation, export, nearby deletion confirmation and native document review keep their existing owners.
+
+Industry references informed task placement and progressive disclosure, not branding, exact timings or parity claims: [Claude artifacts](https://support.claude.com/en/articles/17153992-what-are-artifacts-and-how-do-i-use-them), [ChatGPT navigation](https://help.openai.com/en/articles/10056348-finding-your-chats-projects-and-files-in-chatgpt).
+
+## Actual visual acceptance
+
+The local report contains 108 unmodified application screenshots: 27 states in four environments. They cover desktop English light, desktop English dark/reduced motion, 320px Portuguese touch/light, and 360px Portuguese touch/dark/reduced motion. All use the production build and deterministic public-provider NDJSON, without injected product CSS.
+
+Four actual recordings cover waiting, reasoning, streaming/completion, Stop/error, reader scrolling, history, artifact preview/edit/unsaved/version save, delete/export, format-limit recovery into native document review, and AI generation/suggestion/acceptance/explicit save. Scrolled narrow captures expose proposed text and acceptance controls. Independent review inspected the complete initial inventory, critical originals and decoded frames, then rechecked corrected comparisons and all four updated recordings.
+
+Native header pointer clicks 60ms apart interrupt the normal disclosure transition. Desktop height reverses before closing (minimum 63.71875px; closed 32px), as does touch (minimum 75.71875px; closed 44px). Reduced motion reaches discrete 32/44px states without intermediate heights. These are measured component results, not competitor timings. Brief closing-body/header overlap is an outgoing transition; the steady open header has no repeated summary.
+
+[Original panes, recordings and provenance](../media/assistant-conversation-craft/) are committed as representative evidence. The complete inventory remains in the local acceptance report.
+
+- [Normal desktop walkthrough](../media/assistant-conversation-craft/desktop-light.webm)
+- [Reduced-motion desktop walkthrough](../media/assistant-conversation-craft/desktop-dark-reduced.webm)
+- [320px touch walkthrough](../media/assistant-conversation-craft/pt-320-touch-light.webm)
+- [360px reduced-motion touch walkthrough](../media/assistant-conversation-craft/pt-360-touch-dark.webm)
+
+
+
+
+## Verification
+
+- Final runtime: 255 unit files, 6,878 tests passed.
+- Assistant browser coverage: 125 executed cases verified across full and affected runs; three existing conditional skips. Final affected run: 37/37 passed, including unchanged XLS/PPT overflow assertions, layout rails, touch execution, revisions, sources/imports, keyboard return and workspace states.
+- Final comparison/related unit subset: 77 passed. TypeScript, archive-aware lint, Prettier and immutable evaluation-archive checks passed. Production build passed with the identity above.
+- Docker configuration passed using installed standalone `docker-compose -f docker-compose.yaml config --quiet`; the local Docker CLI lacks its compose subcommand. This does not claim a local Docker-image run.
+- Upstream reasoning: 34 unit, 76 SSR and 10 Chromium/WebKit browser cases passed. Guidance/documentation/link/design checks passed; earlier guidance acceptance remains in PR436.
+- The prior review approval was withdrawn after the visible defects above were reported.
+
+## Limits and remaining observation
+
+Fixtures prove UI behavior and isolation, not real-model quality or competitor parity. Native Word insertion still rejects Markdown tables; the recording demonstrates localized plain-text recovery and a subsequent valid native review. No document changes without acceptance. Artifact-related failures still use Chat error/recovery presentation; clearer artifact association remains a nonblocking P3 observation. Package publishing and merging are outside this acceptance.
diff --git a/docs/explorations/2026-10-21-assistant-header-and-alignment.md b/docs/explorations/2026-10-21-assistant-header-and-alignment.md
new file mode 100644
index 000000000..662024950
--- /dev/null
+++ b/docs/explorations/2026-10-21-assistant-header-and-alignment.md
@@ -0,0 +1,132 @@
+# Assistant header and visible-content alignment
+
+Date: 2026-10-21
+
+Header/alignment figures below record `681e4f9`. The expanded-workflow follow-up at the end
+records the later spacing, target and focus corrections. These are scoped acceptance records,
+not a claim that overall visual hierarchy or motion has reached a final design.
+
+The previous header gave the assistant identity and every destination substantial visual weight.
+History headings started at 16px, while the saving checkbox and command icons started at 24px.
+Equal button frames therefore did not establish a shared leading-content spine.
+
+## Result and ownership
+
+The [application design contract](../design-system.md#assistant-header-and-settings-commands)
+owns the current dimensions and control roles. Five named destinations remain directly available;
+New, Close, nested artifact navigation, drafts and history state retain their existing owners.
+
+| Measured relationship | Before | Current |
+| ------------------------------------------------------- | -------------- | -------------- |
+| Fine-input title row / complete header and navigation | 56 / 97px | 40 / 77px |
+| Coarse-input title row / complete header and navigation | 60 / 113px | 52 / 101px |
+| Heading, saving checkbox and command icon viewport | 16 / 24 / 24px | 16 / 16 / 16px |
+| Command label and associated saving status | 48px | 40px |
+| Command target height, fine / coarse input | 32 / 44px | 32 / 44px |
+
+Values are CSS pixels from the pane's inside border edge. The click surface uses 8px of the
+existing gutter; the content spine remains 16px. History rows form a named command-list context
+without repeated resting frames. Framed workflow actions and inputs retain their affordances.
+
+The selected destination has a restrained fill distinct from the actual canvas in both themes,
+with no curved inset underline. Current labels are medium weight; inactive navigation is regular.
+Keyboard focus remains a separate blue outline. Palette properties do not animate on theme changes.
+
+## Industry references
+
+[Linear's 2026 refresh](https://linear.app/now/behind-the-latest-design-refresh) gives supporting
+navigation less emphasis and makes tabs more compact while retaining predictable headers/actions.
+[Cursor's side-chat and conversation-search update](https://cursor.com/changelog/side-chat)
+keeps creation at the top of chat and provides a dedicated conversation-search route. These inform
+hierarchy and placement; this application uses its existing ran roles and input-density contract.
+
+## Actual browser evidence
+
+The local current report contains 20 before/after pane pairs: Chat, Conversations, Artifacts,
+Sources and Models in English desktop light/dark and Portuguese 320/360px coarse-input variants.
+Four additional screenshots include browser-rendered alignment guides. The red guide marks 16px;
+the blue guide marks the 40px history label/status column only within that control group.
+
+The before pairs isolate header/alignment changes after the initial tab-underline removal. The
+[previous acceptance record](./2026-10-21-assistant-navigation-and-density-acceptance.md) retains
+screenshots of the earlier implementation, including the original selected underline.
+
+| Light header | Dark header |
+| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+|
|
|
+
+| Light history | Dark history |
+| -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+|
|
|
+
+[320px touch alignment guides](../media/assistant-refinement/history-guides-pt-light-320.png),
+[360px dark touch pane](../media/assistant-refinement/history-pt-dark-360.png),
+[Chat](../media/assistant-refinement/chat-en-light-1280.png),
+[Artifacts](../media/assistant-refinement/artifacts-en-light-1280.png),
+[Sources](../media/assistant-refinement/sources-en-light-1280.png), and
+[Models](../media/assistant-refinement/models-en-light-1280.png) show the same current chrome.
+Representative [before history](../media/assistant-refinement/before-history-en-light-1280.png)
+and [before Chat](../media/assistant-refinement/before-chat-en-light-1280.png) are retained.
+[Measured history variants](../media/assistant-refinement/measurements.json) record the actual
+coordinates and header heights, including the effective theme and selected fill.
+
+## Header verification at `681e4f9`
+
+- The independent page spine is 16px. Before the CSS correction, the cross-region assertion
+ failed with the command icon at 24px. It no longer accepts equal but separately inset groups.
+- Chromium: 14 navigation, layout-rail and history-action cases passed, retries disabled. Covers
+ wrapping, resize, hover/press/disabled, contrast, accessible names, focus/confirmation, export,
+ touch targets and rejection/restoration of deliberate geometry defects.
+- WebKit: all four navigation/craft variants passed, retries disabled, including 320/360px touch.
+- Selected fill and shadow are checked after moving the pointer away. Hover cannot supply the
+ selected affordance being verified. The fill must differ from the actual pane canvas.
+- Transparent history rows use the actual pane background for text-contrast measurement;
+ hover/pressed rows use their painted surface. Deletion and focus checks remain in place.
+- The design-token contract and changed-file formatting passed. The production preview was
+ rebuilt from the changed styles; screenshots use that build, not temporary product overrides.
+
+One intermediate run timed out while waiting for the native editor to become ready during
+concurrent screenshot capture. The subsequent complete serial run passed 14/14 with no retries,
+timeout increases or weakened tolerances. Firefox, native browser zoom and real model downloads
+are not claimed as verified by this focused UI correction.
+
+## Expanded-workflow follow-up
+
+Actual production-browser inspection found attachment helper gap 0, model helper gap 17
+(including inline-host baseline space), helper/action gap 12 and adjacent custom-field gap 0.
+The corrected relationships are 8/8/16/16. Artifact confirmation actions now retain 8px separation.
+Touch Chat starters measured 34.25px and now use the adaptive 44px target role. Source filename
+and Remove text centers differed by 6.58px; their shared label role and density-derived first-line
+padding now align without translating glyphs or changing the expanded excerpt's position.
+
+Keyboard removal moves to the next/previous visible source action or Attach when the filtered
+list is empty. Artifact confirmation focuses Cancel and cancellation returns to Delete, keeping
+the draft. A long artifact case verifies that the safe choice is inside the narrow viewport.
+These owners belong to document; no ran component-internal defect was established.
+
+| Before custom fields | Corrected custom fields |
+| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|
|
|
+
+[Sources after correction](../media/assistant-detail-audit/source-excerpt-pt-360-touch-dark.png),
+[Models in dark theme](../media/assistant-detail-audit/models-dark.png),
+[touch Chat targets](../media/assistant-detail-audit/chat-pt-320-touch-light.png),
+[artifact confirmation](../media/assistant-detail-audit/artifact-delete-confirmation-pt-320-touch-light.png),
+and [focus return after cancellation](../media/assistant-detail-audit/artifact-delete-cancelled-pt-320-touch-light.png)
+are representative full-pane renders. [Capture measurements](../media/assistant-detail-audit/captures.json)
+list 68 current states across English desktop light/dark and Portuguese 320/360px coarse variants.
+The local report retains 60 before/after pairs and 8 additional keyboard-focus captures.
+
+The new regressions failed before their fixes: two focus unit cases; browser helper/focus cases;
+confirmation gap 0vs8; touch targets 34.25 vs 44 and filename/Remove center drift 6.58 vs 1px tolerance.
+Existing geometry expectations, retries and timeouts were not weakened. Final verification:
+Chromium 31/31; WebKit 4/4 focused detail cases; unit 41/41; production build, TypeScript,
+archive-aware lint, changed-file format and diff checks. See `agent-detail-polish.spec.ts` for
+rendered spacing, physical targets and keyboard continuation; existing workspace/layout/source
+and affordance suites remain in the run.
+
+Overall hierarchy still needs design work: the five persistent destinations give configuration,
+content and history similar prominence, and artifact commands need a tighter relationship to their
+content. Current animation is limited; these static captures disable animation for geometry and
+are not motion acceptance. Native zoom, system text scaling, screen readers and real model
+downloads are outside this focused verification.
diff --git a/docs/explorations/2026-10-21-assistant-navigation-and-density-acceptance.md b/docs/explorations/2026-10-21-assistant-navigation-and-density-acceptance.md
new file mode 100644
index 000000000..e1fe47b47
--- /dev/null
+++ b/docs/explorations/2026-10-21-assistant-navigation-and-density-acceptance.md
@@ -0,0 +1,129 @@
+# Assistant navigation and density acceptance
+
+Date: 2026-10-21
+
+> Historical acceptance for the initial navigation revision (4743fbf). The current header,
+> selected-tab treatment and history leading-content geometry are documented in the
+> [header and alignment refinement](./2026-10-21-assistant-header-and-alignment.md).
+> Screenshots and numeric contracts below describe the earlier build.
+
+## Decision and scope
+
+Owner review rejected the previous History & data button cloud and the artificial
+Back/title hierarchy in Artifacts. This revision supersedes the 48/54px top-level
+heading lane, content-sized history commands and gutter extension described in the
+October 20 records. Passing the earlier geometry assertions did not establish
+that the complete composition was acceptable.
+
+The implementation follows [ran's design guide](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/DESIGN.md).
+[Linear's UI refresh](https://linear.app/changelog/2026-03-12-ui-refresh) and
+[Notion's navigation](https://www.notion.com/help/navigate-with-the-sidebar) informed
+stable parallel destinations and compact hierarchy. Colors, typography, spacing,
+control roles and motion remain owned by ran and this application's design contract.
+
+The original PR #289 merged while this revision was being verified. The correction
+therefore ships as a follow-up PR rather than republishing the deleted branch.
+
+## Actual rendered changes
+
+| Surface | Accepted composition |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| Header/navigation | AI Assistant, New and Close; five persistent text destinations beneath it; selected and keyboard-focus states |
+| Top-level views | All heading text starts at 16px; no Back to an unrelated parallel destination |
+| Nested artifacts | A labeled Back precedes the heading; import/edit, unsaved drafts and confirmation remain owned by the artifact view |
+| History commands | Equal-width frames at 16px; checkbox/icon column at 24px and label/status column at 48px; neutral resting stroke |
+| Density | 32px fine-input command minimum; 44px coarse-input navigation, checkbox-label and command targets; translated labels can wrap |
+| Command hierarchy | Import/Attach are secondary; reviewed Save/Accept remains primary; destructive commitment retains explicit confirmation |
+| Runtime status | Chat or active Models loading owns it; no unrelated preparation band above Artifacts or Sources |
+| Details | Blue keyboard focus, disclosure chevron, 150ms opacity entry and reduced-motion opt-out; no translated hit targets or blur |
+
+Before and after, captured in the same actual browser at a 1280px window / 432px pane:
+
+| Before | After |
+| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+|  |  |
+|  |  |
+
+## Screenshot inspection
+
+The final production build was captured in 72 full-pane PNGs: 17 states in English
+light/dark desktop and Portuguese 320px-light/360px-dark coarse-input variants.
+Four additional German/Spanish coarse-input captures check the localized current-Chat label
+and selected-navigation visibility. The 17 main-matrix states include Chat, expanded History, confirmation, empty/populated Artifacts,
+import, editor, local/expanded/service Models, empty/populated/expanded/error Sources
+and resized History/Sources. The narrow states explicitly resize the window to 360px.
+Screenshots use local synthetic text and no real provider credentials or inference.
+
+Main-agent inspection and independent visual review found no remaining alignment,
+clipping, overlap, focus-boundary or control-hierarchy blockers in the reviewed
+representative states. Inspection prompted correction of import hint density,
+coarse navigation targets and the inset navigation focus outline before these final captures.
+This is visual acceptance of the inspected states, not a general usability study.
+
+Additional final renders:
+
+| State | Real capture |
+| --------------------------------------------- | ----------------------------------------------------------------------------- |
+| History, dark | [PNG](../media/assistant-craft/2026-10-21-history-dark.png) |
+| Destructive confirmation, dark | [PNG](../media/assistant-craft/2026-10-21-history-confirmation-dark.png) |
+| Portuguese 320px touch History | [PNG](../media/assistant-craft/2026-10-21-history-pt-320-touch-light.png) |
+| Artifacts, dark | [PNG](../media/assistant-craft/2026-10-21-artifacts-dark.png) |
+| Nested import and visible keyboard focus | [PNG](../media/assistant-craft/2026-10-21-artifact-import-light.png) |
+| Nested editor, dark | [PNG](../media/assistant-craft/2026-10-21-artifact-editor-dark.png) |
+| Local model setup | [PNG](../media/assistant-craft/2026-10-21-models-light.png) |
+| Service model setup, dark | [PNG](../media/assistant-craft/2026-10-21-models-service-dark.png) |
+| Attached source | [PNG](../media/assistant-craft/2026-10-21-sources-populated-light.png) |
+| German 320px touch current-Chat navigation | [PNG](../media/assistant-craft/2026-10-21-chat-de-320-touch-light.png) |
+| Spanish 360px touch History, dark | [PNG](../media/assistant-craft/2026-10-21-history-es-360-touch-dark.png) |
+| Portuguese 360px touch attachment error, dark | [PNG](../media/assistant-craft/2026-10-21-source-error-pt-360-touch-dark.png) |
+
+## Behavior fixes and verification
+
+Persistent destination selection is idempotent. It neither acts as Back nor refreshes
+an already active form. A first-mount flag still runs initial visibility/ARIA setup.
+Explicit Chat returns to the displayed conversation and draft. Header and navigation
+intent also invalidate the initial asynchronous cache-discovery fallback, preventing
+it from switching an explicitly selected Chat back to Models. New conversation and
+title/recent-session behavior retain their separate existing semantics.
+
+Artifact refresh now respects an in-flight operation, including after an awaited
+list response, so a workspace refresh cannot replace the import/editor while saving.
+Owned delete/missing-record refresh remains available. Controlled async unit
+regressions first exposed these failures and passed after the fixes.
+
+| Check | Observed result |
+| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Full final unit suite | 252 files, 6,807 tests passed |
+| Focused final unit suites | 8 files, 360 tests passed |
+| Chromium navigation craft matrix | 4 passed, retries disabled |
+| Broad Chromium assistant regression | 85 cases: initially 79 passed, 3 skipped, 3 failed; final targeted rerun passed all 4 selected cases including the 3 failures |
+| WebKit history/layout/craft | 14 passed serially with retries disabled |
+| Firefox history/layout/craft | All 14 stopped at browser launch: `Could not find profile folder`; independent empty-browser launch reproduced it before visiting the app |
+| Lint/typecheck, formatting, production build | Passed; immutable archive verification retained all 1,511 evaluation artifacts |
+| Deliberate geometry mutations | Existing layout suite rejects shifted text, expanded title hit area and displaced sibling rails; a final live probe also rejected an 8px label-only shift with identical button bounds and passed after removing the mutation |
+| Motion probe | Actual computed animation `agent-view-enter`, `0.15s`, transform `none`; reduced motion gives animation `none`, `0s` |
+
+The first full unit run caught two untranslated current-Chat labels. German and Spanish
+labels were corrected without changing the translation-completeness test or its allowlist;
+the complete 252-file rerun then passed.
+
+A final production-preview Chromium run of content review, translated loading, PDF native
+contracts and the four craft variants passed all 18 cases without retries. It retained native
+readback, undo, PDF write restrictions and model-loading cancellation assertions.
+
+The broad Chromium failures comprised the real initial-navigation race fixed above
+and two tests navigating with the superseded same-destination toggle contract. Those
+tests now use explicit Chat/Sources navigation while retaining their source, dirty-draft,
+request, offline and cancellation assertions. They do not merely accept a new screenshot.
+The three skipped cases require unavailable public legacy-format corpus fixtures.
+The table reports separate runs, not one entirely green 85-case rerun.
+
+An earlier concurrent WebKit attempt emitted `WebGL: context lost`; the isolated
+14-case run passed without changing the console allowlist or application code.
+Firefox remains unverified in this environment; its launch failure is not counted
+as an application pass. The production build was completed before final capture;
+no rebuild ran while that browser acceptance was reading its assets.
+
+Native browser zoom, the full repository/Docker suite, actual model downloads and
+real external AI service calls were not verified by this focused revision. No
+vendor files, dependency versions, persistence formats or document-write contracts changed.
diff --git a/docs/explorations/2026-10-22-assistant-craft-correction.md b/docs/explorations/2026-10-22-assistant-craft-correction.md
new file mode 100644
index 000000000..857d48085
--- /dev/null
+++ b/docs/explorations/2026-10-22-assistant-craft-correction.md
@@ -0,0 +1,74 @@
+# Assistant return and Markdown correction acceptance
+
+Accepted runtime: `bd0e8c146bbde9b55a53ba04db1d95ff60affcd9`.
+Build SHA-256: `89f8d8bf62aaa8d1f594cad6fedbb3e9bc6c4d26ced2674b58dc0e21af58395d`.
+The earlier
+[visual approval](2026-10-21-assistant-conversation-craft.md) is withdrawn: it
+missed duplicate return controls and an 8px visible command-text offset. The
+original media remains historical evidence.
+
+## Corrected behavior
+
+The shell hosts the actual guarded artifact Back button. Detail and import
+return to the artifact list; the list returns to Chat. Escape follows the same
+hierarchy even from peer header controls. Dirty confirmation, safe Cancel,
+pending-save protection and restored focus/draft selection retain their owners.
+The compact visible destination remains readable on 320px touch panes and is
+included in its localized accessible name. Command text follows the 16px spine.
+
+Chat and artifact readers use the same safe table/code presentation. Tables
+honor declared column alignment, use regular body weight and tabular numbers,
+and scroll wide content locally. Ordinary four-column tables fit the desktop
+pane. Code has a compact language/Copy header, exact literal clipboard payload,
+real success/failure feedback and local horizontal scrolling. Supported completed
+blocks up to 8000 characters load fixed Shiki grammars and dual-theme token
+colors lazily; unknown/unfinished blocks remain plain. No execution action is
+implied. Completed nested code keeps selection, scroll and DOM identity when a
+stream settles. Disposal guards late clipboard/highlight work.
+
+## Actual acceptance
+
+The final build produced 92 original pane screenshots and 8 continuous recordings
+(about 18 seconds each). Root and independent review inspected all 8 state sheets,
+all 8 decoded recording sheets and critical full-size originals. No blocking
+composition or legibility defect remained. Representative [original panes,
+recordings and provenance](../media/assistant-craft-correction/) are committed;
+the complete inventory and full-browser originals remain in the local report.
+The environments are desktop English light/dark and Portuguese touch at 320px
+light / 360px dark. Dark variants request reduced motion. The deterministic
+provider fixture supplies public NDJSON only; no product CSS or DOM is injected.
+
+## Verification
+
+- 6,915 unit tests in 258 files passed with unchanged coverage thresholds: 90.54%
+ lines, 88.16% statements, 84.24% branches and 89.02% functions.
+- 38 distinct related browser cases verified:33 initial passes, 2 corrected visible-route
+ cases, and 3 reasoning/touch cases. No retries or overflow tolerance changes.
+- Archive-aware lint, root/workspace TypeScript, Prettier and 1511 immutable evaluation
+ artifact checks passed. The production build identity is recorded above.
+- Local report QA at 1280px and 320px decoded 94 displayed images and 8 recordings,
+ with zero horizontal overflow or JavaScript errors.
+- Spec, code and actual screenshot/decoded-recording review passed. Remote CI
+ follows the approved push and is recorded by the PR checks/local report.
+
+No thresholds or overflow tolerances were relaxed. The existing Stop regression still checks raw source, deferred parsing,
+focus, draft preservation and disposal; only its renderer-call signature now
+includes the explicit presentation owner.
+
+
+
+## Design references and limits
+
+The adopted reading hierarchy is informed by [Geist Table](https://vercel.com/geist/table),
+[Geist Code Block](https://vercel.com/geist/code-block),
+[AI Elements Code Block](https://elements.ai-sdk.dev/components/code-block),
+and [ChatGPT's public code-block guidance](https://help.openai.com/en/articles/20001246-working-with-writing-blocks-and-code-blocks-in-chatgpt).
+These references establish concrete presentation patterns, not competitor parity.
+
+The fixture proves UI behavior, not model answer quality. Native Word insertion
+still has its documented Markdown-table limit. Changing a still-growing unfinished
+fence retains the existing streaming renderer behavior; selection preservation
+covers completed/reused blocks and late highlighting. ran's existing reasoning
+and agent-guidance changes remain in [ran#436](https://github.com/chaxus/ran/pull/436);
+this correction belongs to the application's renderer and composition. No package
+publication or merge is included.
diff --git a/docs/media/assistant-conversation-craft/acceptance.json b/docs/media/assistant-conversation-craft/acceptance.json
new file mode 100644
index 000000000..078f78688
--- /dev/null
+++ b/docs/media/assistant-conversation-craft/acceptance.json
@@ -0,0 +1,19 @@
+{
+ "status": "superseded",
+ "sourceCommit": "d6c0411372e168a5a1c124c82408b7499e7e3cc2",
+ "ranCommit": "72c44840707ee5dc88a3e60e5a4906f73a7fc98d",
+ "summary": "The prior visual approval missed duplicate artifact return controls and an 8px visible command-text offset. Its approval is withdrawn; original screenshots, recordings and executed checks remain historical evidence.",
+ "tests": {
+ "unit": "6,878 passed; 255 suites; final source",
+ "e2e": "125 executed assistant cases covered across full + affected runs; 3 existing skips. Final affected run: 37/37 passed.",
+ "types": "TypeScript passed",
+ "lint": "Archive-aware lint and Prettier passed; docker-compose config validation passed",
+ "ran": "34 unit + 76 SSR + 10 Chromium/WebKit reasoning cases passed; guidance checks passed"
+ },
+ "limits": [
+ "Deterministic public NDJSON proves UI behavior and isolation, not real-model quality or exact competitor parity.",
+ "Native Word insertion still rejects Markdown tables; the recording demonstrates localized guidance and recovery into real native review with a plain-text draft.",
+ "Nonblocking P3: artifact-related failures still use Chat error/recovery presentation; their artifact association could be clearer."
+ ],
+ "correctiveAcceptance": "../../explorations/2026-10-22-assistant-craft-correction.md"
+}
diff --git a/docs/media/assistant-conversation-craft/answer-completed-desktop-light.png b/docs/media/assistant-conversation-craft/answer-completed-desktop-light.png
new file mode 100644
index 000000000..b37e1f66f
Binary files /dev/null and b/docs/media/assistant-conversation-craft/answer-completed-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/artifact-accepted-draft-desktop-light.png b/docs/media/assistant-conversation-craft/artifact-accepted-draft-desktop-light.png
new file mode 100644
index 000000000..75c192edc
Binary files /dev/null and b/docs/media/assistant-conversation-craft/artifact-accepted-draft-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/artifact-delete-confirmation-pt-360-touch-dark.png b/docs/media/assistant-conversation-craft/artifact-delete-confirmation-pt-360-touch-dark.png
new file mode 100644
index 000000000..4999d35f5
Binary files /dev/null and b/docs/media/assistant-conversation-craft/artifact-delete-confirmation-pt-360-touch-dark.png differ
diff --git a/docs/media/assistant-conversation-craft/artifact-document-review-desktop-light.png b/docs/media/assistant-conversation-craft/artifact-document-review-desktop-light.png
new file mode 100644
index 000000000..a9bb6f68d
Binary files /dev/null and b/docs/media/assistant-conversation-craft/artifact-document-review-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/artifact-edit-desktop-light.png b/docs/media/assistant-conversation-craft/artifact-edit-desktop-light.png
new file mode 100644
index 000000000..ff4aab81b
Binary files /dev/null and b/docs/media/assistant-conversation-craft/artifact-edit-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/artifact-preview-desktop-light.png b/docs/media/assistant-conversation-craft/artifact-preview-desktop-light.png
new file mode 100644
index 000000000..86b94fb3b
Binary files /dev/null and b/docs/media/assistant-conversation-craft/artifact-preview-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/artifact-suggestion-review-desktop-light.png b/docs/media/assistant-conversation-craft/artifact-suggestion-review-desktop-light.png
new file mode 100644
index 000000000..aa9a64b73
Binary files /dev/null and b/docs/media/assistant-conversation-craft/artifact-suggestion-review-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/artifact-suggestion-review-pt-320-touch-light.png b/docs/media/assistant-conversation-craft/artifact-suggestion-review-pt-320-touch-light.png
new file mode 100644
index 000000000..a7bc5342e
Binary files /dev/null and b/docs/media/assistant-conversation-craft/artifact-suggestion-review-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-conversation-craft/desktop-dark-reduced.webm b/docs/media/assistant-conversation-craft/desktop-dark-reduced.webm
new file mode 100644
index 000000000..82c2df2e6
Binary files /dev/null and b/docs/media/assistant-conversation-craft/desktop-dark-reduced.webm differ
diff --git a/docs/media/assistant-conversation-craft/desktop-light.webm b/docs/media/assistant-conversation-craft/desktop-light.webm
new file mode 100644
index 000000000..c8bf6e260
Binary files /dev/null and b/docs/media/assistant-conversation-craft/desktop-light.webm differ
diff --git a/docs/media/assistant-conversation-craft/history-desktop-light.png b/docs/media/assistant-conversation-craft/history-desktop-light.png
new file mode 100644
index 000000000..627ea46fe
Binary files /dev/null and b/docs/media/assistant-conversation-craft/history-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/motion.json b/docs/media/assistant-conversation-craft/motion.json
new file mode 100644
index 000000000..f51acb1ca
--- /dev/null
+++ b/docs/media/assistant-conversation-craft/motion.json
@@ -0,0 +1,378 @@
+[
+ {
+ "name": "desktop-light",
+ "width": 1280,
+ "locale": "en",
+ "theme": "light",
+ "coarse": false,
+ "motion": "no-preference",
+ "start": 4.685,
+ "openHeight": 66.75,
+ "frames": [
+ {
+ "time": 6.2950000166893005,
+ "height": 66.75
+ },
+ {
+ "time": 22.75500002503395,
+ "height": 66.328125
+ },
+ {
+ "time": 39.75,
+ "height": 65.40625
+ },
+ {
+ "time": 56.34499999880791,
+ "height": 63.71875
+ },
+ {
+ "time": 72.98500001430511,
+ "height": 63.71875
+ },
+ {
+ "time": 89.22500002384186,
+ "height": 66.734375
+ },
+ {
+ "time": 106.39500001072884,
+ "height": 66.75
+ },
+ {
+ "time": 123.02000001072884,
+ "height": 66.75
+ },
+ {
+ "time": 139.5450000166893,
+ "height": 66.75
+ },
+ {
+ "time": 156.11000001430511,
+ "height": 66.75
+ },
+ {
+ "time": 173.03500002622604,
+ "height": 66.75
+ },
+ {
+ "time": 188.74000000953674,
+ "height": 66.75
+ },
+ {
+ "time": 206.3250000178814,
+ "height": 66.75
+ },
+ {
+ "time": 222.0750000178814,
+ "height": 66.75
+ },
+ {
+ "time": 239.66000002622604,
+ "height": 66.75
+ },
+ {
+ "time": 255.4050000011921,
+ "height": 66.75
+ },
+ {
+ "time": 272.96000000834465,
+ "height": 66.75
+ },
+ {
+ "time": 289.6899999976158,
+ "height": 66.75
+ },
+ {
+ "time": 306.3250000178814,
+ "height": 66.75
+ },
+ {
+ "time": 322.96000000834465,
+ "height": 66.75
+ }
+ ],
+ "closedHeight": 32
+ },
+ {
+ "name": "desktop-dark-reduced",
+ "width": 1280,
+ "locale": "en",
+ "theme": "dark",
+ "coarse": false,
+ "motion": "reduce",
+ "start": 4.43,
+ "openHeight": 66.75,
+ "frames": [
+ {
+ "time": 5.805000007152557,
+ "height": 32
+ },
+ {
+ "time": 22.375,
+ "height": 32
+ },
+ {
+ "time": 39.15999999642372,
+ "height": 32
+ },
+ {
+ "time": 55.74500000476837,
+ "height": 32
+ },
+ {
+ "time": 71.49000000953674,
+ "height": 66.75
+ },
+ {
+ "time": 89.46000000834465,
+ "height": 66.75
+ },
+ {
+ "time": 105.78499999642372,
+ "height": 66.75
+ },
+ {
+ "time": 122.39500001072884,
+ "height": 66.75
+ },
+ {
+ "time": 139.05000001192093,
+ "height": 66.75
+ },
+ {
+ "time": 155.71500000357628,
+ "height": 66.75
+ },
+ {
+ "time": 172.15000000596046,
+ "height": 66.75
+ },
+ {
+ "time": 189.03499999642372,
+ "height": 66.75
+ },
+ {
+ "time": 205.69499999284744,
+ "height": 66.75
+ },
+ {
+ "time": 222.16499999165535,
+ "height": 66.75
+ },
+ {
+ "time": 239.11000001430511,
+ "height": 66.75
+ },
+ {
+ "time": 254.875,
+ "height": 66.75
+ },
+ {
+ "time": 271.58500000834465,
+ "height": 66.75
+ },
+ {
+ "time": 289.2950000166893,
+ "height": 66.75
+ },
+ {
+ "time": 306.03999999165535,
+ "height": 66.75
+ },
+ {
+ "time": 322.3799999952316,
+ "height": 66.75
+ },
+ {
+ "time": 338.49000000953674,
+ "height": 66.75
+ }
+ ],
+ "closedHeight": 32
+ },
+ {
+ "name": "pt-320-touch-light",
+ "width": 320,
+ "locale": "pt",
+ "theme": "light",
+ "coarse": true,
+ "motion": "no-preference",
+ "start": 4.049,
+ "openHeight": 78.75,
+ "frames": [
+ {
+ "time": 3.6599999964237213,
+ "height": 78.75
+ },
+ {
+ "time": 20.049999982118607,
+ "height": 78.328125
+ },
+ {
+ "time": 35.804999977350235,
+ "height": 77.40625
+ },
+ {
+ "time": 53.479999989271164,
+ "height": 75.71875
+ },
+ {
+ "time": 69.0799999833107,
+ "height": 75.71875
+ },
+ {
+ "time": 86.7999999821186,
+ "height": 78.734375
+ },
+ {
+ "time": 102.41499999165535,
+ "height": 78.75
+ },
+ {
+ "time": 119.99499997496605,
+ "height": 78.75
+ },
+ {
+ "time": 135.97499999403954,
+ "height": 78.75
+ },
+ {
+ "time": 153.4050000011921,
+ "height": 78.75
+ },
+ {
+ "time": 169.13499999046326,
+ "height": 78.75
+ },
+ {
+ "time": 186.83499997854233,
+ "height": 78.75
+ },
+ {
+ "time": 202.5799999833107,
+ "height": 78.75
+ },
+ {
+ "time": 220.13999998569489,
+ "height": 78.75
+ },
+ {
+ "time": 235.72999998927116,
+ "height": 78.75
+ },
+ {
+ "time": 253.3299999833107,
+ "height": 78.75
+ },
+ {
+ "time": 269.06999999284744,
+ "height": 78.75
+ },
+ {
+ "time": 286.625,
+ "height": 78.75
+ },
+ {
+ "time": 302.375,
+ "height": 78.75
+ },
+ {
+ "time": 319.94999998807907,
+ "height": 78.75
+ }
+ ],
+ "closedHeight": 44
+ },
+ {
+ "name": "pt-360-touch-dark",
+ "width": 360,
+ "locale": "pt",
+ "theme": "dark",
+ "coarse": true,
+ "motion": "reduce",
+ "start": 4.083,
+ "openHeight": 78.75,
+ "frames": [
+ {
+ "time": 1.7450000047683716,
+ "height": 78.75
+ },
+ {
+ "time": 18.515000015497208,
+ "height": 44
+ },
+ {
+ "time": 35.0450000166893,
+ "height": 44
+ },
+ {
+ "time": 51.71500000357628,
+ "height": 44
+ },
+ {
+ "time": 69.47000002861023,
+ "height": 78.75
+ },
+ {
+ "time": 85.73500001430511,
+ "height": 78.75
+ },
+ {
+ "time": 102.05500000715256,
+ "height": 78.75
+ },
+ {
+ "time": 118.99000000953674,
+ "height": 78.75
+ },
+ {
+ "time": 135.6400000154972,
+ "height": 78.75
+ },
+ {
+ "time": 151.6050000190735,
+ "height": 78.75
+ },
+ {
+ "time": 168.2800000011921,
+ "height": 78.75
+ },
+ {
+ "time": 185.10000002384186,
+ "height": 78.75
+ },
+ {
+ "time": 201.89500001072884,
+ "height": 78.75
+ },
+ {
+ "time": 218.4050000011921,
+ "height": 78.75
+ },
+ {
+ "time": 235.125,
+ "height": 78.75
+ },
+ {
+ "time": 251.75,
+ "height": 78.75
+ },
+ {
+ "time": 269.25,
+ "height": 78.75
+ },
+ {
+ "time": 285.2450000047684,
+ "height": 78.75
+ },
+ {
+ "time": 302.4200000166893,
+ "height": 78.75
+ },
+ {
+ "time": 318.705000013113,
+ "height": 78.75
+ }
+ ],
+ "closedHeight": 44
+ }
+]
diff --git a/docs/media/assistant-conversation-craft/provenance.json b/docs/media/assistant-conversation-craft/provenance.json
new file mode 100644
index 000000000..bb3223769
--- /dev/null
+++ b/docs/media/assistant-conversation-craft/provenance.json
@@ -0,0 +1,11 @@
+{
+ "buildIdentity": {
+ "schema": 1,
+ "revision": "d6c0411372e168a5a1c124c82408b7499e7e3cc2",
+ "contentSha256": "23a96f4182123707557f63f43762b563cdfcd91f25f9a9aaccb20561081123b0"
+ },
+ "generatedAt": "2026-10-21T11:24:06.655Z",
+ "fixture": "Public provider NDJSON; deterministic UI acceptance only",
+ "captureScript": "work/capture-conversation-craft.mjs",
+ "states": 108
+}
diff --git a/docs/media/assistant-conversation-craft/pt-320-touch-light.webm b/docs/media/assistant-conversation-craft/pt-320-touch-light.webm
new file mode 100644
index 000000000..96d0adc6c
Binary files /dev/null and b/docs/media/assistant-conversation-craft/pt-320-touch-light.webm differ
diff --git a/docs/media/assistant-conversation-craft/pt-360-touch-dark.webm b/docs/media/assistant-conversation-craft/pt-360-touch-dark.webm
new file mode 100644
index 000000000..9ed2519c7
Binary files /dev/null and b/docs/media/assistant-conversation-craft/pt-360-touch-dark.webm differ
diff --git a/docs/media/assistant-conversation-craft/reasoning-open-desktop-light.png b/docs/media/assistant-conversation-craft/reasoning-open-desktop-light.png
new file mode 100644
index 000000000..e8f7d2ac4
Binary files /dev/null and b/docs/media/assistant-conversation-craft/reasoning-open-desktop-light.png differ
diff --git a/docs/media/assistant-conversation-craft/waiting-pt-320-touch-light.png b/docs/media/assistant-conversation-craft/waiting-pt-320-touch-light.png
new file mode 100644
index 000000000..880cce822
Binary files /dev/null and b/docs/media/assistant-conversation-craft/waiting-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-conversation-craft/welcome-desktop-light.png b/docs/media/assistant-conversation-craft/welcome-desktop-light.png
new file mode 100644
index 000000000..887cea277
Binary files /dev/null and b/docs/media/assistant-conversation-craft/welcome-desktop-light.png differ
diff --git a/docs/media/assistant-craft-correction/acceptance.json b/docs/media/assistant-craft-correction/acceptance.json
new file mode 100644
index 000000000..f02502908
--- /dev/null
+++ b/docs/media/assistant-craft-correction/acceptance.json
@@ -0,0 +1,50 @@
+{
+ "status": "accepted",
+ "runtime": {
+ "schema": 1,
+ "revision": "bd0e8c146bbde9b55a53ba04db1d95ff60affcd9",
+ "contentSha256": "89f8d8bf62aaa8d1f594cad6fedbb3e9bc6c4d26ced2674b58dc0e21af58395d"
+ },
+ "summary": "Corrective visual acceptance passed original-image and decoded continuous-recording review; the previous missed approval is withdrawn.",
+ "capture": {
+ "before": 44,
+ "after": 92,
+ "recordings": 8,
+ "environments": [
+ "desktop English light",
+ "desktop English dark/reduced motion",
+ "320px Portuguese touch/light",
+ "360px Portuguese touch/dark/reduced motion"
+ ],
+ "fixture": "Deterministic public provider NDJSON; no product CSS/DOM injection."
+ },
+ "tests": {
+ "unit": "6915 tests / 258 files passed with coverage thresholds unchanged",
+ "coverage": {
+ "statements": 88.16,
+ "branches": 84.24,
+ "functions": 89.02,
+ "lines": 90.54
+ },
+ "browser": "38 distinct related cases verified: 33 initial passes +2 corrected visible-route cases +3 reasoning/touch cases; no retries or assertion/overflow tolerance relaxation.",
+ "types": "Root noEmit and workspace builds passed",
+ "lint": "Archive-aware lint and immutable1511 evaluation checks passed",
+ "format": "Full archive-aware Prettier check and changed-authored-file formatting passed",
+ "build": "Production build passed; exact identity recorded above"
+ },
+ "review": {
+ "spec": "passed",
+ "code": "passed",
+ "visual": "Root and independent reviewer inspected all8 state sheets, all8 updated decoded recording sheets and critical original panes across the four declared environments. No blocking composition or legibility defect remained."
+ },
+ "limits": [
+ "Fixtures verify UI behavior, not real-model quality or exact competitor parity.",
+ "Native Word insertion retains its documented Markdown table limit.",
+ "Selection preservation covers completed/reused blocks and late highlighting; growing unfinished fences retain existing streaming behavior.",
+ "ran reasoning/guidance changes remain in PR436; this correction belongs to application composition/shared safe rendering."
+ ],
+ "reportQA": "1280px and320px:94images decoded,8video metadata loaded,zero overflow or JavaScript errors.",
+ "remoteCI": {
+ "status": "pending after approved PR update"
+ }
+}
diff --git a/docs/media/assistant-craft-correction/before-chat-code-desktop-light.png b/docs/media/assistant-craft-correction/before-chat-code-desktop-light.png
new file mode 100644
index 000000000..f1e90e53c
Binary files /dev/null and b/docs/media/assistant-craft-correction/before-chat-code-desktop-light.png differ
diff --git a/docs/media/assistant-craft-correction/before-return-detail-desktop-dark.png b/docs/media/assistant-craft-correction/before-return-detail-desktop-dark.png
new file mode 100644
index 000000000..f8a93b7c2
Binary files /dev/null and b/docs/media/assistant-craft-correction/before-return-detail-desktop-dark.png differ
diff --git a/docs/media/assistant-craft-correction/markdown-artifact-code-copy-failed-pt-360-dark.png b/docs/media/assistant-craft-correction/markdown-artifact-code-copy-failed-pt-360-dark.png
new file mode 100644
index 000000000..520fdf68b
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-artifact-code-copy-failed-pt-360-dark.png differ
diff --git a/docs/media/assistant-craft-correction/markdown-artifact-code-desktop-light.png b/docs/media/assistant-craft-correction/markdown-artifact-code-desktop-light.png
new file mode 100644
index 000000000..513fcc715
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-artifact-code-desktop-light.png differ
diff --git a/docs/media/assistant-craft-correction/markdown-artifact-wide-table-pt-360-dark.png b/docs/media/assistant-craft-correction/markdown-artifact-wide-table-pt-360-dark.png
new file mode 100644
index 000000000..d7dd54625
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-artifact-wide-table-pt-360-dark.png differ
diff --git a/docs/media/assistant-craft-correction/markdown-chat-code-copied-pt-320-light.png b/docs/media/assistant-craft-correction/markdown-chat-code-copied-pt-320-light.png
new file mode 100644
index 000000000..85fd653bf
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-chat-code-copied-pt-320-light.png differ
diff --git a/docs/media/assistant-craft-correction/markdown-chat-code-desktop-dark.png b/docs/media/assistant-craft-correction/markdown-chat-code-desktop-dark.png
new file mode 100644
index 000000000..92cdab0db
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-chat-code-desktop-dark.png differ
diff --git a/docs/media/assistant-craft-correction/markdown-chat-table-desktop-light.png b/docs/media/assistant-craft-correction/markdown-chat-table-desktop-light.png
new file mode 100644
index 000000000..731e27739
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-chat-table-desktop-light.png differ
diff --git a/docs/media/assistant-craft-correction/markdown-desktop-dark.webm b/docs/media/assistant-craft-correction/markdown-desktop-dark.webm
new file mode 100644
index 000000000..f0283802e
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-desktop-dark.webm differ
diff --git a/docs/media/assistant-craft-correction/markdown-desktop-light.webm b/docs/media/assistant-craft-correction/markdown-desktop-light.webm
new file mode 100644
index 000000000..7fa36fea1
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-desktop-light.webm differ
diff --git a/docs/media/assistant-craft-correction/markdown-pt-320-light.webm b/docs/media/assistant-craft-correction/markdown-pt-320-light.webm
new file mode 100644
index 000000000..dc78602f4
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-pt-320-light.webm differ
diff --git a/docs/media/assistant-craft-correction/markdown-pt-360-dark.webm b/docs/media/assistant-craft-correction/markdown-pt-360-dark.webm
new file mode 100644
index 000000000..ca2dbca60
Binary files /dev/null and b/docs/media/assistant-craft-correction/markdown-pt-360-dark.webm differ
diff --git a/docs/media/assistant-craft-correction/provenance.json b/docs/media/assistant-craft-correction/provenance.json
new file mode 100644
index 000000000..e2c005860
--- /dev/null
+++ b/docs/media/assistant-craft-correction/provenance.json
@@ -0,0 +1,143 @@
+{
+ "runtime": {
+ "schema": 1,
+ "revision": "bd0e8c146bbde9b55a53ba04db1d95ff60affcd9",
+ "contentSha256": "89f8d8bf62aaa8d1f594cad6fedbb3e9bc6c4d26ced2674b58dc0e21af58395d"
+ },
+ "completeLocalCaptureCount": 92,
+ "committedFiles": [
+ {
+ "file": "return-detail-desktop-light.png",
+ "source": "assistant-return-correction/after/detail-desktop-light.png",
+ "sha256": "318e26e28b838eea40b11ecaddab2f0a957a0848ab96d865aca09324ba385281",
+ "bytes": 25337
+ },
+ {
+ "file": "return-detail-desktop-dark.png",
+ "source": "assistant-return-correction/after/detail-desktop-dark.png",
+ "sha256": "f252309274ef12a16293d00656dc31cdf4e9cf365e795e12d389b64f0115497d",
+ "bytes": 24940
+ },
+ {
+ "file": "return-detail-pt-320-light.png",
+ "source": "assistant-return-correction/after/detail-pt-320-light.png",
+ "sha256": "d34132c6f1799f2ab6a03ef804181d70c730ac1197964bdf535138cbad61a8e8",
+ "bytes": 26244
+ },
+ {
+ "file": "return-escape-question-pt-320-light.png",
+ "source": "assistant-return-correction/after/escape-question-pt-320-light.png",
+ "sha256": "07a46c689b082f060630530cfb213978405c3cb93fbb23215000104910284c7e",
+ "bytes": 35209
+ },
+ {
+ "file": "return-saved-version-pt-360-dark.png",
+ "source": "assistant-return-correction/after/saved-version-pt-360-dark.png",
+ "sha256": "de9bc5671483bdae63cd3bca64de7d9d81178d4752cd1d3efe28e2de9cfa87cd",
+ "bytes": 28380
+ },
+ {
+ "file": "return-structured-table-pt-320-light.png",
+ "source": "assistant-return-correction/after/structured-table-pt-320-light.png",
+ "sha256": "19fb69529b976de039e4783a933a53be58e1ed5d1de8148bcaf42037b4f34440",
+ "bytes": 25196
+ },
+ {
+ "file": "markdown-chat-table-desktop-light.png",
+ "source": "assistant-markdown-craft/after/chat-table-desktop-light.png",
+ "sha256": "1ad9cd625ab305b9e2bbe6b6409b9f938b318b3d2d3dba1a2a4d03cc9ebb34d1",
+ "bytes": 67617
+ },
+ {
+ "file": "markdown-chat-code-desktop-dark.png",
+ "source": "assistant-markdown-craft/after/chat-code-desktop-dark.png",
+ "sha256": "718dc46f3d6c6e853ed31ac3ac9879bd60e2045f4d776fec643e5b0b16e3a122",
+ "bytes": 66238
+ },
+ {
+ "file": "markdown-chat-code-copied-pt-320-light.png",
+ "source": "assistant-markdown-craft/after/chat-code-copied-pt-320-light.png",
+ "sha256": "b62be9a1990dbc6251bd8df1378f58afaeffe3cf0a9e18269b4677897310fa8f",
+ "bytes": 53789
+ },
+ {
+ "file": "markdown-artifact-code-desktop-light.png",
+ "source": "assistant-markdown-craft/after/artifact-code-desktop-light.png",
+ "sha256": "0ca77609129a3e8f0d10bf44e38260b8015dd421bb28b391a7b08fc62da790a9",
+ "bytes": 66473
+ },
+ {
+ "file": "markdown-artifact-code-copy-failed-pt-360-dark.png",
+ "source": "assistant-markdown-craft/after/artifact-code-copy-failed-pt-360-dark.png",
+ "sha256": "0df9ed712c7450370a64c756f5942417ad8af0fb0a3d2c9cf7d993868aee7be7",
+ "bytes": 59752
+ },
+ {
+ "file": "markdown-artifact-wide-table-pt-360-dark.png",
+ "source": "assistant-markdown-craft/after/artifact-wide-table-pt-360-dark.png",
+ "sha256": "20a2f0dc4df53496e0a39d7d94a427bae2fdefdaf8f3611236513a41c41a9b41",
+ "bytes": 54730
+ },
+ {
+ "file": "before-return-detail-desktop-dark.png",
+ "source": "assistant-return-correction/before/detail-desktop-dark.png",
+ "sha256": "788d3ce9b86bc8a8675f312ce304be4e3ff4f1fc180d3573b91cc9234ca659f9",
+ "bytes": 27374
+ },
+ {
+ "file": "before-chat-code-desktop-light.png",
+ "source": "assistant-markdown-craft/before/chat-code-desktop-light.png",
+ "sha256": "56ebd1d0f6432fce6ef01c962a41aff8df8ca26a8e7efcd6f9a241e3d0e61aee",
+ "bytes": 56726
+ },
+ {
+ "file": "return-desktop-light.webm",
+ "source": "assistant-return-correction/recordings/desktop-light-pane.webm",
+ "sha256": "e6a9ae8494386c3615ef172e0875b52b33ac582be6f95ed09e500ce50e5b79e5",
+ "bytes": 176602
+ },
+ {
+ "file": "return-desktop-dark.webm",
+ "source": "assistant-return-correction/recordings/desktop-dark-pane.webm",
+ "sha256": "f617d38e1a89fd695b4475d73d9e74f1c89ecb9afe8e6acb4ead53bd4d5e5e67",
+ "bytes": 141509
+ },
+ {
+ "file": "return-pt-320-light.webm",
+ "source": "assistant-return-correction/recordings/pt-320-light-pane.webm",
+ "sha256": "1bad42096cf5daad88b764a26721aa7edd8b8bdf88cfe1b12f38a753fb8bc61b",
+ "bytes": 202435
+ },
+ {
+ "file": "return-pt-360-dark.webm",
+ "source": "assistant-return-correction/recordings/pt-360-dark-pane.webm",
+ "sha256": "e6ea4f3d4643d1058f23217efde09a1a076eb44c3708a8659e933ed6d540f38e",
+ "bytes": 190746
+ },
+ {
+ "file": "markdown-desktop-light.webm",
+ "source": "assistant-markdown-craft/recordings/desktop-light-pane.webm",
+ "sha256": "93847fad12430906f51402082ed68ebbe8b59774ab64845de08b8f23877f5a73",
+ "bytes": 276583
+ },
+ {
+ "file": "markdown-desktop-dark.webm",
+ "source": "assistant-markdown-craft/recordings/desktop-dark-pane.webm",
+ "sha256": "ddb06e8f731d5e89c0bd6a7156fb114432c764cdee25b1ed86301a7c697d90bb",
+ "bytes": 241527
+ },
+ {
+ "file": "markdown-pt-320-light.webm",
+ "source": "assistant-markdown-craft/recordings/pt-320-light-pane.webm",
+ "sha256": "b5cee7e75b666e437df0767b33d466828605569433593b57567d9441b5acd339",
+ "bytes": 246074
+ },
+ {
+ "file": "markdown-pt-360-dark.webm",
+ "source": "assistant-markdown-craft/recordings/pt-360-dark-pane.webm",
+ "sha256": "a2172d77421c358520f9807f65f05d5303c3a54b22c4f8ffbe8613fa41a02d76",
+ "bytes": 245942
+ }
+ ],
+ "note": "Representative unmodified pane screenshots and spatially cropped continuous recordings; complete inventory and full-browser originals remain in the local report."
+}
diff --git a/docs/media/assistant-craft-correction/return-desktop-dark.webm b/docs/media/assistant-craft-correction/return-desktop-dark.webm
new file mode 100644
index 000000000..ddcd09dce
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-desktop-dark.webm differ
diff --git a/docs/media/assistant-craft-correction/return-desktop-light.webm b/docs/media/assistant-craft-correction/return-desktop-light.webm
new file mode 100644
index 000000000..6bbfddd16
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-desktop-light.webm differ
diff --git a/docs/media/assistant-craft-correction/return-detail-desktop-dark.png b/docs/media/assistant-craft-correction/return-detail-desktop-dark.png
new file mode 100644
index 000000000..7d4bdd73f
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-detail-desktop-dark.png differ
diff --git a/docs/media/assistant-craft-correction/return-detail-desktop-light.png b/docs/media/assistant-craft-correction/return-detail-desktop-light.png
new file mode 100644
index 000000000..bc34f90e3
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-detail-desktop-light.png differ
diff --git a/docs/media/assistant-craft-correction/return-detail-pt-320-light.png b/docs/media/assistant-craft-correction/return-detail-pt-320-light.png
new file mode 100644
index 000000000..2f1f34f6b
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-detail-pt-320-light.png differ
diff --git a/docs/media/assistant-craft-correction/return-escape-question-pt-320-light.png b/docs/media/assistant-craft-correction/return-escape-question-pt-320-light.png
new file mode 100644
index 000000000..76195e831
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-escape-question-pt-320-light.png differ
diff --git a/docs/media/assistant-craft-correction/return-pt-320-light.webm b/docs/media/assistant-craft-correction/return-pt-320-light.webm
new file mode 100644
index 000000000..3108e9f8f
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-pt-320-light.webm differ
diff --git a/docs/media/assistant-craft-correction/return-pt-360-dark.webm b/docs/media/assistant-craft-correction/return-pt-360-dark.webm
new file mode 100644
index 000000000..d8e77052a
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-pt-360-dark.webm differ
diff --git a/docs/media/assistant-craft-correction/return-saved-version-pt-360-dark.png b/docs/media/assistant-craft-correction/return-saved-version-pt-360-dark.png
new file mode 100644
index 000000000..e501f7c41
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-saved-version-pt-360-dark.png differ
diff --git a/docs/media/assistant-craft-correction/return-structured-table-pt-320-light.png b/docs/media/assistant-craft-correction/return-structured-table-pt-320-light.png
new file mode 100644
index 000000000..26ce9e900
Binary files /dev/null and b/docs/media/assistant-craft-correction/return-structured-table-pt-320-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-artifact-editor-dark.png b/docs/media/assistant-craft/2026-10-21-artifact-editor-dark.png
new file mode 100644
index 000000000..28e108942
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-artifact-editor-dark.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-artifact-import-light.png b/docs/media/assistant-craft/2026-10-21-artifact-import-light.png
new file mode 100644
index 000000000..ade660668
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-artifact-import-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-artifacts-dark.png b/docs/media/assistant-craft/2026-10-21-artifacts-dark.png
new file mode 100644
index 000000000..9de6c0f20
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-artifacts-dark.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-artifacts-light.png b/docs/media/assistant-craft/2026-10-21-artifacts-light.png
new file mode 100644
index 000000000..78bd7c05c
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-artifacts-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-before-artifacts-light.png b/docs/media/assistant-craft/2026-10-21-before-artifacts-light.png
new file mode 100644
index 000000000..813bbbfa8
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-before-artifacts-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-before-history-light.png b/docs/media/assistant-craft/2026-10-21-before-history-light.png
new file mode 100644
index 000000000..814adefac
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-before-history-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-chat-de-320-touch-light.png b/docs/media/assistant-craft/2026-10-21-chat-de-320-touch-light.png
new file mode 100644
index 000000000..82927b5bf
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-chat-de-320-touch-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-history-confirmation-dark.png b/docs/media/assistant-craft/2026-10-21-history-confirmation-dark.png
new file mode 100644
index 000000000..304f41bb9
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-history-confirmation-dark.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-history-dark.png b/docs/media/assistant-craft/2026-10-21-history-dark.png
new file mode 100644
index 000000000..c2b510466
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-history-dark.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-history-es-360-touch-dark.png b/docs/media/assistant-craft/2026-10-21-history-es-360-touch-dark.png
new file mode 100644
index 000000000..15571ecc1
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-history-es-360-touch-dark.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-history-light.png b/docs/media/assistant-craft/2026-10-21-history-light.png
new file mode 100644
index 000000000..96483b9bb
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-history-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-history-pt-320-touch-light.png b/docs/media/assistant-craft/2026-10-21-history-pt-320-touch-light.png
new file mode 100644
index 000000000..78b8c87c6
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-history-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-models-light.png b/docs/media/assistant-craft/2026-10-21-models-light.png
new file mode 100644
index 000000000..c4efe3e79
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-models-light.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-models-service-dark.png b/docs/media/assistant-craft/2026-10-21-models-service-dark.png
new file mode 100644
index 000000000..37c8271b9
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-models-service-dark.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-source-error-pt-360-touch-dark.png b/docs/media/assistant-craft/2026-10-21-source-error-pt-360-touch-dark.png
new file mode 100644
index 000000000..52eb15c7d
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-source-error-pt-360-touch-dark.png differ
diff --git a/docs/media/assistant-craft/2026-10-21-sources-populated-light.png b/docs/media/assistant-craft/2026-10-21-sources-populated-light.png
new file mode 100644
index 000000000..ee407d969
Binary files /dev/null and b/docs/media/assistant-craft/2026-10-21-sources-populated-light.png differ
diff --git a/docs/media/assistant-detail-audit/artifact-delete-cancelled-pt-320-touch-light.png b/docs/media/assistant-detail-audit/artifact-delete-cancelled-pt-320-touch-light.png
new file mode 100644
index 000000000..75cae184a
Binary files /dev/null and b/docs/media/assistant-detail-audit/artifact-delete-cancelled-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-detail-audit/artifact-delete-confirmation-pt-320-touch-light.png b/docs/media/assistant-detail-audit/artifact-delete-confirmation-pt-320-touch-light.png
new file mode 100644
index 000000000..c24bb061e
Binary files /dev/null and b/docs/media/assistant-detail-audit/artifact-delete-confirmation-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-detail-audit/before-models-expanded-bottom-pt-320-touch-light.png b/docs/media/assistant-detail-audit/before-models-expanded-bottom-pt-320-touch-light.png
new file mode 100644
index 000000000..4d232def2
Binary files /dev/null and b/docs/media/assistant-detail-audit/before-models-expanded-bottom-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-detail-audit/captures.json b/docs/media/assistant-detail-audit/captures.json
new file mode 100644
index 000000000..784f8fa16
--- /dev/null
+++ b/docs/media/assistant-detail-audit/captures.json
@@ -0,0 +1,886 @@
+[
+ {
+ "name": "history",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "history-light.png",
+ "width": 1280
+ },
+ {
+ "name": "history-confirmation",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "history-confirmation-light.png",
+ "width": 1280
+ },
+ {
+ "name": "artifacts",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifacts-light.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-import",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifact-import-light.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-editor",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifact-editor-light.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-delete-confirmation",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": "cancel"
+ },
+ "file": "artifact-delete-confirmation-light.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-delete-cancelled",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": "delete"
+ },
+ "file": "artifact-delete-cancelled-light.png",
+ "width": 1280
+ },
+ {
+ "name": "artifacts-populated",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifacts-populated-light.png",
+ "width": 1280
+ },
+ {
+ "name": "models",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-light.png",
+ "width": 1280
+ },
+ {
+ "name": "models-expanded",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-expanded-light.png",
+ "width": 1280
+ },
+ {
+ "name": "models-expanded-bottom",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-expanded-bottom-light.png",
+ "width": 1280
+ },
+ {
+ "name": "models-service",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-service-light.png",
+ "width": 1280
+ },
+ {
+ "name": "chat",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "chat-light.png",
+ "width": 1280
+ },
+ {
+ "name": "sources",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "sources-light.png",
+ "width": 1280
+ },
+ {
+ "name": "sources-populated",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "sources-populated-light.png",
+ "width": 1280
+ },
+ {
+ "name": "source-excerpt",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "source-excerpt-light.png",
+ "width": 1280
+ },
+ {
+ "name": "source-error",
+ "theme": "light",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "source-error-light.png",
+ "width": 1280
+ },
+ {
+ "name": "history",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "history-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "history-confirmation",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "history-confirmation-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "artifacts",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifacts-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-import",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifact-import-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-editor",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifact-editor-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-delete-confirmation",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": "cancel"
+ },
+ "file": "artifact-delete-confirmation-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "artifact-delete-cancelled",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": "delete"
+ },
+ "file": "artifact-delete-cancelled-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "artifacts-populated",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "artifacts-populated-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "models",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "models-expanded",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-expanded-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "models-expanded-bottom",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-expanded-bottom-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "models-service",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "models-service-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "chat",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "chat-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "sources",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "sources-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "sources-populated",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "sources-populated-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "source-excerpt",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "source-excerpt-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "source-error",
+ "theme": "dark",
+ "locale": "en",
+ "coarse": false,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 40,
+ "active": null
+ },
+ "file": "source-error-dark.png",
+ "width": 1280
+ },
+ {
+ "name": "history",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "history-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "history-confirmation",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "history-confirmation-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "artifacts",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifacts-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "artifact-import",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifact-import-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "artifact-editor",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifact-editor-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "artifact-delete-confirmation",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": "cancel"
+ },
+ "file": "artifact-delete-confirmation-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "artifact-delete-cancelled",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": "delete"
+ },
+ "file": "artifact-delete-cancelled-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "artifacts-populated",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifacts-populated-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "models",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "models-expanded",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-expanded-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "models-expanded-bottom",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-expanded-bottom-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "models-service",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-service-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "chat",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "chat-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "sources",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "sources-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "sources-populated",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "sources-populated-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "source-excerpt",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "source-excerpt-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "source-error",
+ "theme": "light",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "source-error-pt-320-touch-light.png",
+ "width": 320
+ },
+ {
+ "name": "history",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "history-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "history-confirmation",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "history-confirmation-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "artifacts",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifacts-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "artifact-import",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifact-import-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "artifact-editor",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifact-editor-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "artifact-delete-confirmation",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": "cancel"
+ },
+ "file": "artifact-delete-confirmation-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "artifact-delete-cancelled",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": "delete"
+ },
+ "file": "artifact-delete-cancelled-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "artifacts-populated",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "artifacts-populated-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "models",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "models-expanded",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-expanded-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "models-expanded-bottom",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-expanded-bottom-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "models-service",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "models-service-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "chat",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "chat-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "sources",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "sources-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "sources-populated",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "sources-populated-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "source-excerpt",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "source-excerpt-pt-360-touch-dark.png",
+ "width": 360
+ },
+ {
+ "name": "source-error",
+ "theme": "dark",
+ "locale": "pt",
+ "coarse": true,
+ "geometry": {
+ "overflow": 0,
+ "headerHeight": 52,
+ "active": null
+ },
+ "file": "source-error-pt-360-touch-dark.png",
+ "width": 360
+ }
+]
diff --git a/docs/media/assistant-detail-audit/chat-pt-320-touch-light.png b/docs/media/assistant-detail-audit/chat-pt-320-touch-light.png
new file mode 100644
index 000000000..28301dadb
Binary files /dev/null and b/docs/media/assistant-detail-audit/chat-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-detail-audit/models-dark.png b/docs/media/assistant-detail-audit/models-dark.png
new file mode 100644
index 000000000..7f6aef223
Binary files /dev/null and b/docs/media/assistant-detail-audit/models-dark.png differ
diff --git a/docs/media/assistant-detail-audit/models-expanded-bottom-pt-320-touch-light.png b/docs/media/assistant-detail-audit/models-expanded-bottom-pt-320-touch-light.png
new file mode 100644
index 000000000..5be40de71
Binary files /dev/null and b/docs/media/assistant-detail-audit/models-expanded-bottom-pt-320-touch-light.png differ
diff --git a/docs/media/assistant-detail-audit/source-excerpt-pt-360-touch-dark.png b/docs/media/assistant-detail-audit/source-excerpt-pt-360-touch-dark.png
new file mode 100644
index 000000000..dca8425c6
Binary files /dev/null and b/docs/media/assistant-detail-audit/source-excerpt-pt-360-touch-dark.png differ
diff --git a/docs/media/assistant-refinement/artifacts-en-light-1280.png b/docs/media/assistant-refinement/artifacts-en-light-1280.png
new file mode 100644
index 000000000..dc9865634
Binary files /dev/null and b/docs/media/assistant-refinement/artifacts-en-light-1280.png differ
diff --git a/docs/media/assistant-refinement/before-chat-en-light-1280.png b/docs/media/assistant-refinement/before-chat-en-light-1280.png
new file mode 100644
index 000000000..8b4809e3f
Binary files /dev/null and b/docs/media/assistant-refinement/before-chat-en-light-1280.png differ
diff --git a/docs/media/assistant-refinement/before-history-en-light-1280.png b/docs/media/assistant-refinement/before-history-en-light-1280.png
new file mode 100644
index 000000000..cdc416de3
Binary files /dev/null and b/docs/media/assistant-refinement/before-history-en-light-1280.png differ
diff --git a/docs/media/assistant-refinement/chat-en-light-1280.png b/docs/media/assistant-refinement/chat-en-light-1280.png
new file mode 100644
index 000000000..e534a25ca
Binary files /dev/null and b/docs/media/assistant-refinement/chat-en-light-1280.png differ
diff --git a/docs/media/assistant-refinement/header-en-dark-1280.png b/docs/media/assistant-refinement/header-en-dark-1280.png
new file mode 100644
index 000000000..1dfb8aaee
Binary files /dev/null and b/docs/media/assistant-refinement/header-en-dark-1280.png differ
diff --git a/docs/media/assistant-refinement/header-en-light-1280.png b/docs/media/assistant-refinement/header-en-light-1280.png
new file mode 100644
index 000000000..f54ddb327
Binary files /dev/null and b/docs/media/assistant-refinement/header-en-light-1280.png differ
diff --git a/docs/media/assistant-refinement/history-en-dark-1280.png b/docs/media/assistant-refinement/history-en-dark-1280.png
new file mode 100644
index 000000000..73285d8be
Binary files /dev/null and b/docs/media/assistant-refinement/history-en-dark-1280.png differ
diff --git a/docs/media/assistant-refinement/history-en-light-1280.png b/docs/media/assistant-refinement/history-en-light-1280.png
new file mode 100644
index 000000000..247bdc1f5
Binary files /dev/null and b/docs/media/assistant-refinement/history-en-light-1280.png differ
diff --git a/docs/media/assistant-refinement/history-guides-pt-light-320.png b/docs/media/assistant-refinement/history-guides-pt-light-320.png
new file mode 100644
index 000000000..90bc25f11
Binary files /dev/null and b/docs/media/assistant-refinement/history-guides-pt-light-320.png differ
diff --git a/docs/media/assistant-refinement/history-pt-dark-360.png b/docs/media/assistant-refinement/history-pt-dark-360.png
new file mode 100644
index 000000000..f074b70e9
Binary files /dev/null and b/docs/media/assistant-refinement/history-pt-dark-360.png differ
diff --git a/docs/media/assistant-refinement/history-pt-light-320.png b/docs/media/assistant-refinement/history-pt-light-320.png
new file mode 100644
index 000000000..ced6004e3
Binary files /dev/null and b/docs/media/assistant-refinement/history-pt-light-320.png differ
diff --git a/docs/media/assistant-refinement/measurements.json b/docs/media/assistant-refinement/measurements.json
new file mode 100644
index 000000000..f772eccc1
--- /dev/null
+++ b/docs/media/assistant-refinement/measurements.json
@@ -0,0 +1,94 @@
+[
+ {
+ "width": 1280,
+ "locale": "en",
+ "theme": "light",
+ "coarse": false,
+ "view": "history",
+ "headerHeight": 40,
+ "navHeight": 37,
+ "chromeHeight": 77,
+ "titleTextLeft": 16,
+ "activeShadow": "none",
+ "activeFill": "rgb(235, 235, 235)",
+ "canvas": "rgb(255, 255, 255)",
+ "overflow": 0,
+ "headingLeft": 16,
+ "summaryLeft": 16,
+ "checkboxLeft": 16,
+ "iconLefts": [16, 16, 16, 16, 16],
+ "labelLefts": [40, 40, 40, 40, 40],
+ "saveLabelLeft": 40,
+ "statusLeft": 40,
+ "file": "history-en-light-1280.png"
+ },
+ {
+ "width": 1280,
+ "locale": "en",
+ "theme": "dark",
+ "coarse": false,
+ "view": "history",
+ "headerHeight": 40,
+ "navHeight": 37,
+ "chromeHeight": 77,
+ "titleTextLeft": 16,
+ "activeShadow": "none",
+ "activeFill": "rgb(31, 31, 31)",
+ "canvas": "rgb(26, 26, 26)",
+ "overflow": 0,
+ "headingLeft": 16,
+ "summaryLeft": 16,
+ "checkboxLeft": 16,
+ "iconLefts": [16, 16, 16, 16, 16],
+ "labelLefts": [40, 40, 40, 40, 40],
+ "saveLabelLeft": 40,
+ "statusLeft": 40,
+ "file": "history-en-dark-1280.png"
+ },
+ {
+ "width": 320,
+ "locale": "pt",
+ "theme": "light",
+ "coarse": true,
+ "view": "history",
+ "headerHeight": 52,
+ "navHeight": 49,
+ "chromeHeight": 101,
+ "titleTextLeft": 16,
+ "activeShadow": "none",
+ "activeFill": "rgb(235, 235, 235)",
+ "canvas": "rgb(255, 255, 255)",
+ "overflow": 0,
+ "headingLeft": 16,
+ "summaryLeft": 16,
+ "checkboxLeft": 16,
+ "iconLefts": [16, 16, 16, 16, 16],
+ "labelLefts": [40, 40, 40, 40, 40],
+ "saveLabelLeft": 40,
+ "statusLeft": 40,
+ "file": "history-pt-light-320.png"
+ },
+ {
+ "width": 360,
+ "locale": "pt",
+ "theme": "dark",
+ "coarse": true,
+ "view": "history",
+ "headerHeight": 52,
+ "navHeight": 49,
+ "chromeHeight": 101,
+ "titleTextLeft": 16,
+ "activeShadow": "none",
+ "activeFill": "rgb(31, 31, 31)",
+ "canvas": "rgb(26, 26, 26)",
+ "overflow": 0,
+ "headingLeft": 16,
+ "summaryLeft": 16,
+ "checkboxLeft": 16,
+ "iconLefts": [16, 16, 16, 16, 16],
+ "labelLefts": [40, 40, 40, 40, 40],
+ "saveLabelLeft": 40,
+ "statusLeft": 40,
+ "file": "history-pt-dark-360.png"
+ }
+]
diff --git a/docs/media/assistant-refinement/models-en-light-1280.png b/docs/media/assistant-refinement/models-en-light-1280.png
new file mode 100644
index 000000000..b07207dad
Binary files /dev/null and b/docs/media/assistant-refinement/models-en-light-1280.png differ
diff --git a/docs/media/assistant-refinement/sources-en-light-1280.png b/docs/media/assistant-refinement/sources-en-light-1280.png
new file mode 100644
index 000000000..e18eafaf6
Binary files /dev/null and b/docs/media/assistant-refinement/sources-en-light-1280.png differ
diff --git a/docs/superpowers/plans/2026-10-21-assistant-conversation-craft.md b/docs/superpowers/plans/2026-10-21-assistant-conversation-craft.md
new file mode 100644
index 000000000..d7c502909
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-assistant-conversation-craft.md
@@ -0,0 +1,59 @@
+# Assistant Conversation Craft Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development to implement this plan. Steps use checkbox syntax for tracking.
+
+**Goal:** Bring the complete sidebar conversation, composer, public reasoning and artifact workflow to a coherent, task-centered completion level, accepted through actual screenshots and recordings before PR updates.
+
+**Architecture:** Keep request, storage and document-review owners intact. Separate public reasoning from answer text at the transport boundary and reuse ranui reasoning/disclosure and the existing chat package’s safe Markdown renderer. Reduce application chrome and compose existing host controls into the relevant task surfaces.
+
+**Tech Stack:** TypeScript, ranui0.5 Web Components/builder/tokens, ChatView, Vitest, Playwright, Vite.
+
+## Chunk1: Implement owned behavior and composition
+
+### Task1: Public reasoning sideband
+
+Files: packages/agent-core/src/llm/{types,openai-format,openai-stream,openai,ollama,gemini,anthropic}.ts; runtime.ts; runtime event types as owned there; lib/agent-plugin/ui/controller.ts; document-read-answer.ts; targeted tests.
+
+- [x] Write failing adapter/runtime/controller tests for explicitly returned reasoning, separation from answer/history and ignored late callbacks.
+- [x] Add optional fifth provider chatStream argument onReasoning; normalize OpenAI-compatible reasoning_content/reasoning, Gemini thought text and Anthropic exposed thinking events without exposing signatures/redacted/internal blocks.
+- [x] Forward a reasoning_delta runtime event and optional controller onReasoningDelta callback under existing lifetime guards. Expose optional reasoning callback in document-read-answer. Keep model request defaults unchanged.
+- [x] Run affected unit cases and package build. Independently review spec then correctness.
+
+### Task2: ChatView/composer/answer craft
+
+Files: packages/chat-ui/src/{chat-view,types,styles,index}.ts; package usage guide; targeted unit cases.
+
+- [x] Add failing tests for appendReasoningDelta/endReasoning, reader-owned disclosure state, bounded reasoning, abort/clear/dispose and no copy/save/apply/context contamination.
+- [x] Reuse r-reasoning. Add public methods appendReasoningDelta(text), endReasoning({interrupted?}); conclude on first answer/end/stop. Do not fabricate absent reasoning. Preserve reading position and actual activity announcements.
+- [x] Refine composer, transcript typography/action states, artifact-result identity and compact progress presentation. Maintain IME, drafts, editable next draft, explicit Stop and queue semantics; avoid per-token animations.
+- [x] Run affected package tests/build and independent spec/correctness review.
+
+### Task3: Artifact reader/editor workbench
+
+Files: lib/agent-plugin/ui/{artifacts-view,artifact-presentation,artifact-workflow}.ts; a dedicated owned artifact style file if warranted; targeted unit cases. Shared product translations remain root-owned.
+
+- [x] Write failing tests for preview/edit, version selection with dirty draft, cancellation and export/review semantics.
+- [x] Build readable text preview with the shared safe chat Markdown renderer, an explicit edit mode, compact coherent toolbar/version/actions, source disclosure for structured content and nearby deletion confirmation with safe focus.
+- [x] Preserve all storage/draft/schema/native-document-review guards; do not autosave or infer artifacts from prose.
+- [x] Provide any new label keys/defaults for root localization; verify focused units and independent reviews.
+
+### Task4: Host shell and contextual entries (root integration)
+
+Files: panel.ts, panel-workspace.ts, task-entry.ts, workspace-labels.ts, styles/assistant.css and optional owned style sheet, shared i18n types/messages, affected navigation integration tests.
+
+- [x] Reproduce current two-row chrome and automatic settings routing in acceptance; assert the new one-row task-centered contract independently.
+- [x] Place Chat/Artifacts/Conversations in the single compact header. Move actual model/settings trigger to composer context; Sources remains near composition. Keep all routes operable through real user paths.
+- [x] Start in Chat without preparing models. Wire real reasoning callbacks in controller and direct read paths to ChatView under existing request lifetime checks.
+- [x] Align surrounding settings/source/task details with the new hierarchy and update all product locales. Avoid a second token/motion vocabulary.
+- [x] Update tests to use the actual revised navigation paths, preserving behavioral coverage; do not weaken assertions/timeouts/retries to mask failures.
+
+## Chunk2: Browser craft acceptance and delivery
+
+### Task5: Actual walkthrough, motion and upstream ownership
+
+- [x] Build once and run the affected tests serially against that build. Keep heavy capture and E2E runs sequential.
+- [x] Capture baseline/current representative panes and deterministic streamed reasoning/answer/artifact workflows in light/dark, English/Portuguese, desktop/touch/narrow states.
+- [x] Record normal and reduced-motion flows, interruption/reversal, reader scroll, focus return and artifact preview/edit/continue. Inspect actual frames, not only video creation success.
+- [x] If a ran component defect is reproduced, fix its owner and add library-level failing/passing coverage; verify local consumed package rather than hiding it in host CSS. Do not publish a package without explicit release authorization.
+- [x] Independent visual/spec and code-quality review; fix findings and repeat only affected checks/captures.
+- [x] Update the local design contract/report and provide scoped evidence/remaining limits. Commit/push/update PR290 only after actual screenshot/recording acceptance; attach any created upstream PR. Do not merge or publish.
diff --git a/docs/superpowers/plans/2026-10-21-assistant-markdown-craft.md b/docs/superpowers/plans/2026-10-21-assistant-markdown-craft.md
new file mode 100644
index 000000000..167dcd7a4
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-assistant-markdown-craft.md
@@ -0,0 +1,38 @@
+# Assistant Markdown craft implementation plan
+
+> **For agentic workers:** Use subagent-driven-development with separate spec/code review.
+
+**Goal:** Give Chat and Artifacts one polished, safe table/code presentation.
+
+**Architecture:** Keep Marked token parsing and safe DOM construction. One small presentation
+owner supplies shared block styles, code actions and bounded lazy token highlighting. The
+existing streaming cache and host source/state ownership remain authoritative.
+
+**Tech Stack:** TypeScript, native DOM, ran tokens, Marked, declared Shiki 4.4.3, Vitest/Playwright.
+
+## Chunk 1: Shared presentation
+
+- [x] Read all renderer consumers and existing streaming/copy/security tests; add focused RED
+ cases for discarded language/alignment and code copy behavior before changing implementation.
+- [x] Add explicit fine-grained Shiki dependency/imports and safe dual-theme token rendering;
+ bound completed-code highlighting and guard stale/selected/detached targets.
+- [x] Add a renderer-owned code header/copy lifecycle with localized states and disposal.
+ Wire it through completed/restored/streamed Chat and Artifact previews.
+- [x] Consolidate table/code CSS; use quiet table hierarchy, explicit column alignment and
+ scoped horizontal scrolling. Remove conflicting artifact-only block rules.
+- [x] Run focused Markdown/streaming/security/artifact tests and types/lint; fix actual failures.
+- [x] Obtain independent spec then code review; correct findings before acceptance.
+
+## Chunk 2: Actual acceptance and delivery
+
+- [x] Build the final combined navigation/Markdown source and record its identity.
+- [x] Run the relevant whole-assistant browser sweep and all unit tests once on final source.
+- [x] Capture before/after Chat and Artifact tables/code, copy/scroll recordings, and the
+ corrected return/dirty-confirmation path across four declared environments.
+- [x] Inspect original images and decoded recording frames. Reject visible defects even if
+ assertions pass; mark the earlier missed acceptance as superseded.
+- [x] Save a concise corrective report and prepare accepted changes for the existing PR
+ without loosening thresholds or assertions.
+
+Remote CI is observed after the approved push; the PR and local acceptance report
+retain its final result without changing the tested runtime.
diff --git a/docs/superpowers/plans/2026-10-21-assistant-navigation-and-density.md b/docs/superpowers/plans/2026-10-21-assistant-navigation-and-density.md
new file mode 100644
index 000000000..6a837701a
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-assistant-navigation-and-density.md
@@ -0,0 +1,36 @@
+# Assistant navigation and density implementation plan
+
+> **For agentic workers:** Use subagent-driven development for implementation and independent review. Steps use checkbox syntax for tracking.
+
+**Goal:** Correct the two rejected compositions, audit analogous views and update PR #289 only after actual rendered acceptance.
+
+**Architecture:** The existing panel workspace owns parallel navigation and focus. View owners retain nested artifact flows and all data operations; shared assistant CSS owns density and alignment. No framework or dependency changes.
+
+**Tech Stack:** TypeScript, ranui builders/tokens, Vite, Vitest, Playwright.
+
+## Task 1: Baseline, navigation and hierarchy
+
+- [x] Capture the unchanged History, Artifacts, Models and Sources panes in a real browser.
+- [x] Add failing owner/browser checks for explicit Chat navigation, idempotent destination selection, top-level heading alignment and absent top-level Back.
+- [x] Update `panel-workspace.ts`, `panel.ts`, `sources-view.ts`, `artifacts-view.ts` and `view-header.ts`: visible navigation, selected state, top-level headers, genuine nested Back, runtime ownership.
+- [x] Add `agentWorkspaceChat` to i18n types and all seven locale dictionaries.
+- [x] Style the navigation and headers in `styles/assistant.css`; retain keyboard and coarse input targets.
+- [x] Run focused owner/view/artifact/history tests and review against the scope lock.
+
+## Task 2: Command composition and craft
+
+- [x] Add browser regression checks for equal command frames, 32px fine/44px coarse targets, checkbox/icon/text columns and ordinary Import/Attach roles.
+- [x] Correct History layout and secondary control treatment in shared CSS; preserve all operations.
+- [x] Add restrained entry motion using existing ran tokens and a reduced-motion guard.
+- [x] Inspect actual full-pane screenshots and iterate on proportions, density, spacing and themes.
+
+## Task 3: Regression and acceptance
+
+- [x] Adapt affected tests to the new product navigation. Retain actual interaction coverage and independent geometry checks; do not preserve rejected layout contracts.
+- [x] Run relevant assistant owner/unit suites and browser workspace/history/source/artifact/layout suites without retries.
+- [x] Exercise narrow/coarse, translated, dark, nested, loading/error and confirmation states; inspect captured PNGs.
+- [x] Reverse-check deliberate text/frame drift and restore.
+- [x] Run `pnpm lint:ts`, `pnpm format:check`, `pnpm build` and `git diff --check`.
+- [x] Replace superseded rules in `docs/design-system.md` and add an evidence-backed acceptance record with real captures and limitations.
+- [x] Obtain independent specification/code/visual review and resolve findings.
+- [ ] Commit and push to the existing PR branch, rewrite PR metadata to describe the final implementation and evidence, then inspect CI for new failures.
diff --git a/docs/superpowers/plans/2026-10-21-assistant-single-return.md b/docs/superpowers/plans/2026-10-21-assistant-single-return.md
new file mode 100644
index 000000000..07f9d53d0
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-assistant-single-return.md
@@ -0,0 +1,34 @@
+# Assistant single-return correction plan
+
+> **For agentic workers:** Use subagent-driven-development for implementation and separate
+> spec/code review. The current checkout is already isolated on the existing PR branch.
+
+**Goal:** Remove duplicated return navigation and visible command drift in the actual artifact flow.
+
+**Architecture:** The shell owns the single return location; the artifact view retains its
+existing guarded return operation. Reuse the native button/action rather than duplicating
+navigation state or deleting dirty/busy protection. Shared CSS owns command insets.
+
+**Tech Stack:** TypeScript, native DOM/ranui, Vitest, Playwright.
+
+## Chunk 1: Correct the application composition
+
+- [x] Add a failing browser regression for one return location and the actual return destination
+ across root list, detail/import and dirty confirmation. Measure visible command starts.
+- [x] Implement the smallest change in `lib/agent-plugin/ui/artifacts-view.ts`, `panel.ts`,
+ `panel-workspace.ts` if needed, and `styles/assistant*.css`. Keep state ownership intact.
+- [x] Replace the superseded double-return contract in `docs/design-system.md`; migrate tests
+ to the new real route without removing dirty, busy, focus or overflow assertions.
+- [x] Run affected unit/browser suites, types/lint and changed-file formatting.
+- [x] Obtain independent spec and code review, fix actual findings, and verify again.
+
+## Chunk 2: Actual acceptance and delivery
+
+- [x] Build the final production source and record its exact identity.
+- [x] Capture complete panes and actual recordings for the hierarchy and dirty cancel/discard
+ in desktop light/dark and 320/360px Portuguese coarse input; inspect originals and video frames.
+- [x] Update the user-facing report with corrected evidence and acknowledge the previous miss.
+- [x] Prepare only accepted changes and evidence for the existing PR update.
+
+Remote checks are observed after the approved push; the PR and local acceptance
+report retain their final result without changing the tested runtime.
diff --git a/docs/superpowers/plans/2026-10-22-assistant-workbench-redesign.md b/docs/superpowers/plans/2026-10-22-assistant-workbench-redesign.md
new file mode 100644
index 000000000..269fe6a66
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-22-assistant-workbench-redesign.md
@@ -0,0 +1,85 @@
+# Assistant workbench redesign implementation plan
+
+> **For agentic workers:** Use subagent-driven-development for isolated Chat/Markdown and
+> Artifact implementation; root owns shell/composer integration. Independent spec and code/
+> visual review must follow. Preserve behavioral/security checks and inspect real media.
+
+**Goal:** Redesign Document's actual assistant into a coherent document-first conversation
+and artifact workbench, retaining its functional lifecycle and producing reviewable evidence.
+
+**Architecture:** A small presentation-layout owner extends the existing panel width and
+workspace owners. ChatView remains generic; artifacts retain their store/draft/refinement
+owners. Shared Markdown remains one safe renderer. No new request/storage engine.
+
+**Tech stack:** TypeScript, existing ranui builder/components/tokens, Vite, Vitest, Playwright.
+
+## Chunk 1: Independent contract and baseline
+
+- [x] Read the ranui plugin/owners and current host contracts; inspect actual editor captures.
+- [x] Present and record the workbench direction and task/safety boundaries in the paired spec.
+- [x] Independent review of spec/integration and address concrete findings before implementation.
+- [x] Add a real-browser failing test for explicit expansion, dual chat/artifact visibility,
+ preserved draft/mode on collapse and one guarded return. Use fixture transport only, never
+ fake product UI. Wait for a real stream before advancing it; use `Hello` for an ordinary-chat
+ fixture, because document-bound prompts exercise a separate native capture lifecycle.
+
+## Chunk 2: Parallel bounded owners
+
+### Chat and Markdown owner
+
+Files: `packages/chat-ui/src/{chat-view,styles,markdown-styles,markdown-presentation}.ts` as
+needed; meaningful co-located generic tests under `test/unit/`.
+
+- [x] Read relevant existing ChatView/Markdown tests and establish independent expectations.
+- [x] Add/regress observable behavior before functional changes, run the failing test, then
+ implement. A pure visual change is verified in real browser, not a mirrored CSS-string test.
+- [x] Redesign conversation measure, rhythm, user messages, inline phase/reasoning/answer
+ hierarchy and restrained actions; preserve virtualization, reader anchoring and selection.
+- [x] Redesign shared code/table presentation without weakening safe links/literal media,
+ parser bounds, clipboard integrity, native selection, scroll or lifecycle/disposal guards.
+- [x] Test delayed copy completion across stream changes and reduced-motion state where changed.
+
+### Artifact owner
+
+Files: `lib/agent-plugin/ui/{artifacts-view,artifact-workflow,artifact-presentation}.ts` as
+needed, `styles/assistant-artifacts.css`, relevant artifact unit tests.
+
+- [x] Test meaningful draft/version/navigation behavior before functional changes.
+- [x] Build one compact object toolbar, calm reader/editor canvas and coherent command/footer
+ region. Keep object identity clear without competing title stacks; never remove user body text.
+- [x] Preserve source data, explicit version saves, dirty cancel/discard, conflicts, refinement
+ acceptance, export and native document review. Expose structure for the shell's split layout.
+- [x] Run relevant artifact state/interaction tests; leave store/schema/inference untouched.
+
+### Root shell/composer owner
+
+Files: `lib/agent-plugin/ui/{panel,panel-workspace,panel-resize,speech-input}.ts`, a small
+`panel-layout.ts` if needed, `styles/assistant.css`, seven locale messages/types, shell tests.
+
+- [x] Implement explicit expand/collapse with existing localized expansion label and a matching
+ collapse label. Separate temporary expanded width from persisted dock preference.
+- [x] At 840px+ assistant width, show Chat and Artifacts as independent columns. Use selected
+ single surface below that width. Preserve scroll/draft/focus and correct current-state labels.
+- [x] Keep the artifact-owned guarded return; wide mode places it with the artifact surface,
+ compact mode uses the existing single shell return slot. Do not clone its operation.
+- [x] Group voice language and privacy in a reachable voice-options disclosure, keeping source/
+ microphone/Send discoverable and Stop independently reachable. Test real popup keyboard/focus,
+ language selection, denied permission, active dictation, narrow reflow and draft preservation.
+- [x] Remove only redundant first-use helper rows; preserve actual task actions and recovery.
+
+## Chunk 3: Integration, craft and delivery
+
+- [ ] Build shared workspaces once after bounded edits; avoid parallel dist/result mutations.
+- [ ] Run meaningful new browser tests against the built actual editor; retain existing source,
+ streaming, Markdown safety, reasoning, queue, dictation, focus and artifact regressions.
+- [ ] Adapt obsolete presentation assertions to the independently declared new design contract;
+ demonstrate relevant visible-text drift/overflow or async-feedback mutation rejection.
+- [ ] Capture before/after full editor and panes: dock/expanded artifact, streamed reasoning/
+ answer, draft/stop/error, table/code, edit/version/refinement and narrow layouts. Include
+ light/dark, English/Chinese/Portuguese, keyboard/touch and reduced motion where relevant.
+- [ ] Record continuous actual interactions; bind source/dependency/build/media/state hashes.
+ Inspect screenshots and video frames/playback, then independent visual and code review.
+- [ ] Run `pnpm run lint:ts`, `pnpm run format:check`, meaningful unit tests and affected
+ real-browser suites. Update the canonical application design contract and evidence summary.
+- [ ] Only after local visual/interaction review passes, commit/push to PR #290 and rewrite its
+ title/body for the final redesign. Inspect head-specific CI. No merge/package publication.
diff --git a/docs/superpowers/specs/2026-10-21-assistant-conversation-craft.md b/docs/superpowers/specs/2026-10-21-assistant-conversation-craft.md
new file mode 100644
index 000000000..17ed0b7eb
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-21-assistant-conversation-craft.md
@@ -0,0 +1,28 @@
+# Assistant conversation craft
+
+The user explicitly approved continuing the task-centered layout, details and purposeful-motion direction, including composer, streamed answers, provider reasoning and artifacts. They require actual screenshots and recordings before PR updates, permit incompatible presentation changes, and want component defects fixed upstream in ran.
+
+## Observed baseline and ownership
+
+Baseline244a9a8 has five persistent text destinations, top-level workspace h2s, a compact but administrative settings flow, a shared ChatView, explicit artifact provenance, real preparation/read/planning states and robust drafts/cancellation/review guards. Public provider reasoning is currently discarded. Installed ranui0.5 already supplies r-reasoning, disclosure-row, markdown and popover; reuse their contracts. Application layout belongs to document, reusable control defects to ran.
+
+## Chosen design
+
+1. **One compact header.** Remove the second five-tab row. Retain a home/Chat identity, Artifacts and Conversations as compact named/tooltip commands, then New and Close. Source management belongs beside composition; the selected-model/settings entry belongs below the composer. Primary navigation stays reachable across views. Chat is the starting view; missing configuration is an explicit preparation action, never an automatic switch into a settings form. Keep a clear return route on secondary surfaces.
+2. **Composition as one object.** The draft, source context, attachment/voice controls and Send/Stop share one stable composer. A quiet model entry follows it; status/help must not form a stack of duplicate setup paragraphs. Preserve IME, multiline drafting, selection, queue/stop and actual destination disclosures. Long/localized labels reflow without truncating the editable lane.
+3. **Answers as a reading surface.** User messages remain compact; assistant content has restrained typography, bounded code/table overflow and stable actions. Running, interrupted, error, review and completed states have distinct real feedback. Never simulate progress, replay a completed answer as fake tokens or let streaming drag a reader who scrolled away.
+4. **Public reasoning only.** Extend the provider stream with an optional sideband callback for explicitly returned reasoning/thought-summary text. Answer text and tool arguments remain separate, including complete JSON and tool-bearing fallback responses. Gate callbacks per inference as well as per user run, so a retained callback cannot emit after its inference settles while the tool loop continues. End each phase before the first answer/tool event or settlement; Stop, supersession, conversation changes and disposal also close the presentation. ChatView renders it with ranui r-reasoning, respecting reader open/close ownership; it never participates in copy/save/apply/model context. Cap the total at 64 KiB of UTF-8 per user run, truncate only at a code point boundary, show one localized truncation notice and ignore further reasoning until the next run. Successive inference phases share that run budget. It is presentation-only, not persisted in conversation history. Unsupported providers show their real activity state and no invented reasoning block. Do not enable extra thinking requests or alter model defaults/cost policy.
+5. **Artifacts as objects.** Give saved artifacts a readable preview and explicit edit state, compact title/version/mode controls, a coherent action bar and local continuation workflow. Reuse the chat package’s safe Markdown renderer for text preview so HTML and image tokens remain literal and preview cannot introduce passive remote-media requests. Installed r-markdown intentionally supports media and has no public disabling option; do not patch shadow DOM or duplicate parsers to force it into this host’s stricter source policy. Keep raw structured data behind an explicit disclosure; preserve schema validation, draft/version conflicts, native-document review, export and explicit saves. Deletion uses a nearby clear question and short confirmation/cancel actions, with focus restoration. Avoid duplicate title stacks and scattered controls.
+6. **Purposeful motion.** Use existing ran fast/base roles for layer changes, reasoning/disclosure expansion and local feedback. No palette/theme interpolation, text blur or decorative motion on every token. Motion reverses cleanly; reduced motion preserves states and focus. Animate a layer's content, not an ancestor of anchored controls.
+
+## Scope lock
+
+Allowed: document panel/workspace/task UI and relevant styles; packages/chat-ui; narrowly scoped provider/runtime/controller reasoning sideband; product labels in all shipped locales; tests, canonical local contract and actual evidence. Upstream ran only for a reproduced reusable-component defect, on its owning implementation/tests/docs. No vendor, credentials, publishing, default model, provider trust/routing, writing approval or storage-format changes. Existing PRs stay unchanged remotely until local visual and recording acceptance passes.
+
+## Alternatives considered
+
+Keeping five tabs and polishing their CSS leaves configuration/content/history with equal emphasis, so rejected. Hiding all routes behind a bespoke menu adds a new control and increases routine navigation cost, so rejected. Reuse compact primary header commands and contextual composer entries instead. Rebuilding a second reasoning/disclosure primitive is rejected because ranui already owns it.
+
+## Acceptance
+
+Real pane states in English and long Portuguese at432px desktop pane /320/360px windows, light/dark and fine/coarse input. Exercise chat drafts, mixed Markdown streaming, reasoning absent/present, manual reasoning collapse, stop/error, reader scroll, artifact preview/edit/version/cancel/apply-review/export and model/source entry. Check actual text/control geometry, physical targets, accessible names, keyboard/focus, owned scrolling and reduced motion. Screen recordings must show the implemented full flow, with scripted deterministic service events clearly identified as fixtures, not real-model quality evidence. Source-level unit/regression tests cannot substitute for this acceptance. Independent design and correctness review must both pass before updating PR290 (and a ran PR if required).
diff --git a/docs/superpowers/specs/2026-10-21-assistant-markdown-craft-design.md b/docs/superpowers/specs/2026-10-21-assistant-markdown-craft-design.md
new file mode 100644
index 000000000..96f0c5a70
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-21-assistant-markdown-craft-design.md
@@ -0,0 +1,59 @@
+# Assistant table and code-block craft
+
+The user rejected the current table/code presentation and requested industry references.
+This work continues the authorized whole-assistant polish; the single-return correction
+remains part of the same delivery and must retain its verified behavior.
+
+## Evidence and direction
+
+| Assumption | Evidence |
+| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| The renderer drops fence language and table column alignment | `packages/chat-ui/src/markdown.ts`, code/table branches |
+| Artifact text preview already uses the same safe renderer | `lib/agent-plugin/ui/artifact-presentation.ts` |
+| Chat and artifact table/code styles have separate owners | `packages/chat-ui/src/styles.ts`, `styles/assistant-artifacts.css` |
+| ChatGPT exposes language and Copy on code blocks | [official help](https://help.openai.com/en/articles/20001246-working-with-writing-blocks-and-code-blocks-in-chatgpt) |
+| A quiet table can use hierarchy and alignment without a heavy header slab | [Geist Table](https://vercel.com/geist/table), actual public-page screenshot inspected |
+| Mature code blocks separate language/actions from syntax-highlighted content | [Geist Code Block](https://vercel.com/geist/code-block), actual public-page screenshot inspected; [AI Elements](https://elements.ai-sdk.dev/components/code-block) |
+| Ran's existing Shiki helper is private; its full Markdown component also enables embedded renderers | `work/ran/packages/ranui/components/markdown/{highlight,render,index}.ts` |
+
+The shared safe Markdown renderer remains the owner. Tables use quiet headers, intentional
+column padding and restrained row separators; honor only explicit left/center/right Markdown
+alignment and use tabular numerals. Wide tables scroll within an accessible, named region.
+Do not invent sorting or spreadsheet controls for static answer tables.
+
+Code blocks gain one compact language/Copy header, restrained frame, readable mono type and
+preserved whitespace. Copy operates on the exact code payload and reports real success/failure.
+Use token spans for highlighting, never model HTML. Unknown/oversized/unfinished code remains
+readable plain code. Theme changes recolor without re-highlighting or replaying text.
+No execution or preview control is added without an actual supported execution path.
+
+## Scope lock
+
+- May change: `packages/chat-ui/src/{markdown,streaming-markdown,styles,chat-view,types,index}.ts`,
+ small co-located Markdown presentation/highlighting/style helpers, `packages/chat-ui/package.json`,
+ `pnpm-lock.yaml`, artifact presentation/labels and surrounding CSS, applicable shared locale
+ messages, affected tests and concise design/usage docs.
+- Leave model defaults, transport, storage schema, inference, document mutation, vendor code,
+ immutable evaluation archives and the verified navigation logic untouched.
+- Preserve safe links, literal HTML/images, streaming's 8000-character parser bound, raw source
+ used for Copy/Save/Apply, source selection, reader attachment, Stop acknowledgement and disposal.
+- Highlighting uses an explicit matching `shiki@4.4.3` dependency and fine-grained lazy core,
+ JavaScript regex engine, bounded language imports and GitHub light/dark themes. This uses the
+ same engine/version already in ran without deep-importing private build chunks. Highlight at
+ most 8000 UTF-16 characters in a completed block. The fixed language/alias import map covers
+ JavaScript, TypeScript, JSX, TSX, Python, JSON, HTML, CSS, Bash, SQL, YAML and Markdown;
+ unknown fences remain plain code, with no language-driven import path. Adding an
+ upstream public API would require a new release/large downstream binary patch for this focused
+ correction; the direct declared dependency is the smaller supported integration.
+- Own copy/highlight async work and timer cleanup in a renderer presentation lifecycle shared
+ by Chat and Artifacts. Guard removed/replaced content and pending clipboard results. Preserve
+ active text selection and horizontal scroll. Keep syntax font metrics stable.
+
+## Acceptance
+
+Verify language and exact clipboard payload, actual success/failure feedback, repeated copy,
+live localization/disposal, explicit table alignment, literal adversarial HTML/code, unknown
+languages, oversized/incomplete/streamed blocks, dark/light color and local horizontal scroll.
+Exercise both real Chat and Artifact previews with short/wide tables, multiline/long-line code,
+English/Portuguese and 320/360px touch. Inspect full panes plus recorded copy/scroll behavior
+before updating the existing PR. Existing parser/security/navigation assertions are retained.
diff --git a/docs/superpowers/specs/2026-10-21-assistant-navigation-and-density-design.md b/docs/superpowers/specs/2026-10-21-assistant-navigation-and-density-design.md
new file mode 100644
index 000000000..a8272620a
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-21-assistant-navigation-and-density-design.md
@@ -0,0 +1,77 @@
+# Assistant navigation and command density correction
+
+## Goal and authority
+
+The owner rejected the rendered History & data button cloud and the artificial
+Back/title hierarchy in Artifacts. Correct those surfaces and analogous Models,
+Conversations and Sources views; inspect real screenshots before updating PR #289.
+The follow-up requests polished visual craft and purposeful motion.
+
+Use [ran DESIGN.md](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/DESIGN.md)
+sections 2, 3, 8, 11 and 12. The owner's correction supersedes the previous
+application-specific 48/54px heading lane and gutter-extended history targets.
+
+Industry references: [Linear's UI refresh](https://linear.app/changelog/2026-03-12-ui-refresh)
+uses consistent navigation, headers and control hierarchy;
+[Notion's sidebar](https://www.notion.com/help/navigate-with-the-sidebar) distinguishes
+parallel top-level destinations from nested pages. These inform composition, not
+a copied palette, framework or visual identity.
+
+## Scope lock and verified assumptions
+
+| Evidence | Consequence |
+| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
+| User screenshots show large irregular history command surfaces and an indented Artifacts heading | Review the entire pane, including resting state and density |
+| `panel-workspace.ts` owns chat/settings/history/sources/artifacts visibility and focus | Keep navigation state with this owner |
+| `panel.ts` mounts all workspace triggers in the header; title returns to a recent/draft conversation | Keep the existing title behavior and add an explicit current-chat navigation action |
+| `artifacts-view.ts` owns list/import/editor, dirty drafts, async cancellation and confirmation | Remove only its list-level Back; retain nested Back and all guards |
+| `history-controls.ts` owns storage, downloads, busy guards and confirmation | Change its presentation, not persistence or operations |
+
+May edit assistant view composition, shared assistant CSS, seven locale labels,
+related unit/browser tests and application design/acceptance documentation.
+Do not change vendor files, packages' runtime/data contracts, dependency versions,
+model defaults, document writes, persistence formats or inference consent.
+
+## Chosen composition
+
+- Keep one quiet header with AI Assistant, New conversation and Close.
+- Place visible, persistent destinations beneath it: Chat, Artifacts,
+ Conversations, Sources and Models. Reuse current trigger nodes and the
+ workspace lifecycle; navigation selection is idempotent. A current-chat entry
+ preserves the displayed draft rather than selecting another conversation.
+ Narrow/translated navigation may scroll horizontally; no destination disappears.
+- All top-level h2 text, explanatory copy, search/field/list frames and history
+ command frames use the same 16px content inset. Top-level views have no Back.
+- Show runtime preparation/status only in Chat or the existing Models owner.
+ It must not insert an unrelated status band above Artifacts/Sources.
+- Use a 16px section heading in the auxiliary pane. Give title/description a
+ smaller gap than description/actions. Nested artifact Back is labeled and
+ precedes the title, allowing the title to return to the same content inset.
+- History commands form one equal-width, medium-density secondary column: 32px
+ minimum on fine input and 44px on coarse input, neutral resting control stroke,
+ no heavy gray fill or enclosing card. Checkbox and action icons share a 24px
+ lane; labels share 48px, measured from the pane's inside edge. Status text
+ follows its associated checkbox label. Use 8px inside action groups and 16px
+ before destructive commands. Preserve danger foreground and explicit confirmation.
+- Import/Attach open a workflow, so use ordinary secondary affordance. Save or
+ accept a concrete reviewed result remains a monochrome primary commitment.
+- Optional motion is a short opacity-only entry using ran's fast duration and
+ existing easing. No layout travel, blur, skeleton, delayed focus or new library.
+ Reduced-motion disables it; hover/focus feedback stays immediate.
+
+Alternatives rejected: keeping a Back row on each parallel view repeats navigation;
+a destination dropdown hides discoverability; a new app-wide shell or design system
+is unnecessary. Reuse native controls and the existing presentation owner.
+
+## Acceptance
+
+Capture full panes before/after, not just button crops: History expanded and
+confirmation; Artifacts empty/list/import/editor/return; Sources empty/populated;
+Models local/service/expanded; Chat and narrow navigation. Inspect light/dark,
+320/360px windows and the 432px desktop pane, English/Portuguese, fine/coarse
+input, keyboard focus, resizing and reduced motion. Verify current-chat drafts,
+artifact unsaved-change recovery, source actions, deletion cancellation/downloads
+and no automatic inference on navigation. Use independent browser coordinates
+and contrast checks alongside visual review; reverse-check text/frame drift.
+Record screenshots and findings. Only push/update the PR after the visual review
+and applicable lint/type/format/build and related regression checks pass.
diff --git a/docs/superpowers/specs/2026-10-21-assistant-single-return-design.md b/docs/superpowers/specs/2026-10-21-assistant-single-return-design.md
new file mode 100644
index 000000000..5d76c4193
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-21-assistant-single-return-design.md
@@ -0,0 +1,23 @@
+# Assistant single-return correction
+
+The previous visual acceptance missed a composition defect: the shell's Chat return and
+the artifact editor/import return were visible together. The local layout contract even
+allowed both. This follow-up corrects that contract under the user's existing authorization
+to simplify navigation and verify actual screenshots/recordings before updating the PR.
+
+- The shell owns one visible return location. An artifact detail/import returns to the
+ artifact list; the list returns to the displayed Chat. Other secondary root views return
+ to Chat. No second content-area Back row remains in the composed application.
+- Retain artifact-owned dirty confirmation, cancellation, busy state, localization and
+ draft lifetime. A central return must execute the existing operation rather than bypass it.
+- Escape follows the same visible hierarchy when it is not consumed by an active control.
+- Remove redundant nested collection chrome where the shell already establishes its context.
+ Preserve actual document contents and explicit editing/version commands.
+- Measure visible glyph/text ranges and icon viewports separately from hit frames. Quiet
+ artifact command groups must start on the content spine, including wrapped rows. Do not
+ move bordered field text out of its intentional control inset.
+- Validate list/detail/import, dirty cancel/discard, version changes, back-to-chat draft and
+ selection restoration, keyboard focus, busy state, English/Portuguese, light/dark,
+ desktop and 320/360px coarse input. Capture complete panes and an actual recorded path.
+- Update the existing application PR only after direct image/video review. Do not relax
+ screenshot tolerances or behavior assertions. Update ran only if the defect is upstream.
diff --git a/docs/superpowers/specs/2026-10-22-assistant-workbench-redesign.md b/docs/superpowers/specs/2026-10-22-assistant-workbench-redesign.md
new file mode 100644
index 000000000..910136a3b
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-assistant-workbench-redesign.md
@@ -0,0 +1,124 @@
+# Document assistant workbench redesign
+
+## User-authorized direction
+
+The user requested a complete redesign of the actual Document assistant, continuing the
+approved ran design/plugin direction and permitting incompatible presentation changes.
+They require real screenshots and continuous interaction recordings before updating PR #290.
+Use the existing request, storage and review lifecycles. This is one product redesign,
+not a new assistant backend or a separate demonstration site.
+
+## Evidence and decisions
+
+- Code facts: `panel.ts` composes mutually exclusive workspace surfaces; `panel-workspace.ts`
+ owns navigation, `panel-resize.ts` owns width, ChatView owns answers/composition, and
+ `artifacts-view.ts` owns version/draft guards. The baseline is `27ddb4c`.
+- Runtime facts: actual editor captures show a 432px assistant, a permanently visible voice
+ language/privacy strip, and an artifact replacing the conversation. Its object title,
+ mode/version row and broad action row compete with the document body.
+- References: [Claude artifact workflow](https://support.claude.com/en/articles/17153992-what-are-artifacts-and-how-do-i-use-them)
+ supports keeping the result beside the conversation; [ChatGPT content blocks](https://help.openai.com/en/articles/20001246-working-with-writing-blocks-and-code-blocks-in-chatgpt)
+ separates reusable writing/code and its applicable actions. Adapt these task relationships,
+ not their brand palette or unsupported execution features.
+- Owners: ranui plugin routes to DESIGN, INTERACTION and ACCEPTANCE. The installed ranui
+ 0.5.0 API/source remains authoritative for component use; the new guidance is read from
+ the matching ran checkout because it is not yet in the installed npm package.
+
+## Chosen composition
+
+Three approaches were considered: another visual refresh of the single pane; replacing the
+editor with an assistant application; and a document-first expandable workbench. Adopt the
+workbench: it materially improves the task without discarding the editor or inference owners.
+
+1. **Shell.** One 48px fine-input / 52px coarse-input header, content-sized Chat identity
+ and recognizable Artifacts/Conversations/New/Expand/Close commands. Default desktop assistant width is
+ 480px, with existing pointer/keyboard resize and a 320px minimum. Explicit expansion
+ uses at most 1040px while retaining at least 360px for the Office document. Hide Expand
+ when narrow input cannot benefit; four remaining icon commands and the content-sized title
+ must fit 320px touch. Manual pointer/keyboard resizing exits expansion and deliberately
+ updates the dock preference. Window resize only clamps presentation. Do not
+ overwrite the user's dock-width preference when entering/leaving expanded mode. Shared content
+ rails are 24px on normal desktop surfaces and 16px on narrow 320px/360px layouts.
+2. **Two working surfaces.** When Artifacts is selected at an assistant width of at least
+ 840px, Chat is left and the existing artifact list/detail is right, with independent scrolling. Columns have
+ 320px/480px minima, a single 1px divider and no decorative gap; distribute extra space 2:3.
+ Artifacts remains the selected route while Chat is a visible companion. Selecting Chat
+ explicitly returns to the centered reading view. Width-only transitions never call route-leave
+ cancellation or artifact refresh; preserve the mounted owner, selected artifact and version.
+ At smaller widths show the selected surface and one immediate-parent return. Expanded
+ desktop Chat uses a centered column capped at 680px, with prose capped at 68ch. Resizing back
+ preserves drafts, selection, reading position, artifact mode and dirty-state guards. If collapse
+ hides focused Chat, move focus to a visible destination while retaining the saved selection.
+ Relocate the existing artifact-owned guarded Back to its local artifact region in split mode
+ and the single shell return slot in compact mode; never clone it or duplicate returns.
+ Sources, settings and history retain their existing routes.
+3. **Conversation.** An intentional reading measure, 16px body copy on ordinary desktop
+ panes, coherent heading/list/table rhythm, compact user messages, quiet answer actions,
+ and a purposeful waiting/reasoning presentation. Only provider-exposed reasoning is
+ shown. Keep it separate from copied/saved answers; reader expansion and scroll intent
+ remain authoritative. Avoid replaying text or animating every streamed token.
+4. **Composer.** One framed drafting object, stable Send and independent Stop/queue behavior.
+ Sources and microphone remain discoverable. Put voice language and privacy guidance in
+ the existing privacy disclosure, extending it into voice options, with a meaningful accessible
+ name and reachable language control without closing its parent. Live status and Cancel remain outside
+ that disclosure. Preserve its recording, permission, selection, IME and cancellation
+ lifecycle. Remove redundant onboarding helper rows, not useful labels or recovery.
+5. **Artifact canvas.** A compact object/title/version/mode toolbar, one readable editable
+ document canvas and a coherent command/footer region. Keep user-authored body headings,
+ raw content and explicit Save version. Preserve version conflicts, dirty confirmations,
+ refinement proposal acceptance, export and Review in document. Deletion remains confirmed.
+6. **Tables and code.** Use the shared safe Markdown presentation in chat and artifacts.
+ Tables get deliberate column padding, quiet headers, numeric alignment and contained
+ horizontal scrolling. Code has a compact language/copy toolbar, readable mono metrics,
+ stable syntax colors and a distinct reading surface. Preserve tabs, blank lines, trailing
+ newline, native selection and scroll across streaming. Bind delayed copy feedback to its
+ source snapshot; no invented Run/Preview capability or passive remote media.
+7. **Details and motion.** Token-derived neutral surfaces, restrained functional borders,
+ visible focus, coarse targets at least 44px. Use interruptible fast transitions for
+ expansion/disclosure/press feedback; no theme interpolation, shimmer or decorative
+ background animation. Reduced motion preserves every state and action.
+
+## Scope lock
+
+May change: assistant presentation/navigation/resize/voice options in `lib/agent-plugin/ui/`,
+`packages/chat-ui/src/`, `styles/assistant*.css`, applicable seven-locale messages/types,
+meaningful tests, canonical application design contract and concise evidence docs. Keep shared
+owners small; do not grow `panel.ts` into a second state engine. Upstream ran is allowed only
+for a reproduced reusable component defect, with its own focused proof and PR update.
+
+Leave provider defaults, inference contracts, request/session identity, queue lease/Stop
+acknowledgement, explicit consent, persistence schema, safe renderer policy, native Office
+review/Apply/Undo guards, vendor assets and archived evaluations unchanged. No automatic model
+calls, downloads, document writes, account features, package publication or merge.
+
+## Acceptance and independent proof
+
+- Actual editor and assistant, not a fake host; deterministic provider events are disclosed
+ as transport fixtures, never real-model quality evidence. Retain baseline captures.
+- Desktop dock, expanded chat/artifact split, narrow 320/360px touch; English, Chinese and
+ long Portuguese; light/dark, keyboard/focus and reduced motion. Test resize while reading
+ and editing, route round trips, dirty cancel/discard and no double return.
+- Real clipboard equality before/after stream, delayed success/rejection across updates,
+ native text selection, long code/table scrolling and no document-wide overflow.
+- Real response/reasoning/start/stop/error UI interactions driven by disclosed fixtures, next
+ draft and queue behavior; no fabricated reasoning, persistence or completion. External provider
+ access is optional. Version/refinement/export/review remain explicit and distinguish their outcomes.
+- Independent geometry expectations precede edits. Show seeded drift/overflow or stale
+ feedback is rejected, then restoration passes. Preserve existing behavioral/security
+ tests; revise obsolete presentation expectations only against this declared new contract.
+- Bind media to runtime sources/dependencies, build, state, viewport/theme/locale and hashes.
+ Agent visual/code review, automated results and human decisions remain distinct. Real
+ screenshots and recordings must be inspected before authorized PR update; no human
+ acceptance is inferred. Run relevant build/type/lint/unit/browser gates and inspect CI
+ on the new pushed head.
+- Walk through tasks informed by _Don't Make Me Think_: recognize conversation/artifact/version,
+ find a destination, resume a draft, choose voice language, save a version, accept refinement,
+ open document review and recover from cancellation/error. Check clear context, differentiated
+ action outcomes and guidance beside the relevant decision. Report the top three observed task
+ problems with evidence, impact and corrections, or fewer if fewer were observed. Agent/automation
+ walkthroughs are labeled separately from human usability tests and do not establish human
+ acceptance. Do not use arbitrary click budgets or counts of aesthetic elements.
+
+The [canonical application contract](../../design-system.md#assistant-application-layout-contract)
+owns these geometry and task-acceptance rules. Update obsolete presentation assertions against
+that declared contract; preserve the functional, security, source and consent contracts.
diff --git a/lib/agent-plugin/document-read-answer.ts b/lib/agent-plugin/document-read-answer.ts
index 3e5b95236..adf736f82 100644
--- a/lib/agent-plugin/document-read-answer.ts
+++ b/lib/agent-plugin/document-read-answer.ts
@@ -3,6 +3,12 @@ import { REFERENCE_CITATION_RULE } from '@ranuts/agent-core/llm/prompt';
import type { LLMMessage, LLMProvider } from '@ranuts/agent-core/llm/types';
import type { DocumentContext } from './document-context';
+export interface ReadAnswerOptions {
+ onReasoningDelta?: (text: string) => void;
+ onReasoningEnd?: () => void;
+ assertCurrent?: () => void;
+}
+
/** Standard tool-result continuation, with no execution capabilities in the answer step. */
export async function generateReadAnswer(
provider: LLMProvider,
@@ -12,6 +18,7 @@ export async function generateReadAnswer(
signal: AbortSignal,
onDelta?: (text: string) => void,
referenceData?: string,
+ options: ReadAnswerOptions = {},
): Promise {
signal.throwIfAborted();
const messages: LLMMessage[] = [
@@ -33,18 +40,48 @@ export async function generateReadAnswer(
},
...exchange,
];
- const response =
- onDelta && provider.chatStream
- ? await provider.chatStream(
- messages,
- [],
- (delta) => {
- if (!signal.aborted) onDelta(delta);
- },
- signal,
- )
- : await provider.chat(messages, [], signal);
+ let active = true;
+ let reasoningObserved = false;
+ let reasoningClosed = false;
+ const live = () => {
+ if (!active || signal.aborted) return false;
+ options.assertCurrent?.();
+ return !signal.aborted;
+ };
+ const endReasoning = () => {
+ if (reasoningClosed) return;
+ reasoningClosed = true;
+ if (reasoningObserved && live()) options.onReasoningEnd?.();
+ };
+ let response;
+ try {
+ response =
+ (onDelta || options.onReasoningDelta) && provider.chatStream
+ ? await provider.chatStream(
+ messages,
+ [],
+ (delta) => {
+ if (!delta || !live()) return;
+ endReasoning();
+ if (live()) onDelta?.(delta);
+ },
+ signal,
+ (text) => {
+ if (!text || reasoningClosed || !live()) return;
+ reasoningObserved = true;
+ options.onReasoningDelta?.(text);
+ },
+ )
+ : await provider.chat(messages, [], signal);
+ } finally {
+ try {
+ endReasoning();
+ } finally {
+ active = false;
+ }
+ }
signal.throwIfAborted();
+ options.assertCurrent?.();
if (response.toolCalls.length || incompleteResponseReason(response) || !response.text.trim())
throw new Error('Incomplete document answer');
return response.text.trim();
diff --git a/lib/agent-plugin/ui/artifact-presentation.ts b/lib/agent-plugin/ui/artifact-presentation.ts
index 90582d366..072e2d51d 100644
--- a/lib/agent-plugin/ui/artifact-presentation.ts
+++ b/lib/agent-plugin/ui/artifact-presentation.ts
@@ -1,6 +1,7 @@
+import { MarkdownPresentation, renderChatMarkdown, type MarkdownLabels } from '@ranuts/chat-ui';
import { validatePayload, payloadText, type ArtifactPayload } from '../artifacts/payload';
import { artifactFilename, exportPayload, type ArtifactExportFormat } from '../artifacts/export';
-export interface ArtifactPresentationLabels {
+export interface ArtifactPresentationLabels extends MarkdownLabels {
titleField?: string;
textField?: string;
export: string;
@@ -11,7 +12,7 @@ export interface ArtifactPresentationLabels {
previous: string;
structuredHint: string;
}
-/** Bounded editable data page. A hidden JSON owner keeps draft capture and AI review lossless. */
+/** Read-only reader and explicit field editor, backed by the canonical source. */
export function createArtifactPresentation(
text: HTMLTextAreaElement,
labels: ArtifactPresentationLabels,
@@ -21,12 +22,16 @@ export function createArtifactPresentation(
const el = document.createElement('div');
el.className = 'agent-artifact-presentation';
const preview = document.createElement('div');
- preview.className = 'agent-artifact-data-preview';
+ preview.className = 'agent-artifact-data-preview agent-artifact-reader cui-markdown';
+ const markdown = new MarkdownPresentation(preview, labels);
+ preview.dataset.artifactReader = 'true';
+ preview.tabIndex = -1;
const abort = new AbortController();
const kind = initial?.kind ?? 'text';
let page = 0;
let committing = false;
let disabled = false;
+ let editing = false;
function payload(): ArtifactPayload {
const data =
kind === 'text' ? { kind: 'text' as const, text: text.value } : validatePayload(JSON.parse(text.value));
@@ -34,8 +39,16 @@ export function createArtifactPresentation(
return data;
}
function render() {
+ if (kind === 'text') {
+ preview.hidden = editing;
+ // Editing changes only the canonical source. Rebuild the reader once
+ // when it becomes visible, avoiding Markdown work on each keystroke.
+ if (!editing) renderChatMarkdown(preview, text.value, markdown);
+ return;
+ }
+ markdown.releaseTree(preview);
preview.replaceChildren();
- if (kind === 'text') return;
+ preview.hidden = false;
const data = payload();
if (data.kind === 'table') {
page = Math.min(page, Math.max(0, Math.ceil(data.rows.length / 10) - 1));
@@ -43,8 +56,9 @@ export function createArtifactPresentation(
const head = document.createElement('tr');
for (const [columnIndex, column] of data.columns.entries()) {
const cell = document.createElement('th');
+ cell.scope = 'col';
cell.append(
- field(column, `${labels.preview} · ${columnIndex + 1}`, (value) => {
+ valueField(column, `${labels.preview} · ${columnIndex + 1}`, (value) => {
data.columns[columnIndex] = value;
commit(data);
}),
@@ -57,7 +71,7 @@ export function createArtifactPresentation(
for (const [columnIndex, value] of row.entries()) {
const td = document.createElement('td');
td.append(
- field(value, `${data.columns[columnIndex]} · ${page * 10 + rowIndex + 1}`, (value) => {
+ valueField(value, `${data.columns[columnIndex]} · ${page * 10 + rowIndex + 1}`, (value) => {
data.rows[page * 10 + rowIndex][columnIndex] = value;
commit(data);
}),
@@ -66,16 +80,21 @@ export function createArtifactPresentation(
}
table.append(tr);
}
- preview.append(table);
+ const wrapper = document.createElement('div');
+ wrapper.className = 'cui-table';
+ wrapper.tabIndex = 0;
+ markdown.table(wrapper);
+ wrapper.append(table);
+ preview.append(wrapper);
pages(Math.max(1, Math.ceil(data.rows.length / 10)));
} else if (data.kind === 'slides') {
page = Math.min(page, data.slides.length - 1);
preview.append(
- field(data.slides[page].title, `${labels.titleField ?? labels.preview} · ${page + 1}`, (value) => {
+ valueField(data.slides[page].title, `${labels.titleField ?? labels.preview} · ${page + 1}`, (value) => {
data.slides[page].title = value;
commit(data);
}),
- field(data.slides[page].body, `${labels.textField ?? labels.preview} · ${page + 1}`, (value) => {
+ valueField(data.slides[page].body, `${labels.textField ?? labels.preview} · ${page + 1}`, (value) => {
data.slides[page].body = value;
commit(data);
}),
@@ -92,6 +111,13 @@ export function createArtifactPresentation(
committing = false;
}
}
+ function valueField(value: string, label: string, change: (value: string) => void) {
+ if (editing) return field(value, label, change);
+ const content = document.createElement('div');
+ content.className = 'agent-artifact-field-value';
+ content.textContent = value;
+ return content;
+ }
function field(value: string, label: string, change: (value: string) => void) {
const input = document.createElement('textarea');
input.className = 'agent-artifact-cell';
@@ -104,6 +130,8 @@ export function createArtifactPresentation(
return input;
}
function pages(count: number) {
+ const navigation = document.createElement('div');
+ navigation.className = 'agent-artifact-pagination';
for (const [label, step] of [
[labels.previous, -1],
[labels.next, 1],
@@ -117,12 +145,14 @@ export function createArtifactPresentation(
page += step;
render();
});
- preview.append(button);
+ navigation.append(button);
}
- preview.append(document.createTextNode(` ${page + 1} / ${count}`));
+ navigation.append(document.createTextNode(` ${page + 1} / ${count}`));
+ preview.append(navigation);
}
const disclosure = document.createElement('details'),
summary = document.createElement('summary');
+ disclosure.className = 'agent-artifact-export';
summary.textContent = labels.export;
const hint = document.createElement('p');
hint.textContent = labels.structuredHint;
@@ -179,19 +209,33 @@ export function createArtifactPresentation(
{ signal: abort.signal },
);
el.append(preview, disclosure);
- render();
+ safeRender();
return {
+ exportEl: disclosure,
+ setEditing(value: boolean) {
+ editing = value;
+ safeRender();
+ },
setDisabled(value: boolean) {
disabled = value;
- for (const input of el.querySelectorAll('button,textarea'))
+ for (const input of [
+ ...el.querySelectorAll('button,textarea'),
+ ...disclosure.querySelectorAll('button'),
+ ])
input.disabled = value || input.dataset.unavailable === 'true';
+ markdown.setDisabled(value);
+ },
+ dispose() {
+ abort.abort();
+ markdown.dispose();
+ disclosure.remove();
},
- dispose: () => abort.abort(),
setLabels(next: ArtifactPresentationLabels) {
labels = next;
+ markdown.setLabels(next);
summary.textContent = labels.export;
hint.textContent = labels.structuredHint;
- safeRender();
+ if (kind !== 'text') safeRender();
},
refresh: safeRender,
el,
diff --git a/lib/agent-plugin/ui/artifact-workflow.ts b/lib/agent-plugin/ui/artifact-workflow.ts
index 0c82ff034..15bd2e0c9 100644
--- a/lib/agent-plugin/ui/artifact-workflow.ts
+++ b/lib/agent-plugin/ui/artifact-workflow.ts
@@ -21,6 +21,8 @@ export interface ArtifactWorkflowLabels {
selected?: string;
entire?: string;
recovered?: string;
+ before?: string;
+ after?: string;
}
/** A request snapshot. The host queues this before resolving a prepared provider. */
export interface ArtifactRevisionRequest {
@@ -171,7 +173,7 @@ export function createArtifactWorkflow(options: {
const preview = document.createElement('div');
preview.className = 'agent-artifact-revision';
preview.hidden = true;
- const diff = document.createElement('p');
+ const diff = document.createElement('div');
diff.className = 'agent-artifact-diff';
const accept = makeButton('accept', () => {
void (async () => {
@@ -202,19 +204,44 @@ export function createArtifactWorkflow(options: {
});
showFull.hidden = true;
preview.append(diff, partial, showFull, accept, reject);
+ function comparisonLabel(side: 'before' | 'after') {
+ return labels[side] ?? (side === 'before' ? 'Original' : 'Proposed');
+ }
function renderDiff(before: string, after: string, full = false) {
- const change = textDifference(before, after);
const clipped = !full && before.length + after.length > 8000;
- const removed = document.createElement('del');
- removed.textContent = clipped ? change.removed.slice(0, 2400) : change.removed;
- const added = document.createElement('ins');
- added.textContent = clipped ? change.added.slice(0, 2400) : change.added;
- diff.replaceChildren(
- clipped ? change.prefix.slice(-1200) : change.prefix,
- removed,
- added,
- clipped ? change.suffix.slice(0, 1200) : change.suffix,
- );
+ if (before.includes('\n') || after.includes('\n')) {
+ diff.dataset.layout = 'blocks';
+ const blocks = (['before', 'after'] as const).map((side) => {
+ const block = document.createElement('section');
+ block.className = 'agent-artifact-diff-block';
+ block.dataset.artifactDiffSide = side;
+ const caption = document.createElement('h4');
+ caption.className = 'agent-artifact-diff-label';
+ caption.dataset.artifactDiffLabel = side;
+ caption.textContent = comparisonLabel(side);
+ const content = document.createElement('div');
+ content.className = 'agent-artifact-diff-text';
+ content.tabIndex = 0;
+ const value = side === 'before' ? before : after;
+ content.textContent = clipped ? value.slice(0, 3600) : value;
+ block.append(caption, content);
+ return block;
+ });
+ diff.replaceChildren(...blocks);
+ } else {
+ diff.dataset.layout = 'inline';
+ const change = textDifference(before, after);
+ const removed = document.createElement('del');
+ removed.textContent = clipped ? change.removed.slice(0, 2400) : change.removed;
+ const added = document.createElement('ins');
+ added.textContent = clipped ? change.added.slice(0, 2400) : change.added;
+ diff.replaceChildren(
+ clipped ? change.prefix.slice(-1200) : change.prefix,
+ removed,
+ added,
+ clipped ? change.suffix.slice(0, 1200) : change.suffix,
+ );
+ }
partial.textContent = labels.partial ?? '';
partial.hidden = !clipped;
showFull.hidden = !clipped;
@@ -293,6 +320,7 @@ export function createArtifactWorkflow(options: {
},
delta(text) {
if (current()) {
+ diff.dataset.layout = 'inline';
diff.textContent = text.slice(0, 8000);
partial.textContent = labels.partial ?? '';
partial.hidden = text.length <= 8000;
@@ -355,7 +383,10 @@ export function createArtifactWorkflow(options: {
note.textContent = message;
cancel?.();
}
- el.append(refine, form, note, preview);
+ const command = document.createElement('div');
+ command.className = 'agent-artifact-command-group';
+ command.append(refine);
+ el.append(command, form, note, preview);
return {
el,
invalidate,
@@ -375,6 +406,9 @@ export function createArtifactWorkflow(options: {
button.textContent = labels[button.dataset.action as keyof ArtifactWorkflowLabels] ?? '';
input.placeholder = labels.instruction;
input.setAttribute('aria-label', labels.instruction);
+ for (const caption of diff.querySelectorAll('[data-artifact-diff-label]'))
+ caption.textContent = comparisonLabel(caption.dataset.artifactDiffLabel as 'before' | 'after');
+ partial.textContent = labels.partial ?? '';
refreshScope();
},
dispose() {
diff --git a/lib/agent-plugin/ui/artifacts-view.ts b/lib/agent-plugin/ui/artifacts-view.ts
index 7a495906a..aa907376c 100644
--- a/lib/agent-plugin/ui/artifacts-view.ts
+++ b/lib/agent-plugin/ui/artifacts-view.ts
@@ -1,3 +1,4 @@
+import '../../../styles/assistant-artifacts.css';
import { createViewHeader, labelViewBack } from './view-header';
import { createArtifactPresentation, type ArtifactPresentationLabels } from './artifact-presentation';
import { validatePayload, payloadText } from '../artifacts/payload';
@@ -12,6 +13,10 @@ import type { MessageSaveOutcome } from '@ranuts/chat-ui';
import { ButtonBuilder, Div, View } from 'ranui/builder';
import { ArtifactError, type Artifact, type ArtifactProvenance, type ArtifactStore } from '../artifacts/store';
export interface ArtifactsLabels extends ArtifactPresentationLabels {
+ edit?: string;
+ cancelEditing?: string;
+ sourceData?: string;
+ unsaved?: string;
title: string;
empty: string;
back: string;
@@ -42,6 +47,8 @@ export function createArtifactsView(options: {
labels: ArtifactsLabels;
onApply(text: string): Promise | unknown;
onClose?(): void;
+ /** Compose the existing guarded Back control into the shell's single return slot. */
+ onBackChange?(back?: HTMLButtonElement): void;
conversation?(): string;
refinement?: {
labels: ArtifactWorkflowLabels;
@@ -49,12 +56,14 @@ export function createArtifactsView(options: {
stop(): void;
};
}) {
- const el = View('section').class('agent-artifacts').build();
+ const el = View('section').class('agent-artifacts').attr('aria-label', options.labels.title).build();
const heading = View('h2').build();
heading.tabIndex = -1;
const header = createViewHeader(heading);
let backDestination: 'conversation' | 'list' = 'conversation';
- const body = Div().build();
+ let backButton: HTMLButtonElement | undefined;
+ // Layout changes retain this scroll owner and the mounted draft beneath it.
+ const body = Div().class('agent-artifacts-body').build();
const status = View('p').class('agent-artifact-status').attr('role', 'status').build();
el.append(header, body, status);
let labels = options.labels;
@@ -63,6 +72,7 @@ export function createArtifactsView(options: {
let dirty = false;
let draftRevision = 0;
let workflow: ReturnType | undefined;
+ let setEditorMode: ((editing: boolean, focus?: boolean, invalidate?: boolean) => void) | undefined;
let captureDraft: (() => ArtifactDraftBinding) | undefined;
const invalidateDraft = () => {
++draftRevision;
@@ -74,11 +84,22 @@ export function createArtifactsView(options: {
const pendingSaves = new Set<(outcome: MessageSaveOutcome) => void>();
let cancelConfirmation: (() => void) | undefined;
const buttons = new Set();
+ function label(key: keyof ArtifactsLabels): string {
+ return (
+ labels[key] ??
+ (
+ {
+ edit: 'Edit',
+ cancelEditing: 'Cancel editing',
+ sourceData: 'Source data',
+ unsaved: 'Unsaved changes',
+ } as Partial>
+ )[key] ??
+ ''
+ );
+ }
function button(key: keyof ArtifactsLabels, run: () => void): HTMLButtonElement {
- const b = ButtonBuilder()
- .attr('type', 'button')
- .text(labels[key] ?? '')
- .build();
+ const b = ButtonBuilder().attr('type', 'button').text(label(key)).build();
b.dataset.action = key;
b.addEventListener('click', () => {
if (key !== 'discardChanges' && key !== 'cancel' && cancelConfirmation) {
@@ -92,12 +113,21 @@ export function createArtifactsView(options: {
return b;
}
function setBack(back: HTMLButtonElement, destination: 'conversation' | 'list'): void {
+ backButton = back;
backDestination = destination;
labelViewBack(
back,
(destination === 'conversation' ? labels.backToConversation : labels.backToList) ?? labels.back,
);
- header.prepend(back);
+ if (options.onBackChange) {
+ header.hidden = true;
+ options.onBackChange(back);
+ } else header.prepend(back);
+ }
+ function requestBack(): boolean {
+ if (!backButton) return false;
+ if (!backButton.disabled && !status.querySelector('[data-action=discardChanges]')) backButton.click();
+ return true;
}
function draftContent(text: string) {
const version = current?.versions.find(
@@ -141,7 +171,7 @@ export function createArtifactsView(options: {
});
editor(a);
status.textContent = labels.saved;
- body.querySelector('textarea[data-artifact-source]')?.focus();
+ body.querySelector('[data-artifact-reader]')?.focus();
});
}),
);
@@ -179,9 +209,13 @@ export function createArtifactsView(options: {
workflow?.dispose();
workflow = undefined;
captureDraft = undefined;
+ setEditorMode = undefined;
cancelConfirmation?.();
cancelConfirmation = undefined;
buttons.clear();
+ backButton = undefined;
+ options.onBackChange?.();
+ header.hidden = false;
header.replaceChildren(heading);
body.replaceChildren();
status.textContent = '';
@@ -194,6 +228,7 @@ export function createArtifactsView(options: {
cancelConfirmation = undefined;
return next();
}
+ const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
status.replaceChildren(
document.createTextNode(labels.discardChanges + ' '),
button('discardChanges', () => {
@@ -206,8 +241,11 @@ export function createArtifactsView(options: {
cancelConfirmation?.();
cancelConfirmation = undefined;
status.textContent = '';
+ if (trigger?.isConnected) trigger.focus();
+ else backButton?.focus();
}),
);
+ status.querySelector('[data-action=cancel]')?.focus();
}
function editor(a: Artifact): void {
if (disposed) return;
@@ -226,6 +264,7 @@ export function createArtifactsView(options: {
.attr('aria-label', labels.titleField)
.build() as HTMLInputElement;
title.value = a.title;
+ title.className = 'agent-artifact-title';
const select = View('select').attr('aria-label', labels.version).build() as HTMLSelectElement;
for (const v of a.versions) {
const option = document.createElement('option');
@@ -241,18 +280,29 @@ export function createArtifactsView(options: {
.build() as HTMLTextAreaElement;
text.dataset.artifactSource = 'true';
text.value = a.versions.at(-1)!.text;
- const presentationHost = document.createElement('div');
+ const presentationHost = Div().class('agent-artifact-presentation-host').build();
const source = document.createElement('details');
const sourceSummary = document.createElement('summary');
- sourceSummary.textContent = labels.textField + ' · JSON';
+ sourceSummary.textContent = label('sourceData') + ' · JSON';
sourceSummary.dataset.artifactSourceSummary = 'true';
+ source.className = 'agent-artifact-source';
source.append(sourceSummary, text);
source.hidden = !(a.versions.at(-1)!.payload && a.versions.at(-1)!.payload!.kind !== 'text');
if (source.hidden) source.removeChild(text);
let presentation = createArtifactPresentation(text, labels, a.versions.at(-1)!.payload, () => title.value);
activePresentation = presentation;
presentationHost.append(presentation.el);
+ let editing = false;
+ const dirtyNote = document.createElement('span');
+ dirtyNote.className = 'agent-artifact-unsaved';
+ dirtyNote.textContent = label('unsaved');
+ dirtyNote.hidden = true;
+ const clearFeedback = () => {
+ if (!status.querySelector('[data-action=discardChanges]')) status.textContent = '';
+ };
const changed = () => {
+ clearFeedback();
+ dirtyNote.hidden = false;
invalidateDraft();
dirty = true;
const save = body.querySelector('[data-action="saveVersion"]');
@@ -263,6 +313,7 @@ export function createArtifactsView(options: {
select.addEventListener('change', () => {
const value = select.value;
const load = () => {
+ clearFeedback();
invalidateDraft();
selectedVersion = value;
title.value = a.title;
@@ -276,10 +327,11 @@ export function createArtifactsView(options: {
if (source.hidden) {
source.open = false;
if (text.parentElement === source) source.removeChild(text);
- form.insertBefore(text, presentationHost);
+ content.insertBefore(text, presentationHost);
} else source.append(text);
- if (presentation.kind !== 'text') form.insertBefore(actions, presentationHost);
- else form.insertBefore(actions, workflow?.el ?? null);
+ actions.insertBefore(presentation.exportEl, deleteButton);
+ setMode(false, false);
+ dirtyNote.hidden = value === String(a.versions.at(-1)!.version);
const apply = body.querySelector('[data-action=apply]');
if (apply) apply.hidden = loaded.payload?.kind !== undefined && loaded.payload.kind !== 'text';
dirty = value !== String(a.versions.at(-1)!.version);
@@ -307,7 +359,7 @@ export function createArtifactsView(options: {
});
editor(updated);
status.textContent = labels.saved;
- body.querySelector('textarea[data-artifact-source]')?.focus();
+ body.querySelector('[data-artifact-reader]')?.focus();
});
}),
button('apply', () => {
@@ -316,26 +368,85 @@ export function createArtifactsView(options: {
await options.onApply(text.value);
});
}),
- button('delete', () => {
- status.replaceChildren(
- button('confirmDelete', () => {
- void perform(async () => {
- await options.store.delete(a.id);
- dirty = false;
- await refresh();
- });
- }),
- button('cancel', () => {
- status.textContent = '';
- }),
- );
+ presentation.exportEl,
+ );
+ const deletePrompt = document.createElement('div');
+ deletePrompt.className = 'agent-artifact-delete-prompt';
+ deletePrompt.hidden = true;
+ const deleteButton = button('delete', () => {
+ const question = document.createElement('span');
+ question.dataset.artifactDeleteQuestion = 'true';
+ question.textContent = labels.confirmDelete;
+ const confirm = button('confirmDelete', () => {
+ void perform(async () => {
+ await options.store.delete(a.id);
+ dirty = false;
+ await refresh(true);
+ });
+ });
+ confirm.textContent = labels.delete;
+ const cancel = button('cancel', () => {
+ deletePrompt.hidden = true;
+ deletePrompt.replaceChildren();
+ deleteButton.focus();
+ });
+ deletePrompt.replaceChildren(question, confirm, cancel);
+ deletePrompt.hidden = false;
+ cancel.focus();
+ });
+ actions.append(deleteButton);
+ const previewButton = button('preview', () => setMode(false));
+ const editButton = button('edit', () => setMode(true));
+ const cancelEdit = button('cancelEditing', () =>
+ discard(() => {
+ title.value = a.title;
+ const loaded = a.versions.find((v) => String(v.version) === selectedVersion)!;
+ text.value = loaded.text;
+ dirty = selectedVersion !== String(a.versions.at(-1)!.version);
+ dirtyNote.hidden = !dirty;
+ actions.querySelector('[data-action=saveVersion]')!.disabled = !dirty;
+ presentation.refresh();
+ setMode(false);
}),
);
+ const modes = Div().class('agent-artifact-modes').children(previewButton, editButton, cancelEdit).build();
+ modes.setAttribute('role', 'group');
+ modes.setAttribute('aria-label', labels.preview);
+ const identity = Div().class('agent-artifact-identity').children(title, select).build();
+ const modebar = Div().class('agent-artifact-modebar').children(modes, dirtyNote).build();
+ const toolbar = Div().class('agent-artifact-toolbar').children(identity, modebar).build();
+ const content = Div()
+ .class('agent-artifact-content')
+ .children(...(source.hidden ? [text] : []), presentationHost, source)
+ .build();
+ const footer = View('footer').class('agent-artifact-footer').children(actions, deletePrompt, origin).build();
const form = Div()
- .class('agent-artifact-editor')
- .children(title, select, ...(source.hidden ? [text] : []), presentationHost, source, origin, actions)
+ .class('agent-artifact-editor agent-artifact-workbench')
+ .children(toolbar, content, footer)
.build();
- if (presentation.kind !== 'text') form.insertBefore(actions, presentationHost);
+ function setMode(value: boolean, focus = true, invalidate = true): void {
+ if (editing !== value && invalidate) invalidateDraft();
+ editing = value;
+ form.dataset.mode = editing ? 'edit' : 'preview';
+ title.readOnly = !editing;
+ text.hidden = !editing || presentation.kind !== 'text';
+ text.readOnly = !editing;
+ source.hidden = !editing || presentation.kind === 'text';
+ // The canonical source stays mounted; its disclosure is only writable in Edit.
+ if (presentation.kind !== 'text') text.hidden = false;
+ previewButton.setAttribute('aria-pressed', String(!editing));
+ editButton.setAttribute('aria-pressed', String(editing));
+ cancelEdit.hidden = !editing;
+ presentation.setEditing(editing);
+ workflow?.refreshScope();
+ if (focus) {
+ if (!editing) presentationHost.querySelector('[data-artifact-reader]')?.focus();
+ else if (presentation.kind === 'text') text.focus();
+ else (presentationHost.querySelector('textarea') ?? title).focus();
+ }
+ }
+ setEditorMode = setMode;
+ setMode(false, false);
body.append(form);
const applyButton = actions.querySelector('[data-action=apply]')!;
applyButton.hidden = !!a.versions.at(-1)!.payload && a.versions.at(-1)!.payload!.kind !== 'text';
@@ -347,8 +458,8 @@ export function createArtifactsView(options: {
title: title.value,
text: text.value,
...(presentation.kind === 'table' || presentation.kind === 'slides' ? { payloadKind: presentation.kind } : {}),
- start: presentation.kind === 'text' ? text.selectionStart : 0,
- end: presentation.kind === 'text' ? text.selectionEnd : 0,
+ start: editing && presentation.kind === 'text' ? text.selectionStart : 0,
+ end: editing && presentation.kind === 'text' ? text.selectionEnd : 0,
});
if (options.refinement) {
const isCurrent = (bound: ArtifactDraftBinding) =>
@@ -379,8 +490,11 @@ export function createArtifactsView(options: {
}
}
text.value = replaceArtifactRange(bound, replacement);
+ clearFeedback();
+ setMode(true, false, false);
presentation.refresh();
dirty = true;
+ dirtyNote.hidden = false;
++draftRevision;
body.querySelector('[data-action=saveVersion]')!.disabled = false;
if (bound.payloadKind) presentationHost.querySelector('textarea')?.focus();
@@ -391,19 +505,19 @@ export function createArtifactsView(options: {
return true;
},
});
- form.append(workflow.el);
+ footer.insertBefore(workflow.el, origin);
for (const event of ['select', 'click', 'keyup', 'input'])
text.addEventListener(event, () => workflow?.refreshScope());
}
form.querySelector('[data-action=saveVersion]')!.disabled = true;
- title.focus();
+ presentationHost.querySelector('[data-artifact-reader]')?.focus();
}
- async function refresh(): Promise {
- if (disposed || dirty) return;
+ async function refresh(ownedOperation = false): Promise {
+ if (disposed || dirty || (busy && !ownedOperation)) return;
const revision = ++generation;
try {
const rows = await options.store.list();
- if (disposed || revision !== generation || dirty) return;
+ if (disposed || revision !== generation || dirty || (busy && !ownedOperation)) return;
current = undefined;
reset();
const list = View('ul').class('agent-artifacts-list').build();
@@ -416,7 +530,7 @@ export function createArtifactsView(options: {
void perform(async () => {
const a = await options.store.get(row.id);
if (a) editor(a);
- else await refresh();
+ else await refresh(true);
});
});
open.textContent = row.title;
@@ -502,7 +616,6 @@ export function createArtifactsView(options: {
),
);
- if (options.onClose) setBack(button('back', options.onClose), 'conversation');
if (el.isConnected) heading.focus();
} catch (e) {
if (!disposed) error(e);
@@ -510,7 +623,12 @@ export function createArtifactsView(options: {
}
return {
el,
- refresh,
+ focus(): void {
+ (backButton ?? heading).focus();
+ },
+ /** A disabled or pending nested return consumes Escape; it never falls through to Chat. */
+ requestBack,
+ refresh: () => refresh(),
save(text: string, origin?: ArtifactProvenance): Promise {
if (disposed || busy) return Promise.resolve('cancelled');
return new Promise((resolve) => {
@@ -551,6 +669,7 @@ export function createArtifactsView(options: {
const matching = text.value === bound.text;
// Old offsets are never applied to a newer body. A changed selection
// request must select afresh or explicitly choose the entire artifact.
+ if (matching && bound.scope === 'selection') setEditorMode?.(true, false);
text.setSelectionRange(
matching && bound.scope === 'selection' ? bound.start : 0,
matching && bound.scope === 'selection' ? bound.end : 0,
@@ -592,10 +711,16 @@ export function createArtifactsView(options: {
},
setLabels(next: ArtifactsLabels): void {
labels = next;
+ el.setAttribute('aria-label', labels.title);
heading.textContent = labels.title;
activePresentation?.setLabels(labels);
+ body.querySelector('.agent-artifact-modes')?.setAttribute('aria-label', labels.preview);
const sourceSummary = body.querySelector('[data-artifact-source-summary]');
- if (sourceSummary) sourceSummary.textContent = labels.textField + ' · JSON';
+ if (sourceSummary) sourceSummary.textContent = label('sourceData') + ' · JSON';
+ const unsaved = body.querySelector('.agent-artifact-unsaved');
+ if (unsaved) unsaved.textContent = label('unsaved');
+ const question = body.querySelector('[data-artifact-delete-question]');
+ if (question) question.textContent = labels.confirmDelete;
for (const b of buttons) {
const key = b.dataset.action as keyof ArtifactsLabels;
if (key === 'back') {
@@ -603,7 +728,7 @@ export function createArtifactsView(options: {
b,
(backDestination === 'conversation' ? labels.backToConversation : labels.backToList) ?? labels.back,
);
- } else if (!b.dataset.artifact) b.textContent = labels[key] ?? '';
+ } else if (!b.dataset.artifact) b.textContent = label(key === 'confirmDelete' ? 'delete' : key);
}
for (const option of body.querySelectorAll('option')) option.textContent = `${labels.version} ${option.value}`;
const origin = body.querySelector('.agent-artifact-provenance');
@@ -627,6 +752,8 @@ export function createArtifactsView(options: {
for (const settle of pendingSaves) settle('cancelled');
++generation;
buttons.clear();
+ backButton = undefined;
+ options.onBackChange?.();
header.replaceChildren(heading);
el.remove();
},
diff --git a/lib/agent-plugin/ui/controller.ts b/lib/agent-plugin/ui/controller.ts
index 1ff332517..7cd46f4a3 100644
--- a/lib/agent-plugin/ui/controller.ts
+++ b/lib/agent-plugin/ui/controller.ts
@@ -43,6 +43,9 @@ export interface AgentChatControllerOptions {
maxIterations?: number;
/** Called with each streamed assistant text delta (live-render the bubble). */
onAgentDelta?: (delta: string) => void;
+ /** Ephemeral provider-returned public reasoning; never a chat turn or history. */
+ onReasoningDelta?: (delta: string) => void;
+ onReasoningEnd?: () => void;
/** Called when a streamed assistant turn completes (close the live bubble). */
onAgentStreamEnd?: () => void;
/** Persist/restore conversation history across reloads. */
@@ -229,7 +232,13 @@ export class AgentChatController {
signal: run.abort.signal,
onEvent: (event) => {
if (!live()) return;
- if (event.type === 'usage') {
+ if (event.type === 'reasoning_delta') {
+ assertCurrent?.();
+ if (live()) this.options.onReasoningDelta?.(event.text);
+ } else if (event.type === 'reasoning_end') {
+ assertCurrent?.();
+ if (live()) this.options.onReasoningEnd?.();
+ } else if (event.type === 'usage') {
run.timing?.usage(event.usage);
this.options.onUsage?.(event.usage);
} else if (event.type === 'assistant_delta') {
diff --git a/lib/agent-plugin/ui/panel-context.ts b/lib/agent-plugin/ui/panel-context.ts
index 9f56c4122..66578f47d 100644
--- a/lib/agent-plugin/ui/panel-context.ts
+++ b/lib/agent-plugin/ui/panel-context.ts
@@ -152,13 +152,12 @@ export function mountPanelContext(options: {
return { update, dispose };
}
-/** Selection chips and the empty-composer hint share one bounded SDK snapshot. */
+/** Selection chips and contextual tasks share one bounded SDK snapshot. */
export function syncPanelContext(options: {
quoteContext: HTMLElement;
quoteSummary: HTMLElement;
quoteTrigger?: HTMLElement;
quoteText: HTMLElement;
- welcomeHint: HTMLElement;
settings: HTMLElement;
ready: () => boolean;
onSnapshot?: (snapshot: ComposerContext) => void;
@@ -180,13 +179,4 @@ export function syncPanelContext(options: {
if (options.quoteText.textContent !== snapshot.text) options.quoteText.textContent = snapshot.text;
const release = options.settings.querySelector('.agent-release-memory');
if (release) release.hidden = !options.ready();
- const welcome =
- context?.kind === 'cell'
- ? 'agentWelcomeCell'
- : context?.kind === 'pdf'
- ? 'agentWelcomePdf'
- : context?.kind === 'slide'
- ? 'agentWelcomeSlide'
- : 'agentWelcomeWord';
- if (options.welcomeHint.textContent !== t(welcome)) options.welcomeHint.textContent = t(welcome);
}
diff --git a/lib/agent-plugin/ui/panel-resize.ts b/lib/agent-plugin/ui/panel-resize.ts
index 08419eb30..98302362f 100644
--- a/lib/agent-plugin/ui/panel-resize.ts
+++ b/lib/agent-plugin/ui/panel-resize.ts
@@ -1,27 +1,38 @@
import { t } from '@ranuts/shared/i18n';
-/** A captured pointer plus a temporary shield prevents the editor iframe stealing drags. */
-export function mountPanelResize(panel: HTMLElement): () => void {
+
+/** Own dock preference and temporary expansion without rebuilding any workspace surface. */
+export function mountPanelResize(panel: HTMLElement, options: { expandButton?: HTMLButtonElement } = {}): () => void {
const handle = document.createElement('div');
handle.className = 'agent-panel-resizer';
handle.tabIndex = 0;
handle.setAttribute('role', 'separator');
- const label = () => handle.setAttribute('aria-label', t('agentResizePanel'));
- label();
- window.addEventListener('languagechange', label);
handle.setAttribute('aria-orientation', 'vertical');
panel.prepend(handle);
- let preferred = 432;
+ let preferred = 480;
try {
- preferred = Number(localStorage.getItem('agent-panel-width')) || 432;
+ preferred = Number(localStorage.getItem('agent-panel-width')) || 480;
} catch {
/* Storage is optional. */
}
+ let expanded = panel.dataset.expanded === 'true';
let shield: HTMLElement | undefined;
let pointer: number | undefined;
const bounds = () => ({
min: Math.min(320, window.innerWidth),
max: window.innerWidth <= 480 ? window.innerWidth : Math.max(320, window.innerWidth - 360),
});
+ const syncLabels = () => {
+ handle.setAttribute('aria-label', t('agentResizePanel'));
+ panel.dataset.expanded = String(expanded);
+ const button = options.expandButton;
+ if (!button) return;
+ button.setAttribute('aria-expanded', String(expanded));
+ button.setAttribute('aria-label', t(expanded ? 'agentCollapse' : 'agentExpand'));
+ button.title = t(expanded ? 'agentCollapse' : 'agentExpand');
+ button.hidden =
+ window.innerWidth <= 480 ||
+ (!expanded && Math.min(1040, bounds().max) <= Number(handle.getAttribute('aria-valuenow')));
+ };
const apply = (value: number, persist = false) => {
const { min, max } = bounds();
const width = Math.round(Math.max(min, Math.min(max, value)));
@@ -37,6 +48,13 @@ export function mountPanelResize(panel: HTMLElement): () => void {
/* Storage is optional. */
}
}
+ syncLabels();
+ panel.dispatchEvent(new Event('agentlayoutchange'));
+ };
+ const resize = () => apply(expanded ? Math.min(1040, bounds().max) : preferred);
+ const toggle = () => {
+ expanded = !expanded;
+ resize();
};
const finish = () => {
if (pointer !== undefined && handle.hasPointerCapture?.(pointer)) handle.releasePointerCapture(pointer);
@@ -48,6 +66,8 @@ export function mountPanelResize(panel: HTMLElement): () => void {
handle.addEventListener('pointerdown', (event) => {
if (pointer !== undefined || event.button !== 0 || window.innerWidth <= 480) return;
event.preventDefault();
+ expanded = false;
+ syncLabels();
pointer = event.pointerId;
shield = document.createElement('div');
shield.className = 'agent-panel-resize-shield';
@@ -74,17 +94,20 @@ export function mountPanelResize(panel: HTMLElement): () => void {
const next = { ArrowLeft: current + 24, ArrowRight: current - 24, Home: min, End: max }[event.key];
if (next === undefined) return;
event.preventDefault();
+ expanded = false;
apply(next, true);
});
- const resize = () => apply(preferred);
+ options.expandButton?.addEventListener('click', toggle);
window.addEventListener('resize', resize);
window.addEventListener('blur', finish);
- apply(preferred);
+ window.addEventListener('languagechange', syncLabels);
+ resize();
return () => {
finish();
+ options.expandButton?.removeEventListener('click', toggle);
window.removeEventListener('resize', resize);
window.removeEventListener('blur', finish);
- window.removeEventListener('languagechange', label);
+ window.removeEventListener('languagechange', syncLabels);
handle.remove();
};
}
diff --git a/lib/agent-plugin/ui/panel-workspace.ts b/lib/agent-plugin/ui/panel-workspace.ts
index 5ffecc4fd..18b7f4efe 100644
--- a/lib/agent-plugin/ui/panel-workspace.ts
+++ b/lib/agent-plugin/ui/panel-workspace.ts
@@ -6,12 +6,14 @@ interface WorkspaceSurface {
el: HTMLElement;
trigger: HTMLElement;
focus: () => void;
+ returnToParent?: () => boolean;
}
/** Presentation navigation only. Request/session invalidation remains with the panel's lifecycle owners. */
export function createPanelWorkspace(options: {
panel: HTMLElement;
chat: Pick;
+ chatTrigger?: HTMLElement;
navigation: Pick, 'capture' | 'restore'>;
conversation: () => string;
isOpen: () => boolean;
@@ -19,35 +21,72 @@ export function createPanelWorkspace(options: {
views: Record, WorkspaceSurface>;
beforeChange: (view: PanelWorkspaceView) => void;
changed: (view: PanelWorkspaceView) => void;
+ layoutChanged?: () => void;
}) {
const { panel, chat, views, runtime, navigation } = options;
+ let initialized = false;
+ // A width change changes presentation, never the selected route or its lifecycle.
+ const refreshLayout = (routeChange = false): void => {
+ const split = panel.dataset.view === 'artifacts' && panel.getBoundingClientRect().width >= 840;
+ const hideChat = panel.dataset.view !== 'chat' && !split;
+ const wasHidden = chat.el.hidden;
+ const losingFocus = hideChat && !chat.el.hidden && chat.el.contains(document.activeElement);
+ if (!routeChange && hideChat && !wasHidden) navigation.capture(options.conversation());
+ panel.dataset.split = String(split);
+ chat.el.hidden = hideChat;
+ if (!routeChange && !hideChat && wasHidden) navigation.restore(options.conversation());
+ options.layoutChanged?.();
+ if (!routeChange && losingFocus && options.isOpen())
+ views[panel.dataset.view as Exclude].focus();
+ };
+ panel.addEventListener('agentlayoutchange', () => refreshLayout());
+ const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => refreshLayout());
+ observer?.observe(panel);
+ const reveal = (trigger?: HTMLElement): void => {
+ trigger?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
+ };
+ for (const trigger of [options.chatTrigger, ...Object.values(views).map((surface) => surface.trigger)])
+ trigger?.addEventListener('focus', () => reveal(trigger));
const show = (view: PanelWorkspaceView): void => {
+ if (initialized && panel.dataset.view === view) return;
options.beforeChange(view);
- if (panel.dataset.view === 'chat' && view !== 'chat') navigation.capture(options.conversation());
+ initialized = true;
+ if (!chat.el.hidden && panel.dataset.view) navigation.capture(options.conversation());
panel.dataset.view = view;
if (view === 'settings') runtime.modelRow.insertBefore(runtime.row, runtime.modelActions);
else panel.insertBefore(runtime.row, runtime.settings);
- chat.el.hidden = view !== 'chat';
+ refreshLayout(true);
+ if (options.chatTrigger) {
+ if (view === 'chat') options.chatTrigger.setAttribute('aria-current', 'page');
+ else options.chatTrigger.removeAttribute('aria-current');
+ }
for (const [name, surface] of Object.entries(views)) {
if (name === 'settings') surface.el.classList.toggle('agent-panel-settings-hidden', view !== name);
else surface.el.hidden = view !== name;
surface.trigger.setAttribute('aria-expanded', String(view === name));
+ if (view === name) surface.trigger.setAttribute('aria-current', 'page');
+ else surface.trigger.removeAttribute('aria-current');
}
+ reveal(view === 'chat' ? options.chatTrigger : views[view].trigger);
options.changed(view);
if (!options.isOpen()) return;
if (view !== 'chat') views[view].focus();
else if (!navigation.restore(options.conversation(), true)) chat.focus();
};
+ options.chatTrigger?.addEventListener('click', () => show('chat'));
for (const [name, surface] of Object.entries(views)) {
- surface.trigger.addEventListener('click', () =>
- show(panel.dataset.view === name ? 'chat' : (name as PanelWorkspaceView)),
- );
- surface.el.addEventListener('keydown', (event) => {
- if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented || event.keyCode === 229) return;
- event.stopPropagation();
- show('chat');
- surface.trigger.focus();
- });
+ surface.trigger.addEventListener('click', () => show(name as PanelWorkspaceView));
}
- return { show };
+ panel.addEventListener('keydown', (event) => {
+ if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented || event.keyCode === 229) return;
+ const view = panel.dataset.view as PanelWorkspaceView | undefined;
+ if (!view || view === 'chat') return;
+ const surface = views[view];
+ event.stopPropagation();
+ event.preventDefault();
+ if (surface.returnToParent?.()) return;
+ show('chat');
+ surface.trigger.focus();
+ });
+ return { show, refreshLayout };
}
diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts
index c4605b468..2caa43ecb 100644
--- a/lib/agent-plugin/ui/panel.ts
+++ b/lib/agent-plugin/ui/panel.ts
@@ -1,4 +1,4 @@
-import { createViewHeader, labelViewBack } from './view-header';
+import { createViewHeader } from './view-header';
import { createTaskReceipt } from './task-receipt';
import { createResultActions } from './result-actions';
import { createWorkspaceNavigation } from './workspace-navigation';
@@ -170,13 +170,16 @@ export function createAgentPanel(
.attr('role', 'complementary')
.attr('aria-label', t('agentTitle'))
.build();
- let disposeResize = mountPanelResize(panel);
+ const expandBtn = ButtonBuilder().class('agent-panel-expand').attr('type', 'button').build();
+ expandBtn.innerHTML =
+ '';
+ let disposeResize = mountPanelResize(panel, { expandButton: expandBtn });
let disposeViewport = mountPanelViewport(panel);
window.addEventListener('pagehide', () => disposeViewport());
window.addEventListener('pageshow', (event) => {
if (!event.persisted || !panel.isConnected) return;
disposeResize();
- disposeResize = mountPanelResize(panel);
+ disposeResize = mountPanelResize(panel, { expandButton: expandBtn });
disposeViewport();
disposeViewport = mountPanelViewport(panel);
sidebar.dispose();
@@ -315,8 +318,8 @@ export function createAgentPanel(
// ── Header ──────────────────────────────────────────────────────────────
const title = ButtonBuilder().class('agent-panel-title').attr('type', 'button').text(t('agentTitle')).build();
- // Gear toggles the settings block (provider / key / model), hidden by default
- // so the main panel stays a clean chat surface.
+ title.classList.add('agent-chat-toggle');
+ // Model configuration stays beside composition rather than competing with the conversation.
const settingsBtn = ButtonBuilder()
.class('agent-panel-settings-toggle')
.attr('type', 'button')
@@ -333,8 +336,12 @@ export function createAgentPanel(
else setOpen(false);
})
.build();
- settingsBtn.innerHTML =
- '';
+ settingsBtn.classList.add('agent-model-shortcut');
+ const modelShortcutLabel = Span().class('agent-model-shortcut-label').text(t('agentSettings')).build();
+ const modelShortcutIcon = Span().class('agent-model-shortcut-icon').attr('aria-hidden', 'true').build();
+ modelShortcutIcon.innerHTML =
+ '';
+ settingsBtn.append(modelShortcutLabel, modelShortcutIcon);
closeBtn.innerHTML =
'';
const loadingStatus = Span().class('agent-model-status').attr('role', 'status').build();
@@ -345,9 +352,9 @@ export function createAgentPanel(
.aria('label', t('agentPreparing'))
.build();
const runtimeRow = Div().class('agent-runtime-row').attr('hidden', '').children(loadingStatus).build();
- const header = Div().class('agent-panel-header').children(title, settingsBtn, closeBtn).build();
+ const header = Div().class('agent-panel-header').children(title, closeBtn).build();
- // ── Settings (collapsed by default; opened via the gear) ──────────────────
+ // ── Models workspace settings ────────────────────────────────────────────
const initialEndpointSettings = readEndpointSettings(localStorageGetItem(ENDPOINT_SETTINGS_KEY));
let selectedSource = configuredEndpoint(initialEndpointSettings) ? 'service' : 'local';
let cachedLocalModel = false;
@@ -443,12 +450,16 @@ export function createAgentPanel(
savedMessage: t('agentResultSaved'),
saveFailed: t('agentResultSaveFailed'),
conversation: t('agentTitle'),
+ composer: t('agentComposerLabel'),
+ actions: t('agentComposerActionsLabel'),
send: t('agentSend'),
queue: t('agentQueueAction'),
stop: t('agentStop'),
stopping: t('agentStopping'),
placeholder: t(proposalMode && writingSelect.value !== 'tools' ? 'agentWritingHint' : 'agentComposeHint'),
empty: t('agentWelcome'),
+ codeBlock: t('agentCodeBlock'),
+ table: t('agentMarkdownTable'),
copy: t('agentCopy'),
copied: t('agentCopied'),
copyFailed: t('agentCopyFailed'),
@@ -458,6 +469,9 @@ export function createAgentPanel(
loadEarlier: t('agentLoadEarlierMessages'),
expandMessage: t('agentShowFullMessage'),
waiting: t('agentWaiting'),
+ reasoning: t('agentReasoning'),
+ reasoningInterrupted: t('agentReasoningInterrupted'),
+ reasoningTruncated: t('agentReasoningTruncated'),
routing: t('agentRoutingRequest'),
elapsed: (seconds) => t('agentWaitElapsed', { seconds: new Intl.NumberFormat(getLanguage()).format(seconds) }),
reading: t('agentReadingContent'),
@@ -722,9 +736,6 @@ export function createAgentPanel(
syncRuntimeStatus();
});
speech = mountSpeechInput(chat, () => enabled);
- const welcomeHint = document.createElement('p');
- welcomeHint.className = 'agent-welcome-hint';
- welcomeHint.textContent = t('agentWelcomeHint');
const taskEntry = createTaskEntry({
getInput: () => chat.getInput(),
setInput: (text) => chat.setInput(text),
@@ -764,7 +775,7 @@ export function createAgentPanel(
window.addEventListener('pagehide', (event) => {
if (!event.persisted) resultActions.dispose();
});
- chat.emptyActionsEl.prepend(welcomeHint, taskEntry.el);
+ chat.emptyActionsEl.prepend(taskEntry.el);
chat.el.querySelector('textarea')!.addEventListener('input', (event) => {
if (event.isTrusted || chat.getInput()) taskEntry.inputChanged(chat.getInput());
});
@@ -930,7 +941,6 @@ export function createAgentPanel(
quoteSummary,
quoteTrigger: selectionContext.trigger,
quoteText,
- welcomeHint,
settings,
ready: () => !!webllmProvider?.isReady(),
onSnapshot: (snapshot) => taskEntry.update(snapshot),
@@ -979,6 +989,8 @@ export function createAgentPanel(
},
tools: agentTools,
onAgentDelta: (delta: string): void => chat.appendDelta(delta, { metadata: resultMetadata }),
+ onReasoningDelta: (delta: string): void => chat.appendReasoningDelta(delta),
+ onReasoningEnd: (): void => chat.endReasoning(),
onAgentStreamEnd: (): void => chat.endStream(),
storage: historyStorage,
};
@@ -1209,6 +1221,14 @@ export function createAgentPanel(
readiness.hidden = !reason || modelLoading;
const waiting = modelLoading || !!endpointConnection.pending || !!runtimeCleanup || cacheBusy;
const endpoint = configuredEndpoint(configuration.endpointSettings);
+ modelShortcutLabel.textContent =
+ selectedSource === 'service'
+ ? endpoint?.model || t('agentSettings')
+ : currentProvider() === 'webllm'
+ ? WEBLLM_MODELS.find((model) => model.id === modelSelect.value)?.label || t('agentSettings')
+ : t('agentProviderWllama');
+ settingsBtn.title = `${t('agentSettings')} · ${modelShortcutLabel.textContent}`;
+ settingsBtn.setAttribute('aria-label', settingsBtn.title);
const canReconnect = !!(
endpoint &&
(!offline || endpoint.kind === 'loopback') &&
@@ -1236,6 +1256,8 @@ export function createAgentPanel(
waiting ||
runtimeCleanupFailed ||
(!!chat.getInput().trim() && !writingBlocked);
+ // The model entry already opens settings; reserve a second action for actual preparation/reconnection.
+ if (prepareAction.textContent === t('agentSettings')) prepareAction.hidden = true;
chat.refreshSendAvailability();
loadingStatus.textContent = modelLoading
? [
@@ -1249,7 +1271,9 @@ export function createAgentPanel(
? t('agentUseDevice')
: '';
syncWriteDestination();
- runtimeRow.hidden = !loadingStatus.textContent || (panel.dataset.view === 'settings' && !modelLoading);
+ runtimeRow.hidden =
+ !loadingStatus.textContent ||
+ (panel.dataset.view !== 'chat' && (panel.dataset.view !== 'settings' || !modelLoading));
note.hidden =
selectedSource !== 'local' ||
(modelLoading && panel.dataset.view === 'settings') ||
@@ -2451,6 +2475,19 @@ export function createAgentPanel(
display.push(delta);
},
sourceReference,
+ {
+ onReasoningDelta: (delta) => chat.appendReasoningDelta(delta),
+ onReasoningEnd: () => chat.endReasoning(),
+ assertCurrent: () => {
+ abort.signal.throwIfAborted();
+ if (
+ generation !== controllerGeneration ||
+ conversation !== conversationRevision ||
+ !target.isCurrent(false)
+ )
+ throw new Error(t('agentPlanExpired'));
+ },
+ },
);
abort.signal.throwIfAborted();
if (
@@ -2717,6 +2754,11 @@ export function createAgentPanel(
// Dictation must settle into its original composer before navigation restores another draft.
speech?.close();
rememberDraft();
+ // A secondary view returns to its owning conversation, including an unsent draft.
+ if (panel.dataset.view !== 'chat') {
+ showView('chat');
+ return;
+ }
const sessions = conversations.searchSnapshot();
const current = sessions.find((item) => item.id === displayedConversationId);
// An accepted user turn can be visible before its history is persisted.
@@ -2806,10 +2848,7 @@ export function createAgentPanel(
syncSidebar();
title.textContent = t('agentTitle');
panel.setAttribute('aria-label', t('agentTitle'));
- welcomeHint.textContent = t('agentWelcomeHint');
syncRuntimeStatus();
- settingsBtn.title = t('agentSettings');
- settingsBtn.setAttribute('aria-label', t('agentSettings'));
configuration.syncLabels();
closeBtn.title = t('agentClose');
closeBtn.setAttribute('aria-label', t('agentClose'));
@@ -2839,6 +2878,8 @@ export function createAgentPanel(
const artifactsBtn = View('button').class('agent-workspace-toggle').attr('type', 'button').build();
const artifactStore = createArtifactStore();
+ let artifactBack: HTMLButtonElement | undefined;
+ const artifactReturnSlot = Div().class('agent-artifact-return-slot').attr('hidden', '').build();
const artifactsView = createArtifactsView({
store: artifactStore,
refinement: {
@@ -2849,6 +2890,11 @@ export function createAgentPanel(
labels: artifactLabels(),
conversation: () => conversations.activeId,
onClose: () => showView('chat'),
+ onBackChange: (back) => {
+ artifactBack?.remove();
+ artifactBack = back;
+ syncHeaderIdentity();
+ },
onApply: async (text) => {
showView('chat');
try {
@@ -2906,8 +2952,13 @@ export function createAgentPanel(
if (!event.persisted) artifactsView.dispose();
});
referenceSources.select(conversations.activeId);
- const sourcesBtn = View('button').class('agent-sources-toggle').attr('type', 'button').build();
- const sourceSummary = View('p').class('agent-sources-summary agent-entry-note').build();
+ const sourcesBtn = View('button')
+ .class('agent-sources-toggle')
+ .attr('type', 'button')
+ .attr('aria-controls', 'agent-sources')
+ .attr('aria-expanded', 'false')
+ .build();
+ const sourceSummary = Div().class('agent-sources-summary').build();
let sourceState = referenceSources.capture();
const sourceView = createSourcesView({
sources: referenceSources,
@@ -2918,12 +2969,32 @@ export function createAgentPanel(
if (!sourceState.isCurrent()) invalidateRequestBindings();
sourceState = referenceSources.capture();
sourceSummary.hidden = referenceSources.list().length === 0;
- sourceSummary.textContent = referenceSources
- .list()
- .map((source) => source.title)
- .join(' · ');
+ sourceSummary.replaceChildren(
+ ...referenceSources.list().map((source) => {
+ const chip = View('button')
+ .class('agent-source-chip')
+ .attr('type', 'button')
+ .text(source.title)
+ .build();
+ chip.title = `${t('agentSourcesOpen')}: ${source.title}`;
+ chip.setAttribute('aria-label', chip.title);
+ chip.addEventListener('click', () => {
+ showView('sources');
+ sourceView.openSource(source.id);
+ });
+ return chip;
+ }),
+ );
sourceSummary.title = t('agentSourcesExcerpt');
- sourcesBtn.textContent = `${t('agentSourcesSources')}${referenceSources.list().length ? ` · ${referenceSources.list().length}` : ''}`;
+ const sourceLabel = `${t('agentSourcesSources')}${referenceSources.list().length ? ` · ${referenceSources.list().length}` : ''}`;
+ const label = Span().class('agent-sources-label').text(sourceLabel).build();
+ sourcesBtn.replaceChildren(label);
+ sourcesBtn.setAttribute('aria-label', sourceLabel);
+ sourcesBtn.title = sourceLabel;
+ const icon = Span().attr('aria-hidden', 'true').build();
+ icon.innerHTML =
+ '';
+ sourcesBtn.prepend(icon);
},
});
chat.actionsEl.prepend(sourcesBtn);
@@ -2960,10 +3031,7 @@ export function createAgentPanel(
});
const { settingsHeading, modelActions, localCard, serviceCard, advanced, settingsHero, sourcePicker, offlineHint } =
settingsView;
- const settingsBack = document.createElement('button');
- settingsBack.type = 'button';
- settingsBack.addEventListener('click', () => showView('chat'));
- settingsHero.replaceChildren(createViewHeader(settingsHeading, settingsBack));
+ settingsHero.replaceChildren(createViewHeader(settingsHeading));
syncOfflineSettings = () => {
settingsView.setOnline(navigator.onLine !== false);
ggufUrl.hidden = navigator.onLine === false;
@@ -3042,19 +3110,34 @@ export function createAgentPanel(
},
onDelete: (id, trigger) => historyControls?.requestDelete(id, trigger),
});
- const historyBack = document.createElement('button');
- historyBack.type = 'button';
- historyBack.addEventListener('click', () => showView('chat'));
const historyHeading = conversationList.el.querySelector('h2')!;
- historyView.append(createViewHeader(historyHeading, historyBack), conversationList.el, historyControls.el);
- header.insertBefore(artifactsBtn, settingsBtn);
- header.insertBefore(historyBtn, settingsBtn);
- header.insertBefore(clearBtn, settingsBtn);
+ historyView.append(createViewHeader(historyHeading), conversationList.el, historyControls.el);
+ header.insertBefore(clearBtn, closeBtn);
+ const chatBtn = title;
+ const sourcesNav = sourcesBtn;
+ sourcesNav.classList.add('agent-sources-nav');
+ const workspaceNav = View('nav').class('agent-workspace-nav').children(chatBtn, artifactsBtn, historyBtn).build();
+ header.prepend(workspaceNav);
+ for (const [trigger, id] of [
+ [chatBtn, 'agent-chat'],
+ [artifactsBtn, 'agent-artifacts'],
+ [historyBtn, 'agent-history'],
+ [sourcesNav, 'agent-sources'],
+ ] as const)
+ trigger.setAttribute('aria-controls', id);
+ chat.el.id = 'agent-chat';
+ artifactsView.el.id = 'agent-artifacts';
+ historyView.id = 'agent-history';
+ sourceView.el.id = 'agent-sources';
- chat.el.append(writeDestination);
+ const composerMeta = Div().class('agent-composer-meta').children(settingsBtn).build();
+ chat.el.querySelector('.cui-footer')!.append(composerMeta, readiness, writeDestination);
+ artifactsView.el.prepend(artifactReturnSlot);
+ header.insertBefore(expandBtn, closeBtn);
const workspace = createPanelWorkspace({
panel,
chat,
+ chatTrigger: chatBtn,
navigation,
conversation: () => displayedConversationId,
isOpen: () => open,
@@ -3062,8 +3145,13 @@ export function createAgentPanel(
views: {
settings: { el: settings, trigger: settingsBtn, focus: () => settingsHeading.focus() },
history: { el: historyView, trigger: historyBtn, focus: () => conversationList?.focus() },
- sources: { el: sourceView.el, trigger: sourcesBtn, focus: () => sourceView.focus() },
- artifacts: { el: artifactsView.el, trigger: artifactsBtn, focus: () => artifactsBtn.focus() },
+ sources: { el: sourceView.el, trigger: sourcesNav, focus: () => sourceView.focus() },
+ artifacts: {
+ el: artifactsView.el,
+ trigger: artifactsBtn,
+ focus: () => artifactsView.focus(),
+ returnToParent: () => artifactsView.requestBack(),
+ },
},
beforeChange: (view) => {
speech?.close();
@@ -3073,33 +3161,73 @@ export function createAgentPanel(
viewRevision++;
},
changed: (view) => {
+ syncHeaderIdentity();
+ sourcesBtn.setAttribute('aria-expanded', String(view === 'sources'));
if (view === 'artifacts') void artifactsView.refresh();
syncHistoryActivity();
syncRuntimeStatus();
},
+ layoutChanged: () => syncHeaderIdentity(),
});
function showView(view: PanelWorkspaceView): void {
workspace.show(view);
}
+ const headerIcons = {
+ artifacts:
+ '',
+ history:
+ '',
+ };
+ function syncHeaderIdentity(): void {
+ const split = panel.dataset.split === 'true';
+ const inChat = panel.dataset.view === 'chat' || split;
+ const nested = panel.dataset.view === 'artifacts' && !!artifactBack;
+ title.hidden = nested && !split;
+ artifactReturnSlot.hidden = !nested || !split;
+ if (artifactBack) {
+ artifactBack.classList.add('agent-panel-return');
+ // The chevron already expresses Back; keep its parent destination readable.
+ const caption = artifactBack.querySelector('span');
+ if (caption) caption.textContent = t('agentArtifactTitle');
+ artifactBack.hidden = !nested;
+ const destination = split ? artifactReturnSlot : workspaceNav;
+ if (artifactBack.parentElement !== destination || (!split && destination.firstElementChild !== artifactBack)) {
+ const focused = document.activeElement === artifactBack;
+ if (split) destination.append(artifactBack);
+ else destination.prepend(artifactBack);
+ if (focused) artifactBack.focus({ preventScroll: true });
+ }
+ }
+ title.textContent = inChat ? t('agentTitle') : t('agentWorkspaceChat');
+ if (!inChat) {
+ const backIcon = Span().class('agent-header-home-icon').attr('aria-hidden', 'true').build();
+ backIcon.innerHTML =
+ '';
+ title.prepend(backIcon);
+ }
+ title.title = inChat ? t('agentWorkspaceChat') : t('agentBackToChat');
+ title.setAttribute('aria-label', title.title);
+ }
+ const labelDestination = (button: HTMLButtonElement, label: string): void => {
+ button.title = label;
+ button.setAttribute('aria-label', label);
+ button.innerHTML = headerIcons[button === artifactsBtn ? 'artifacts' : 'history'];
+ };
const syncProductLabels = () => {
settingsView.syncLabels();
- labelViewBack(settingsBack, t('agentBackToChat'));
- labelViewBack(historyBack, t('agentBackToChat'));
sourceView.setLabels(sourcesLabels());
artifactsView.setLabels(artifactLabels());
artifactsView.setRefinementLabels(artifactWorkflowLabels());
resultActions.refresh();
- artifactsBtn.title = t('agentArtifactTitle');
- artifactsBtn.setAttribute('aria-label', t('agentArtifactTitle'));
- artifactsBtn.innerHTML =
- '';
+ syncHeaderIdentity();
+ labelDestination(artifactsBtn, t('agentArtifactTitle'));
+ labelDestination(historyBtn, t('agentConversations'));
+ workspaceNav.setAttribute('aria-label', t('agentTitle'));
refinementLabel.textContent = t('agentRefiningProposal');
refinementCancel.textContent = t('agentPlanCancel');
disableLabel.textContent = t('agentEnable');
disableHint.textContent = t('agentOffNote');
disable.setAttribute('aria-label', t('agentEnable'));
- historyBtn.title = t('agentConversations');
- historyBtn.setAttribute('aria-label', t('agentConversations'));
};
syncProductLabels();
window.addEventListener('languagechange', syncProductLabels);
@@ -3122,9 +3250,9 @@ export function createAgentPanel(
restoringInitialHistory = false;
});
showView('chat');
- // Cache discovery may choose a default view only until chat interaction starts.
- // Capture clicks because speech controls stop propagation before requesting permission.
+ // Capture interaction revisions for asynchronous focus and navigation guards.
const keepChatVisible = () => viewRevision++;
+ header.addEventListener('click', keepChatVisible, { capture: true });
chat.el.addEventListener('click', keepChatVisible, { capture: true });
chat.el.addEventListener('input', keepChatVisible);
// Keep the empty-composer setup link and contextual main action from duplicating.
@@ -3133,11 +3261,8 @@ export function createAgentPanel(
});
// Older default-model loads may have cached files without a saved preset.
- // Check those without warming the engine, and never replace a view the user
- // has deliberately opened while the storage check was pending.
- if (options.externalEntry && !hasConfiguration && selectedSource === 'service') showView('settings');
+ // Check those without warming the engine or replacing the current view.
if (options.externalEntry && !hasConfiguration && selectedSource === 'local') {
- const revision = viewRevision;
const attempt = loadAttempt;
const route = routeKey();
const configured =
@@ -3149,7 +3274,6 @@ export function createAgentPanel(
.then((cached) => {
if (hasConfiguration || !panel.isConnected || attempt !== loadAttempt || route !== routeKey()) return;
hasConfiguration = cached;
- if (!cached && viewRevision === revision) showView('settings');
});
}
diff --git a/lib/agent-plugin/ui/presentation.ts b/lib/agent-plugin/ui/presentation.ts
index f0181c921..95aa87242 100644
--- a/lib/agent-plugin/ui/presentation.ts
+++ b/lib/agent-plugin/ui/presentation.ts
@@ -235,6 +235,8 @@ export function displayError(error: unknown): string {
'agentWritingCurrencyChanged',
'agentToolNotChosen',
'agentWordOnly',
+ 'agentWordFormatUnsupported',
+ 'agentWordFormattingReviewUnsupported',
'agentContextTooLong',
'agentSlideNoRoom',
'agentDocumentActionTimeout',
diff --git a/lib/agent-plugin/ui/sources-view.ts b/lib/agent-plugin/ui/sources-view.ts
index a9631c70f..3edc843e2 100644
--- a/lib/agent-plugin/ui/sources-view.ts
+++ b/lib/agent-plugin/ui/sources-view.ts
@@ -1,4 +1,4 @@
-import { createViewHeader, labelViewBack } from './view-header';
+import { createViewHeader } from './view-header';
import { sourcePreview } from './source-preview';
import { sourceMatches } from '../references/reference-search';
import { Div, View } from 'ranui/builder';
@@ -67,7 +67,6 @@ export function createSourcesView(options: {
b.addEventListener('click', action);
return b;
};
- const back = button(options.close);
const label = (control: HTMLElement, key: string) => {
const wrapper = View('label').build();
const text = View('span').text(key).build();
@@ -79,7 +78,7 @@ export function createSourcesView(options: {
const localField = label(local, '');
localField.wrapper.hidden = true;
el.append(
- createViewHeader(heading, back),
+ createViewHeader(heading),
excerpt,
offlineHint,
status,
@@ -134,11 +133,15 @@ export function createSourcesView(options: {
attached.replaceChildren();
for (const source of options.sources.list()) {
const row = Div().class('agent-source-row').build();
+ row.dataset.sourceId = source.id;
if (!sourceMatches(source, search.value)) continue;
const text = sourcePreview(source, labels.openSource ?? labels.excerpt, search.value, labels.previewTruncated);
const remove = button(() => {
+ const index = [...attached.querySelectorAll('.agent-source-remove')].indexOf(remove);
options.sources.remove(source.id);
update();
+ const remaining = attached.querySelectorAll('.agent-source-remove');
+ (remaining[Math.min(index, remaining.length - 1)] ?? attachFile).focus();
});
remove.className = 'agent-source-remove';
remove.textContent = labels.remove;
@@ -156,7 +159,6 @@ export function createSourcesView(options: {
offlineHint.textContent = labels.offline;
syncConnectivity();
excerpt.textContent = labels.excerpt;
- labelViewBack(back, labels.close);
localHeading.textContent = labels.local;
attachFile.textContent = labels.attach;
localField.text.textContent = labels.attach;
@@ -182,6 +184,21 @@ export function createSourcesView(options: {
el,
cancelPending,
focus: () => heading.focus(),
+ openSource: (id: string): boolean => {
+ if (disposed || !options.sources.list().some((source) => source.id === id)) return false;
+ search.value = '';
+ update();
+ const row = [...attached.querySelectorAll('.agent-source-row')].find(
+ (candidate) => candidate.dataset.sourceId === id,
+ );
+ const preview = row?.querySelector('details');
+ if (!preview) return false;
+ preview.open = true;
+ preview.dispatchEvent(new Event('toggle'));
+ preview.querySelector('summary')?.focus();
+ row?.scrollIntoView?.({ block: 'nearest' });
+ return true;
+ },
setLabels,
refresh: update,
dispose: () => {
diff --git a/lib/agent-plugin/ui/speech-input.ts b/lib/agent-plugin/ui/speech-input.ts
index eab5ad6cc..ca9581af4 100644
--- a/lib/agent-plugin/ui/speech-input.ts
+++ b/lib/agent-plugin/ui/speech-input.ts
@@ -1,4 +1,5 @@
import 'ranui/popover';
+import 'ranui/select';
import { getLanguage, t } from '@ranuts/shared/i18n';
import type { ChatView } from '@ranuts/chat-ui';
interface Recognition {
@@ -73,14 +74,20 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
privacy.tabIndex = -1;
const privacyToggle = document.createElement('button');
privacyToggle.type = 'button';
+ privacyToggle.setAttribute('aria-haspopup', 'dialog');
const privacyContent = document.createElement('r-content');
- privacyContent.append(hint);
+ const options = document.createElement('div');
+ options.className = 'agent-speech-options';
+ const noticeEl = document.createElement('p');
+ noticeEl.className = 'agent-speech-notice';
+ options.append(language, noticeEl, hint);
+ privacyContent.append(options);
privacyToggle.innerHTML =
- '';
+ '';
privacy.append(privacyToggle, privacyContent);
feedback.append(status);
// Every state shares one toolbar; disclosures must not resize the draft.
- root.append(mic, language, feedback, cancel, privacy);
+ root.append(mic, privacy, feedback, cancel);
chat.actionsEl.append(root);
let disclosed = false;
let notice = '';
@@ -93,15 +100,39 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
const pauseDisclosure = () => clearTimeout(disclosureTimer);
const scheduleDisclosure = () => {
pauseDisclosure();
- if (!privacy.hasAttribute('open') || triggerHovered || popupHovered || isDisclosureFocused()) return;
+ if (
+ !privacy.hasAttribute('open') ||
+ language.hasAttribute('open') ||
+ triggerHovered ||
+ popupHovered ||
+ isDisclosureFocused()
+ )
+ return;
disclosureTimer = setTimeout(() => privacy.removeAttribute('open'), 3000);
};
+ // The installed ranui floating owner recognizes nested portaled select panels.
+ // Escape unwinds one disclosure at a time through public `open` attributes.
+ const dismissDisclosure = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || event.isComposing || !privacy.hasAttribute('open')) return;
+ event.preventDefault();
+ event.stopPropagation();
+ pauseDisclosure();
+ if (language.hasAttribute('open')) {
+ language.removeAttribute('open');
+ language.focus({ preventScroll: true });
+ } else {
+ privacy.removeAttribute('open');
+ privacyToggle.focus({ preventScroll: true });
+ }
+ };
const bindPopup = () => {
const popup = privacy.popoverContent;
if (!popup) return;
if (popup === boundPopup) return;
boundPopup = popup;
popup.classList.add('agent-speech-popover');
+ popup.setAttribute('role', 'dialog');
+ popup.setAttribute('aria-label', t('agentVoiceOptions'));
popup.addEventListener('pointerenter', () => {
popupHovered = true;
pauseDisclosure();
@@ -112,13 +143,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
});
popup.addEventListener('focusin', pauseDisclosure);
popup.addEventListener('focusout', () => queueMicrotask(scheduleDisclosure));
- popup.addEventListener('keydown', (event) => {
- if (event.key !== 'Escape') return;
- event.preventDefault();
- event.stopPropagation();
- privacy.removeAttribute('open');
- privacyToggle.focus({ preventScroll: true });
- });
+ popup.addEventListener('keydown', dismissDisclosure, { capture: true });
};
const showDisclosure = () => {
privacy.setAttribute('open', '');
@@ -126,13 +151,14 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
scheduleDisclosure();
};
privacy.addEventListener('show', () => {
- language.removeAttribute('open');
privacyToggle.setAttribute('aria-expanded', 'true');
bindPopup();
scheduleDisclosure();
});
- language.addEventListener('show', () => privacy.removeAttribute('open'));
+ language.addEventListener('show', pauseDisclosure);
+ language.addEventListener('hide', scheduleDisclosure);
privacy.addEventListener('hide', () => {
+ language.removeAttribute('open');
privacyToggle.setAttribute('aria-expanded', 'false');
pauseDisclosure();
popupHovered = false;
@@ -148,6 +174,13 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
privacy.addEventListener('focusin', pauseDisclosure);
privacy.addEventListener('focusout', () => queueMicrotask(scheduleDisclosure));
privacyToggle.addEventListener('keydown', (event) => {
+ if (event.key === 'Tab' && !event.shiftKey && privacy.hasAttribute('open') && !language.disabled) {
+ // The dialog is portaled after the composer, outside its native tab order.
+ event.preventDefault();
+ event.stopPropagation();
+ language.focus({ preventScroll: true });
+ return;
+ }
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
@@ -183,10 +216,17 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
feedback.hidden = !busy && !status.textContent;
cancel.tabIndex = busy ? 0 : -1;
mic.disabled = stopping;
+ language.setAttribute('label', t('agentSpeechLanguage'));
language.setAttribute('aria-label', t('agentSpeechLanguage'));
language.disabled = busy;
- hint.textContent = notice || t('agentSpeechPrivacy');
- privacyToggle.setAttribute('aria-label', hint.textContent);
+ hint.textContent = t('agentSpeechPrivacy');
+ noticeEl.textContent = notice;
+ noticeEl.hidden = !notice;
+ privacyToggle.setAttribute('aria-label', t('agentVoiceOptions'));
+ privacyToggle.title = t('agentVoiceOptions');
+ if (notice) privacyToggle.setAttribute('aria-describedby', feedback.id);
+ else privacyToggle.removeAttribute('aria-describedby');
+ boundPopup?.setAttribute('aria-label', t('agentVoiceOptions'));
privacyToggle.setAttribute('aria-expanded', String(privacy.hasAttribute('open')));
cancel.setAttribute('aria-label', t('agentSpeechCancel'));
cancel.title = t('agentSpeechCancel');
@@ -205,6 +245,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
stopping = false;
listening = false;
pauseDisclosure();
+ language.removeAttribute('open');
privacy.removeAttribute('open');
notice = '';
status.textContent = '';
@@ -356,19 +397,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
mic.focus();
}
});
- privacy.addEventListener(
- 'keydown',
- (event) => {
- if (event.key === 'Escape' && privacy.hasAttribute('open')) {
- event.preventDefault();
- event.stopPropagation();
- pauseDisclosure();
- privacy.removeAttribute('open');
- privacyToggle.focus({ preventScroll: true });
- }
- },
- { capture: true },
- );
+ privacy.addEventListener('keydown', dismissDisclosure, { capture: true });
feedback.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !event.defaultPrevented && !event.isComposing && busy) {
close(true);
@@ -380,6 +409,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
if (busy) close(false, true);
else if (status.textContent) {
status.textContent = '';
+ notice = '';
render();
}
});
diff --git a/lib/agent-plugin/ui/task-entry.ts b/lib/agent-plugin/ui/task-entry.ts
index 13bc9ce61..e80908759 100644
--- a/lib/agent-plugin/ui/task-entry.ts
+++ b/lib/agent-plugin/ui/task-entry.ts
@@ -42,6 +42,13 @@ interface Task {
prompt: Parameters[0];
task: RequestIntent['task'];
}
+const taskIcons: Record = {
+ compose: '',
+ read: '',
+ explain: '',
+ summarize: '',
+ polish: '',
+};
const polish: Task = { id: 'polish', label: 'agentEntryPolish', prompt: 'agentEntryPolishPrompt', task: 'rewrite' };
const summarize: Task = {
id: 'summarize',
@@ -160,6 +167,9 @@ export function createTaskEntry(options: {
.class('agent-task-action')
.text(t(item.label))
.build();
+ const icon = Span().class('agent-task-icon').attr('aria-hidden', 'true').build();
+ icon.innerHTML = ``;
+ button.prepend(icon);
button.addEventListener('click', () => {
if (options.getInput().trim()) {
help.textContent = t('agentEntryDraftPresent');
@@ -227,7 +237,9 @@ export function createTaskEntry(options: {
? t('agentEntrySmaller')
: readonly
? t('agentEntryReadOnly')
- : t('agentEntryStaging');
+ : staged
+ ? t('agentEntryStaging')
+ : '';
};
return { el, update, inputChanged, check, consume };
}
diff --git a/lib/agent-plugin/ui/view-header.ts b/lib/agent-plugin/ui/view-header.ts
index 07c0a38f2..5e5bee39b 100644
--- a/lib/agent-plugin/ui/view-header.ts
+++ b/lib/agent-plugin/ui/view-header.ts
@@ -13,4 +13,7 @@ export function labelViewBack(back: HTMLButtonElement, destination: string): voi
back.title = destination;
back.innerHTML =
'';
+ const label = document.createElement('span');
+ label.textContent = destination;
+ back.append(label);
}
diff --git a/lib/agent-plugin/ui/workspace-labels.ts b/lib/agent-plugin/ui/workspace-labels.ts
index 5941a2346..58529d264 100644
--- a/lib/agent-plugin/ui/workspace-labels.ts
+++ b/lib/agent-plugin/ui/workspace-labels.ts
@@ -51,6 +51,15 @@ export function artifactLabels(): ArtifactsLabels {
import: t('agentArtifactImport'),
importHint: t('agentArtifactImportHint'),
preview: t('agentArtifactPreview'),
+ codeBlock: t('agentCodeBlock'),
+ table: t('agentMarkdownTable'),
+ copy: t('agentCopy'),
+ copied: t('agentCopied'),
+ copyFailed: t('agentCopyFailed'),
+ edit: t('agentArtifactEdit'),
+ cancelEditing: t('agentArtifactCancelEditing'),
+ sourceData: t('agentArtifactSourceData'),
+ unsaved: t('agentArtifactUnsaved'),
next: t('agentArtifactNext'),
previous: t('agentArtifactPrevious'),
structuredHint: t('agentArtifactStructuredHint'),
@@ -80,6 +89,8 @@ export function resultActionLabels(): ResultActionLabels {
import type { ArtifactWorkflowLabels } from './artifact-workflow';
export function artifactWorkflowLabels(): ArtifactWorkflowLabels {
return {
+ before: t('agentPlanBefore'),
+ after: t('agentPlanAfter'),
entire: t('agentArtifactRevisionEntire'),
selected: t('agentSelectionContext'),
recovered: t('agentArtifactRevisionRecovered'),
diff --git a/packages/agent-core/src/llm/anthropic.ts b/packages/agent-core/src/llm/anthropic.ts
index c76c088a2..573fe03fd 100644
--- a/packages/agent-core/src/llm/anthropic.ts
+++ b/packages/agent-core/src/llm/anthropic.ts
@@ -27,7 +27,12 @@ interface AnthropicToolUseBlock {
name: string;
input: Record;
}
-type AnthropicBlock = AnthropicTextBlock | AnthropicToolUseBlock | { type: string };
+type AnthropicBlock =
+ | AnthropicTextBlock
+ | AnthropicToolUseBlock
+ | Anthropic.ThinkingBlock
+ | Anthropic.RedactedThinkingBlock
+ | { type: string };
interface AnthropicResponse {
content: AnthropicBlock[];
@@ -36,11 +41,11 @@ interface AnthropicResponse {
/** A streaming handle from `messages.stream()` (the bits this provider uses). */
export interface AnthropicStream {
- on(event: 'text', listener: (textDelta: string) => void): unknown;
+ on(event: 'text' | 'thinking', listener: (textDelta: string) => void): unknown;
finalMessage(): Promise;
/** Optional SDK lifecycle methods; minimal injected clients need not implement them. */
abort?(): void;
- off?(event: 'text', listener: (textDelta: string) => void): unknown;
+ off?(event: 'text' | 'thinking', listener: (textDelta: string) => void): unknown;
}
/** The slice of the Anthropic client this provider uses (eases test mocking). */
@@ -161,6 +166,7 @@ export class AnthropicProvider implements LLMProvider {
tools: LLMToolDef[],
owner?: AbortSignal,
onDelta?: (textDelta: string) => void,
+ onReasoningDelta?: (text: string) => void,
): Promise {
owner?.throwIfAborted();
if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647)
@@ -191,6 +197,16 @@ export class AnthropicProvider implements LLMProvider {
() => controller.abort(new DOMException('AI request timed out', 'TimeoutError')),
this.timeoutMs,
);
+ let reasoningObserved = false;
+ const reasoningListener = (delta: string) => {
+ if (!active || controller.signal.aborted || !delta) return;
+ try {
+ reasoningObserved = true;
+ onReasoningDelta?.(delta);
+ } catch (error) {
+ controller.abort(error);
+ }
+ };
const listener = (delta: string) => {
if (!active || controller.signal.aborted) return;
try {
@@ -211,11 +227,20 @@ export class AnthropicProvider implements LLMProvider {
// Observe rejection before attaching callbacks: minimal host clients may
// emit synchronously, and callbacks can stop or throw reentrantly.
const final = stream.finalMessage();
+ if (onReasoningDelta) stream.on('thinking', reasoningListener);
stream.on('text', listener);
return final;
})();
const response = await Promise.race([pending, aborted]);
controller.signal.throwIfAborted();
+ if (!reasoningObserved && onReasoningDelta) {
+ for (const block of response.content ?? []) {
+ if (block.type !== 'thinking') continue;
+ const thinking = (block as { type: string; thinking?: unknown }).thinking;
+ if (typeof thinking === 'string' && thinking) reasoningListener(thinking);
+ }
+ }
+ controller.signal.throwIfAborted();
return parseAnthropicResponse(response);
} catch (error) {
if (!controller.signal.aborted) controller.abort(error);
@@ -227,6 +252,7 @@ export class AnthropicProvider implements LLMProvider {
controller.signal.removeEventListener('abort', onAbort);
try {
stream?.off?.('text', listener);
+ if (onReasoningDelta) stream?.off?.('thinking', reasoningListener);
} catch {}
}
}
@@ -240,7 +266,8 @@ export class AnthropicProvider implements LLMProvider {
tools: LLMToolDef[],
onDelta: (textDelta: string) => void,
signal?: AbortSignal,
+ onReasoningDelta?: (text: string) => void,
): Promise {
- return this.request(messages, tools, signal, onDelta);
+ return this.request(messages, tools, signal, onDelta, onReasoningDelta);
}
}
diff --git a/packages/agent-core/src/llm/gemini.ts b/packages/agent-core/src/llm/gemini.ts
index 0391c6de2..3872eff0e 100644
--- a/packages/agent-core/src/llm/gemini.ts
+++ b/packages/agent-core/src/llm/gemini.ts
@@ -197,8 +197,9 @@ export class GeminiProvider implements LLMProvider {
tools: LLMToolDef[],
onDelta: (text: string) => void,
signal?: AbortSignal,
+ onReasoningDelta?: (text: string) => void,
): Promise {
- return tools.length ? this.chat(messages, tools, signal) : this.request(messages, tools, signal, onDelta);
+ return this.request(messages, tools, signal, tools.length ? undefined : onDelta, onReasoningDelta);
}
private async request(
@@ -206,6 +207,7 @@ export class GeminiProvider implements LLMProvider {
tools: LLMToolDef[],
signal?: AbortSignal,
onDelta?: (text: string) => void,
+ onReasoningDelta?: (text: string) => void,
): Promise {
signal?.throwIfAborted();
if (!this.apiKey) {
@@ -238,7 +240,12 @@ export class GeminiProvider implements LLMProvider {
const candidate = (value as GeminiResponse).candidates?.[0];
for (const part of candidate?.content?.parts ?? []) {
if (part.functionCall) throw new Error('Unexpected Gemini tool response');
- if (part.thought || !part.text) continue;
+ if (part.thought) {
+ if (!finish && typeof part.text === 'string' && part.text) onReasoningDelta?.(part.text);
+ signal?.throwIfAborted();
+ continue;
+ }
+ if (!part.text) continue;
if (finish || typeof part.text !== 'string') throw new Error('Invalid Gemini stream');
text += part.text;
onDelta(part.text);
@@ -253,6 +260,11 @@ export class GeminiProvider implements LLMProvider {
assertStreamRecord('Gemini', value);
fallback = parseGeminiResponse(value as GeminiResponse);
if (fallback.toolCalls.length) throw new Error('Unexpected Gemini tool response');
+ for (const part of (value as GeminiResponse).candidates?.[0]?.content?.parts ?? []) {
+ if (part.thought && typeof part.text === 'string' && part.text) onReasoningDelta?.(part.text);
+ signal?.throwIfAborted();
+ }
+ signal?.throwIfAborted();
if (fallback.text) onDelta(fallback.text);
},
);
@@ -273,6 +285,11 @@ export class GeminiProvider implements LLMProvider {
this.requestOptions,
);
signal?.throwIfAborted();
+ for (const part of data.candidates?.[0]?.content?.parts ?? []) {
+ if (part.thought && typeof part.text === 'string' && part.text) onReasoningDelta?.(part.text);
+ signal?.throwIfAborted();
+ }
+ signal?.throwIfAborted();
return parseGeminiResponse(data);
}
}
diff --git a/packages/agent-core/src/llm/loopback.ts b/packages/agent-core/src/llm/loopback.ts
index e4aea65eb..2f967f0f7 100644
--- a/packages/agent-core/src/llm/loopback.ts
+++ b/packages/agent-core/src/llm/loopback.ts
@@ -80,9 +80,10 @@ export class LoopbackProvider implements LocalLLMProvider {
tools: LLMToolDef[],
onDelta: (text: string) => void,
signal?: AbortSignal,
+ onReasoningDelta?: (text: string) => void,
): Promise {
if (tools.length) return Promise.reject(new Error('Local service cannot execute editor tools'));
- return this.generate(messages, undefined, signal, onDelta);
+ return this.generate(messages, undefined, signal, onDelta, onReasoningDelta);
}
generateJSON(messages: LLMMessage[], schema: Record, signal?: AbortSignal): Promise {
return this.generate(messages, schema, signal);
@@ -92,6 +93,7 @@ export class LoopbackProvider implements LocalLLMProvider {
schema?: Record,
signal?: AbortSignal,
onDelta?: (text: string) => void,
+ onReasoningDelta?: (text: string) => void,
): Promise {
if (!this.isReady()) throw new Error('Connect the local model first');
// Translate completed host tool exchanges to Ollama's documented chat history.
@@ -133,9 +135,13 @@ export class LoopbackProvider implements LocalLLMProvider {
const data = value as {
done?: boolean;
done_reason?: string;
- message?: { content?: string; tool_calls?: unknown[] };
+ message?: { content?: string; thinking?: string; tool_calls?: unknown[] };
};
if (data.message?.tool_calls?.length) throw new Error('Unexpected local service tool response');
+ const reasoning = data.message?.thinking;
+ if (typeof reasoning === 'string' && reasoning) onReasoningDelta?.(reasoning);
+ this.lifetime.signal.throwIfAborted();
+ signal?.throwIfAborted();
const piece = data.message?.content;
if (piece !== undefined && typeof piece !== 'string') throw new Error('Invalid local service stream');
if (piece) {
@@ -185,7 +191,7 @@ export class LoopbackProvider implements LocalLLMProvider {
)) as {
done?: boolean;
done_reason?: string;
- message?: { content?: string; tool_calls?: unknown[] };
+ message?: { content?: string; thinking?: string; tool_calls?: unknown[] };
error?: unknown;
} | null;
if (
diff --git a/packages/agent-core/src/llm/ollama.ts b/packages/agent-core/src/llm/ollama.ts
index 807a58395..fde172efd 100644
--- a/packages/agent-core/src/llm/ollama.ts
+++ b/packages/agent-core/src/llm/ollama.ts
@@ -8,7 +8,13 @@
* configured), and defaults to a small local model. A key is still accepted for
* remote/proxied Ollama deployments that require auth.
*/
-import { type OpenAICompletion, parseOpenAIResponse, toOpenAIMessages, toOpenAITools } from './openai-format';
+import {
+ type OpenAICompletion,
+ parseOpenAIResponse,
+ openAIReasoning,
+ toOpenAIMessages,
+ toOpenAITools,
+} from './openai-format';
import { fetchOpenAIStream } from './openai-stream';
import { getApiKey } from './keys';
import { DEFAULT_SYSTEM_PROMPT } from './prompt';
@@ -57,8 +63,9 @@ export class OllamaProvider implements LLMProvider {
tools: LLMToolDef[],
onDelta: (text: string) => void,
signal?: AbortSignal,
+ onReasoningDelta?: (text: string) => void,
): Promise {
- if (tools.length) return this.chat(messages, tools, signal);
+ if (tools.length) return this.request(messages, tools, signal, onReasoningDelta);
signal?.throwIfAborted();
return fetchOpenAIStream(
'Ollama',
@@ -79,10 +86,20 @@ export class OllamaProvider implements LLMProvider {
},
this.requestOptions,
onDelta,
+ onReasoningDelta,
);
}
- async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise {
+ chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise {
+ return this.request(messages, tools, signal);
+ }
+
+ private async request(
+ messages: LLMMessage[],
+ tools: LLMToolDef[],
+ signal?: AbortSignal,
+ onReasoningDelta?: (text: string) => void,
+ ): Promise {
const headers: Record = { 'Content-Type': 'application/json' };
if (this.apiKey) {
headers.Authorization = `Bearer ${this.apiKey}`;
@@ -102,6 +119,10 @@ export class OllamaProvider implements LLMProvider {
},
this.requestOptions,
);
+ signal?.throwIfAborted();
+ const reasoning = openAIReasoning(completion.choices?.[0]?.message);
+ if (reasoning) onReasoningDelta?.(reasoning);
+ signal?.throwIfAborted();
return parseOpenAIResponse(completion);
}
}
diff --git a/packages/agent-core/src/llm/openai-format.ts b/packages/agent-core/src/llm/openai-format.ts
index dd9cf2eb2..95968a0d1 100644
--- a/packages/agent-core/src/llm/openai-format.ts
+++ b/packages/agent-core/src/llm/openai-format.ts
@@ -24,11 +24,22 @@ export interface OpenAITimings {
predicted_per_second?: number;
}
+export interface OpenAIReasoning {
+ reasoning_content?: string | null;
+ reasoning?: string | null;
+}
+
+/** Compatible APIs expose either alias. Prefer reasoning_content without double emission. */
+export function openAIReasoning(value?: OpenAIReasoning): string | undefined {
+ if (typeof value?.reasoning_content === 'string' && value.reasoning_content) return value.reasoning_content;
+ if (typeof value?.reasoning === 'string' && value.reasoning) return value.reasoning;
+}
+
export interface OpenAICompletion {
timings?: OpenAITimings | null;
usage?: OpenAIUsage | null;
choices: Array<{
- message: { content?: string | null; tool_calls?: OpenAIToolCall[] };
+ message: OpenAIReasoning & { content?: string | null; tool_calls?: OpenAIToolCall[] };
finish_reason?: string;
}>;
}
@@ -100,7 +111,7 @@ export interface OpenAIStreamChunk {
timings?: OpenAITimings | null;
usage?: OpenAIUsage | null;
choices?: Array<{
- delta?: {
+ delta?: OpenAIReasoning & {
content?: string | null;
tool_calls?: Array<{ index?: number; id?: string; function?: { name?: string; arguments?: string } }>;
};
diff --git a/packages/agent-core/src/llm/openai-stream.ts b/packages/agent-core/src/llm/openai-stream.ts
index 4ad746fb0..7e67009b9 100644
--- a/packages/agent-core/src/llm/openai-stream.ts
+++ b/packages/agent-core/src/llm/openai-stream.ts
@@ -1,4 +1,4 @@
-import { type OpenAICompletion, type OpenAIStreamChunk, parseOpenAIResponse } from './openai-format';
+import { type OpenAICompletion, type OpenAIStreamChunk, parseOpenAIResponse, openAIReasoning } from './openai-format';
import { assertStreamRecord, fetchProviderStream } from './provider-stream';
import type { ProviderHttpOptions } from './provider-http';
import type { LLMResponse } from './types';
@@ -10,6 +10,7 @@ export async function fetchOpenAIStream(
init: RequestInit,
options: ProviderHttpOptions,
onDelta: (text: string) => void,
+ onReasoningDelta?: (text: string) => void,
): Promise {
let text = '';
let finish: string | undefined;
@@ -32,6 +33,9 @@ export async function fetchOpenAIStream(
if (choice?.delta?.tool_calls?.length || choice?.finish_reason === 'tool_calls')
throw new Error(`Unexpected ${provider} tool response`);
if (chunk.usage) usage = chunk.usage;
+ const reasoning = openAIReasoning(choice?.delta);
+ if (reasoning && !finish) onReasoningDelta?.(reasoning);
+ init.signal?.throwIfAborted();
if (choice?.delta?.content) {
if (finish || typeof choice.delta.content !== 'string') throw new Error(`Invalid ${provider} stream`);
text += choice.delta.content;
@@ -41,8 +45,12 @@ export async function fetchOpenAIStream(
},
(value) => {
assertStreamRecord(provider, value);
- fallback = parseOpenAIResponse(value as unknown as OpenAICompletion);
+ const completion = value as unknown as OpenAICompletion;
+ fallback = parseOpenAIResponse(completion);
if (fallback.toolCalls.length) throw new Error(`Unexpected ${provider} tool response`);
+ const reasoning = openAIReasoning(completion.choices?.[0]?.message);
+ if (reasoning) onReasoningDelta?.(reasoning);
+ init.signal?.throwIfAborted();
if (fallback.text) onDelta(fallback.text);
},
);
diff --git a/packages/agent-core/src/llm/openai.ts b/packages/agent-core/src/llm/openai.ts
index bfa3934f2..44edbdca4 100644
--- a/packages/agent-core/src/llm/openai.ts
+++ b/packages/agent-core/src/llm/openai.ts
@@ -6,7 +6,13 @@
* OpenAI-format converters. Uses fetch (injectable) rather than a vendor SDK to
* avoid another dependency; the request shape is small and stable.
*/
-import { type OpenAICompletion, parseOpenAIResponse, toOpenAIMessages, toOpenAITools } from './openai-format';
+import {
+ type OpenAICompletion,
+ parseOpenAIResponse,
+ openAIReasoning,
+ toOpenAIMessages,
+ toOpenAITools,
+} from './openai-format';
import { fetchOpenAIStream } from './openai-stream';
import { getApiKey } from './keys';
import { DEFAULT_SYSTEM_PROMPT } from './prompt';
@@ -53,8 +59,9 @@ export class OpenAIProvider implements LLMProvider {
tools: LLMToolDef[],
onDelta: (text: string) => void,
signal?: AbortSignal,
+ onReasoningDelta?: (text: string) => void,
): Promise {
- if (tools.length) return this.chat(messages, tools, signal);
+ if (tools.length) return this.request(messages, tools, signal, onReasoningDelta);
signal?.throwIfAborted();
if (!this.apiKey) throw new Error('OpenAI API key is not configured');
return fetchOpenAIStream(
@@ -73,10 +80,20 @@ export class OpenAIProvider implements LLMProvider {
},
this.requestOptions,
onDelta,
+ onReasoningDelta,
);
}
- async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise {
+ chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise {
+ return this.request(messages, tools, signal);
+ }
+
+ private async request(
+ messages: LLMMessage[],
+ tools: LLMToolDef[],
+ signal?: AbortSignal,
+ onReasoningDelta?: (text: string) => void,
+ ): Promise {
signal?.throwIfAborted();
if (!this.apiKey) {
throw new Error('OpenAI API key is not configured');
@@ -97,6 +114,9 @@ export class OpenAIProvider implements LLMProvider {
this.requestOptions,
);
signal?.throwIfAborted();
+ const reasoning = openAIReasoning(completion.choices?.[0]?.message);
+ if (reasoning) onReasoningDelta?.(reasoning);
+ signal?.throwIfAborted();
return parseOpenAIResponse(completion);
}
}
diff --git a/packages/agent-core/src/llm/types.ts b/packages/agent-core/src/llm/types.ts
index 22238cc4a..cf77ed7b1 100644
--- a/packages/agent-core/src/llm/types.ts
+++ b/packages/agent-core/src/llm/types.ts
@@ -116,6 +116,8 @@ export interface LLMProvider {
tools: LLMToolDef[],
onDelta: (textDelta: string) => void,
signal?: AbortSignal,
+ /** Provider-returned public reasoning only; ephemeral, never part of response/history. */
+ onReasoningDelta?: (text: string) => void,
): Promise;
}
diff --git a/packages/agent-core/src/runtime.ts b/packages/agent-core/src/runtime.ts
index e6aa8bf36..12f14386d 100644
--- a/packages/agent-core/src/runtime.ts
+++ b/packages/agent-core/src/runtime.ts
@@ -30,6 +30,8 @@ export type AgentEvent =
| { type: 'usage'; usage: NonNullable }
| { type: 'assistant'; text: string; streamed: boolean; interrupted?: true }
| { type: 'assistant_delta'; text: string }
+ | { type: 'reasoning_delta'; text: string }
+ | { type: 'reasoning_end' }
| { type: 'tool_call'; name: string; input: Record }
| {
type: 'tool_result';
@@ -158,23 +160,55 @@ export async function runAgent(
contextNoticeSent = true;
options.onContextTrimmed?.();
}
- const response = provider.chatStream
- ? await provider.chatStream(
- requestMessages,
- toolDefs,
- (delta) => {
- if (!delta || options.signal?.aborted) return;
- if (!firstProviderText) {
- firstProviderText = true;
- options.onFirstProviderText?.();
- }
- streamed = true;
- if (toolDefs.length && provider.streamingTextMode !== 'answer-only') deferredDeltas.push(delta);
- else options.onEvent?.({ type: 'assistant_delta', text: delta });
- },
- options.signal,
- )
- : await provider.chat(requestMessages, toolDefs, options.signal);
+ // Each inference owns its callbacks. A settled provider may retain listeners
+ // while a later inference is still running under the same run/signal.
+ let inferenceActive = true;
+ let reasoningObserved = false;
+ let reasoningClosed = false;
+ const inferenceLive = () => {
+ if (!inferenceActive || options.signal?.aborted) return false;
+ options.assertCurrent?.();
+ return !options.signal?.aborted;
+ };
+ const endReasoning = () => {
+ if (reasoningClosed) return;
+ reasoningClosed = true;
+ if (reasoningObserved && inferenceLive()) options.onEvent?.({ type: 'reasoning_end' });
+ };
+ let response: LLMResponse;
+ try {
+ response = provider.chatStream
+ ? await provider.chatStream(
+ requestMessages,
+ toolDefs,
+ (delta) => {
+ if (!delta || !inferenceLive()) return;
+ endReasoning();
+ if (!inferenceLive()) return;
+ if (!firstProviderText) {
+ firstProviderText = true;
+ options.onFirstProviderText?.();
+ }
+ if (!inferenceLive()) return;
+ streamed = true;
+ if (toolDefs.length && provider.streamingTextMode !== 'answer-only') deferredDeltas.push(delta);
+ else options.onEvent?.({ type: 'assistant_delta', text: delta });
+ },
+ options.signal,
+ (text) => {
+ if (!text || reasoningClosed || !inferenceLive()) return;
+ reasoningObserved = true;
+ options.onEvent?.({ type: 'reasoning_delta', text });
+ },
+ )
+ : await provider.chat(requestMessages, toolDefs, options.signal);
+ } finally {
+ try {
+ endReasoning();
+ } finally {
+ inferenceActive = false;
+ }
+ }
if (options.signal?.aborted) return { text: '', messages, toolCallCount, stoppedOnLimit: false, aborted: true };
options.assertCurrent?.();
if (response.contextTrimmed && !contextNoticeSent) {
diff --git a/packages/chat-ui/CLAUDE.md b/packages/chat-ui/CLAUDE.md
index f763fe89d..88a2e7b6b 100644
--- a/packages/chat-ui/CLAUDE.md
+++ b/packages/chat-ui/CLAUDE.md
@@ -46,6 +46,8 @@ container.appendChild(chat.el); // mount the root element
| `append({ role, text })` | Add a finished message. `role`: `'user' \| 'agent' \| 'tool' \| 'status' \| 'error'`. |
| `restoreHistory(messages, { pageSize?: number })` | Opt into a recent window (default/max 40) and load earlier messages without replacing existing rows. |
| `appendMany(messages)` | Add finished messages in order with one final layout update, for restoring history. |
+| `appendReasoningDelta(text)` | Display provider-exposed reasoning outside message/source history. |
+| `endReasoning({ interrupted?: boolean })` | End one inference phase; preserve explicit interruption identity. |
| `appendDelta(text)` | Stream into a live agent bubble (auto-created on first delta). |
| `endStream()` | Finalise the streaming bubble (drops the caret). |
| `observePresentation(callback?)` | Arm one request-owned visible DOM text observation; omit the callback to cancel. |
@@ -56,8 +58,54 @@ container.appendChild(chat.el); // mount the root element
| `setLabels(labels)` | Re-apply labels live (e.g. language change). |
| `dispose()` | Release resize observers and copy-feedback timers when unmounting. |
-`chat.actionsEl` is a host-populated slot directly above the input (an IM-style
-compose toolbar) — append your own controls; it collapses when empty.
+`chat.actionsEl` is the host-populated toolbar beside Send/Stop, below the input.
+Place source controls here and source chips in `contextEl` above the input.
+`pendingEl` stays outside the named composer group; the host owns its queue.
+The input grows to 160px, preserves IME and Shift+Enter, and keeps the next draft editable.
+Localize the accessible group names with `labels.composer` and `labels.actions`;
+`setLabels()` updates them without replacing controls.
+
+## Public reasoning
+
+`appendReasoningDelta(text)` accepts only provider-exposed reasoning during an
+active run. It lazily creates native ranui `r-reasoning`; unsupported providers
+show no reasoning row. `endReasoning({ interrupted?: boolean })` ends one
+inference phase. First answer text, a tool/review/non-answer phase and `endStream()`
+also end it; Stop, error/status and settlement of an unfinished phase mark it
+interrupted. An explicit interruption rejects further reasoning for that run.
+
+```ts
+chat.setRunning(true);
+chat.appendReasoningDelta(publicReasoningDelta); // separate provider callback
+chat.endReasoning(); // inference completed; repeat for later inference phases
+chat.appendDelta(answerDelta); // answer source only
+chat.endStream();
+chat.setRunning(false);
+```
+
+The component coalesces reasoning content with its layout frame and preserves
+reader-owned expansion and scrolling. It never assigns a model duration.
+The shared budget is 64 KiB of UTF-8 per user run, truncated at a code-point
+boundary with one notice. Surrogate pairs split between deltas are buffered;
+orphaned surrogates become U+FFFD at the phase boundary. Extra text is ignored until the next false → true
+`setRunning` transition. Repeated `setRunning(true)` does not reset the budget.
+
+Reasoning is volatile presentation, separate from `ChatMessage`, `data-source`,
+history, answer getters, Copy, Save, Apply and message presentation/provenance
+hooks. Hosts must keep it out of model context and persistence. `clear()`,
+`restoreHistory()` and `dispose()` discard it and reject stale reasoning; clear
+retains the host's run controls until settlement. Hosts still own request-identity
+guards when routing asynchronous callbacks. Localize `reasoning`,
+`reasoningInterrupted`, `reasoningTruncated` (English defaults: “Reasoning”,
+“Reasoning interrupted”, “Reasoning truncated”).
+
+`renderChatMarkdown(target, text)` exports the same completed-answer renderer
+for host previews. It replaces target children with DOM nodes, treats model HTML
+and image syntax as literal text, and permits only explicit `http:`, `https:` and
+`mailto:` links, opened with `noopener noreferrer`. It neither loads remote media
+nor inserts model HTML. The caller owns input bounds and styling; use a
+`cui-bubble` container inside the existing ChatView style scope when sharing the
+conversation typography.
## Canonical streaming flow
diff --git a/packages/chat-ui/README.md b/packages/chat-ui/README.md
index a46ce5d0e..dd4efcf7b 100644
--- a/packages/chat-ui/README.md
+++ b/packages/chat-ui/README.md
@@ -39,6 +39,7 @@ Override the look via the `--cui-*` custom properties on `.cui-root`.
## API
- `append({ role, text })` — add a finished message (`user` / `agent` / `tool` / `status` / `error`).
+- `appendReasoningDelta(text)` / `endReasoning({ interrupted?: boolean })` — display and finish ephemeral provider-exposed reasoning.
- `appendDelta(text)` — stream text into a live agent bubble (auto-created).
- `endStream()` — finalise the streaming bubble.
- `setRunning(bool)` — update run controls while keeping the next draft editable; queue acceptance is opt-in.
@@ -47,7 +48,67 @@ Override the look via the `--cui-*` custom properties on `.cui-root`.
- `clear()` — remove messages and waiting feedback; it does not stop the host's run.
- `getInput()` / `setInput(text)` / `focus()` — input helpers.
- `setLabels(labels)` — update labels live (e.g. language change).
-- `actionsEl` — host-populated slot above the input (compose toolbar); collapses when empty.
+- `actionsEl` — host-populated toolbar beside Send/Stop, below the input.
+- `contextEl` — host-populated source context above the input, inside the composer.
+- `pendingEl` — host-populated queue footer outside the composer.
+- `dispose()` — release observers/timers and discard ephemeral reasoning.
+
+`labels.composer` and `labels.actions` name the accessible composer and its
+control row; both update live through `setLabels()`.
+
+## Public reasoning
+
+`appendReasoningDelta(text)` accepts only provider-exposed reasoning during an
+active run. It lazily creates native ranui `r-reasoning`; unsupported providers
+show no reasoning row. `endReasoning({ interrupted?: boolean })` ends one
+inference phase. First answer text, a tool/review/non-answer phase and `endStream()`
+also end it; Stop, error/status and settlement of an unfinished phase mark it
+interrupted. An explicit interruption rejects further reasoning for that run.
+
+```ts
+chat.setRunning(true);
+chat.appendReasoningDelta(publicReasoningDelta); // separate provider callback
+chat.endReasoning(); // inference completed; repeat for later inference phases
+chat.appendDelta(answerDelta); // answer source only
+chat.endStream();
+chat.setRunning(false);
+```
+
+The component coalesces reasoning content with its layout frame and preserves
+reader-owned expansion and scrolling. It never assigns a model duration.
+The shared budget is 64 KiB of UTF-8 per user run, truncated at a code-point
+boundary with one notice. Surrogate pairs split between deltas are buffered;
+orphaned surrogates become U+FFFD at the phase boundary. Extra text is ignored until the next false → true
+`setRunning` transition. Repeated `setRunning(true)` does not reset the budget.
+
+Reasoning is volatile presentation, separate from `ChatMessage`, `data-source`,
+history, answer getters, Copy, Save, Apply and message presentation/provenance
+hooks. Hosts must keep it out of model context and persistence. `clear()`,
+`restoreHistory()` and `dispose()` discard it and reject stale reasoning; clear
+retains the host's run controls until settlement. Hosts still own request-identity
+guards when routing asynchronous callbacks. Localize `reasoning`,
+`reasoningInterrupted`, `reasoningTruncated` (English defaults: “Reasoning”,
+“Reasoning interrupted”, “Reasoning truncated”).
+
+`renderChatMarkdown(target, text)` exports the same completed-answer renderer
+for host previews. It replaces target children with DOM nodes, treats model HTML
+and image syntax as literal text, and permits only explicit `http:`, `https:` and
+`mailto:` links, opened with `noopener noreferrer`. It neither loads remote media
+nor inserts model HTML. The caller owns input bounds. Shared table/code styles are
+injected even for artifact-only previews; prose typography remains host-owned.
+Tables honor declared column alignment and have a named keyboard-scroll region.
+Code blocks preserve literal text in a local scroll frame with a language and
+Copy header. Supported completed fences up to 8000 characters receive lazy
+Shiki token colors; unknown, unfinished and larger fences stay plain. Highlighting
+never replaces an active text selection and theme changes only recolor tokens.
+
+Hosts with a mount/unmount lifecycle should create
+`new MarkdownPresentation(root, labels)`, pass it as the optional third argument
+to `renderChatMarkdown(target, text, presentation)`, call `setLabels(...)` for live
+localization and `dispose()` on unmount. This releases clipboard feedback timers
+and guards late clipboard/highlight results. ChatView already owns this lifecycle.
+Copy uses the displayed code payload; message Copy/Save/Apply still use the original
+answer source. Code blocks have no implied execution or preview action.
Waiting feedback appears immediately, with a locally measured clock formatted
by `labels.elapsed(seconds)` and a classification label from `labels.routing`.
diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json
index 9e449c5a1..31f01e571 100644
--- a/packages/chat-ui/package.json
+++ b/packages/chat-ui/package.json
@@ -24,7 +24,8 @@
"dependencies": {
"marked": "16.4.2",
"ranui": "0.5.0",
- "ranuts": "0.5.0"
+ "ranuts": "0.5.0",
+ "shiki": "4.4.3"
},
"devDependencies": {
"typescript": "^6.0.3"
diff --git a/packages/chat-ui/src/chat-view.ts b/packages/chat-ui/src/chat-view.ts
index 0817ed794..a9f5b8e09 100644
--- a/packages/chat-ui/src/chat-view.ts
+++ b/packages/chat-ui/src/chat-view.ts
@@ -1,7 +1,9 @@
import { ButtonBuilder, Div, Span, View } from 'ranui/builder';
import { throttle } from 'ranuts/utils';
+import { Reasoning } from 'ranui/reasoning';
import { ensureChatUiStyles } from './styles';
import { renderMarkdown } from './markdown';
+import { MarkdownPresentation } from './markdown-presentation';
import { StreamingMarkdown, STREAMING_MARKDOWN_LIMIT } from './streaming-markdown';
import type {
ChatNavigationSnapshot,
@@ -28,6 +30,31 @@ const ICON_DRAFT =
/** Distance (px) from the bottom within which we keep auto-scrolling on new content. */
const STICK_THRESHOLD = 60;
+const REASONING_BYTE_LIMIT = 64 * 1024;
+
+interface ReasoningPhase {
+ row: HTMLDivElement;
+ element: Reasoning;
+ text: string;
+ dirty: boolean;
+ interrupted: boolean;
+ notice?: HTMLDivElement;
+}
+
+/** A bounded UTF-8 prefix; never split a supplementary Unicode code point. */
+function reasoningPrefix(text: string, remaining: number): { text: string; bytes: number; truncated: boolean } {
+ let bytes = 0;
+ let end = 0;
+ for (const character of text) {
+ const point = character.codePointAt(0)!;
+ const size = point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
+ if (bytes + size > remaining)
+ return { text: text.slice(0, end).replace(/[\uD800-\uDFFF]/gu, '\uFFFD'), bytes, truncated: true };
+ bytes += size;
+ end += character.length;
+ }
+ return { text: text.replace(/[\uD800-\uDFFF]/gu, '\uFFFD'), bytes, truncated: false };
+}
// Compact identity for ephemeral presentation snapshots; never persisted or used for authorization.
function navigationFingerprint(text: string): string {
@@ -64,6 +91,8 @@ export class ChatView {
private readonly emptyEl: HTMLDivElement;
private readonly emptyTitle: HTMLDivElement;
private readonly input: HTMLTextAreaElement;
+ private readonly composerEl: HTMLDivElement;
+ private readonly composerBarEl: HTMLDivElement;
private readonly sendBtn: HTMLButtonElement;
private readonly stopBtn: HTMLButtonElement;
private readonly scrollBtn: HTMLButtonElement;
@@ -108,6 +137,14 @@ export class ChatView {
private readonly actionLabels = new WeakMap string>();
private readonly messageMetadata = new WeakMap> | undefined>();
private readonly copyTimers = new Map>();
+ /** Volatile reasoning is deliberately separate from message/source records. */
+ private readonly reasoningPhases = new Set();
+ private liveReasoning?: ReasoningPhase;
+ private lastRunReasoning?: ReasoningPhase;
+ private reasoningBytes = 0;
+ private reasoningTruncated = false;
+ private reasoningInterrupted = false;
+ private reasoningTail = '';
/** The agent bubble currently receiving streamed deltas, if any. */
private liveMsg: HTMLDivElement | null = null;
private liveText = '';
@@ -116,6 +153,10 @@ export class ChatView {
private presentationRevision = 0;
private livePresentationRevision?: number;
private liveMarkdown?: StreamingMarkdown;
+ private markdownPresentation?: MarkdownPresentation;
+ private get markdown(): MarkdownPresentation {
+ return (this.markdownPresentation ??= new MarkdownPresentation(this.messagesEl, this.labels));
+ }
private liveNode: Text | null = null;
private pendingText = '';
private actionRunning = false;
@@ -194,13 +235,15 @@ export class ChatView {
.build();
this.scrollBtn.innerHTML = ICON_DOWN;
- // Compose toolbar slot: host-populated controls just above the input.
+ // Compose toolbar slot: host-populated controls beside Send/Stop.
this.actionsEl = Div().class('cui-actions').build();
- // Composer: rounded container holding the textarea + a circular icon button.
+ // Composer: one accessible boundary for context, draft and stable actions.
this.input = View('textarea')
.class('cui-input')
.attr('rows', '1')
+ .attr('wrap', 'soft')
+ .attr('enterkeyhint', 'send')
.on('compositionstart', () => {
this.composing = true;
})
@@ -240,18 +283,20 @@ export class ChatView {
this.stopBtn.innerHTML = ICON_STOP;
this.stopBtn.hidden = true;
- const composer = Div()
+ this.composerBarEl = Div()
+ .class('cui-composer-bar')
+ .attr('role', 'group')
+ .children(this.actionsEl, this.stopBtn, this.sendBtn)
+ .build();
+ this.composerEl = Div()
.class('cui-composer')
- .children(
- this.contextEl,
- this.input,
- Div().class('cui-composer-bar').children(this.actionsEl, this.stopBtn, this.sendBtn).build(),
- )
+ .attr('role', 'group')
+ .children(this.contextEl, this.input, this.composerBarEl)
.build();
// Footer hosts the jump-to-latest button (floats just above it), the action
// slot, and the composer.
- const footer = Div().class('cui-footer').children(this.scrollBtn, this.pendingEl, composer).build();
+ const footer = Div().class('cui-footer').children(this.scrollBtn, this.pendingEl, this.composerEl).build();
this.el = Div().class('cui-root').children(this.messagesEl, this.progressEl, footer, this.feedbackEl).build();
if (typeof ResizeObserver !== 'undefined') {
@@ -294,6 +339,7 @@ export class ChatView {
/** Append a finished message and scroll to it. Returns the bubble element. */
append(message: ChatMessage): HTMLElement {
+ this.endReasoning({ interrupted: message.role === 'error' || message.role === 'status' });
if ((message.role === 'status' || message.role === 'error') && this.liveMsg) {
this.interruptStream();
}
@@ -357,12 +403,12 @@ export class ChatView {
try {
if (this.options.streamingMarkdown && visibleText.length > STREAMING_MARKDOWN_LIMIT)
bubble.classList.add('cui-stream-raw');
- else renderMarkdown(bubble, visibleText);
+ else renderMarkdown(bubble, visibleText, this.markdown);
} catch {
bubble.textContent = visibleText;
if (this.options.streamingMarkdown) bubble.classList.add('cui-stream-raw');
}
- } else renderMarkdown(bubble, visibleText);
+ } else renderMarkdown(bubble, visibleText, this.markdown);
}
if (message.role === 'agent') this.options.presentMessage?.(bubble, message.metadata);
const row = Div().class(`cui-msg cui-msg-${message.role}`).attr('data-role', message.role).children(bubble).build();
@@ -568,7 +614,7 @@ export class ChatView {
const ownedFocus = document.activeElement === expand;
if (markdown) {
try {
- renderMarkdown(bubble, message.text);
+ renderMarkdown(bubble, message.text, this.markdown);
this.options.presentMessage?.(bubble, message.metadata);
} catch {
bubble.textContent = message.text;
@@ -661,12 +707,120 @@ export class ChatView {
}
}
+ /** Render only provider-exposed reasoning, outside messages, history and source actions. */
+ appendReasoningDelta(delta: string): void {
+ if (
+ !delta ||
+ !this.running ||
+ this.stopping ||
+ this.disposed ||
+ this.presentationCleared ||
+ this.reasoningInterrupted ||
+ this.reasoningTruncated
+ )
+ return;
+ let text = this.reasoningTail + delta;
+ this.reasoningTail = '';
+ const last = text.charCodeAt(text.length - 1);
+ if (last >= 0xd800 && last <= 0xdbff) {
+ this.reasoningTail = text.slice(-1);
+ text = text.slice(0, -1);
+ }
+ this.appendReasoningText(text);
+ }
+
+ /** Accept a complete scalar sequence, including a normalized tail at phase end. */
+ private appendReasoningText(text: string): void {
+ if (!text || this.reasoningTruncated) return;
+ const prefix = reasoningPrefix(text, REASONING_BYTE_LIMIT - this.reasoningBytes);
+ this.reasoningBytes += prefix.bytes;
+ if (prefix.text && !this.liveReasoning) {
+ const element = new Reasoning();
+ const row = Div().class('cui-reasoning').children(element).build();
+ const phase: ReasoningPhase = { row, element, text: '', dirty: false, interrupted: false };
+ this.liveReasoning = phase;
+ this.lastRunReasoning = phase;
+ this.reasoningPhases.add(phase);
+ element.label = this.labels.reasoning ?? 'Reasoning';
+ // Native reasoning owns automatic disclosure and yields permanently to the reader.
+ // Never write `open` here, including during label updates or completion.
+ element.streaming = true;
+ element.addEventListener('disclosuretoggle', () => {
+ if (this.reasoningPhases.has(phase)) this.scheduleLayout();
+ });
+ this.emptyEl.remove();
+ this.messagesEl.append(row);
+ this.resizeObserver?.observe(row);
+ this.updateActivity();
+ }
+ if (this.liveReasoning && prefix.text) {
+ this.liveReasoning.text += prefix.text;
+ this.liveReasoning.dirty = true;
+ }
+ if (prefix.truncated) {
+ this.reasoningTruncated = true;
+ this.reasoningTail = '';
+ const phase = this.lastRunReasoning;
+ if (phase) {
+ phase.notice = Div()
+ .class('cui-reasoning-notice')
+ .text(this.labels.reasoningTruncated ?? 'Reasoning truncated')
+ .build();
+ phase.row.append(phase.notice);
+ }
+ }
+ this.scheduleLayout();
+ }
+
+ /** End one inference phase; later phases share this user run's UTF-8 budget. */
+ endReasoning(options: { interrupted?: boolean } = {}): void {
+ if (this.disposed) return;
+ if (this.reasoningTail) {
+ this.reasoningTail = '';
+ this.appendReasoningText('\uFFFD');
+ }
+ if (options.interrupted) this.reasoningInterrupted = true;
+ const phase = this.liveReasoning;
+ if (!phase) return;
+ this.flushReasoning();
+ phase.interrupted = !!options.interrupted;
+ phase.element.label = phase.interrupted
+ ? (this.labels.reasoningInterrupted ?? 'Reasoning interrupted')
+ : (this.labels.reasoning ?? 'Reasoning');
+ phase.element.streaming = false;
+ this.liveReasoning = undefined;
+ this.updateActivity();
+ this.scheduleLayout();
+ }
+
+ private flushReasoning(): void {
+ const phase = this.liveReasoning;
+ if (!phase?.dirty) return;
+ phase.element.content = phase.text;
+ phase.dirty = false;
+ }
+
+ private discardReasoning(): void {
+ for (const phase of this.reasoningPhases) {
+ phase.element.streaming = false;
+ phase.element.content = '';
+ phase.text = '';
+ this.resizeObserver?.unobserve(phase.row);
+ phase.row.remove();
+ }
+ this.reasoningPhases.clear();
+ this.liveReasoning = undefined;
+ this.lastRunReasoning = undefined;
+ this.reasoningTail = '';
+ }
+
/**
* Append streamed text to a live agent bubble, creating it on the first delta.
* Call {@link endStream} when the turn completes.
*/
appendDelta(delta: string, metadata: Pick = {}): void {
if (!delta || this.disposed || this.stopping) return;
+ this.endReasoning();
const firstForPhase = !this.respondingHasText;
this.respondingHasText = true;
if (!this.liveMsg) {
@@ -679,7 +833,7 @@ export class ChatView {
bubble.replaceChildren(this.liveNode);
if (this.options.streamingMarkdown) {
bubble.classList.remove('cui-stream-raw');
- this.liveMarkdown = new StreamingMarkdown(bubble);
+ this.liveMarkdown = new StreamingMarkdown(bubble, this.markdown);
}
}
if (firstForPhase) this.updateActivity();
@@ -691,6 +845,7 @@ export class ChatView {
/** Finalise the current streaming bubble (removes the caret). */
endStream(): void {
if (this.stopping || this.disposed) return;
+ this.endReasoning();
const stick = this.following;
if (this.liveMarkdown) {
this.liveMarkdown.update(this.liveText, true);
@@ -698,7 +853,7 @@ export class ChatView {
} else this.flushStream();
if (this.liveMsg) this.liveMsg.dataset.source = this.liveText;
const bubble = this.liveMsg?.querySelector('.cui-bubble');
- if (bubble && !this.liveMarkdown) renderMarkdown(bubble, this.liveText);
+ if (bubble && !this.liveMarkdown) renderMarkdown(bubble, this.liveText, this.markdown);
if (bubble && this.liveMsg) this.options.presentMessage?.(bubble, this.messageMetadata.get(this.liveMsg));
if (
bubble &&
@@ -736,7 +891,7 @@ export class ChatView {
if (!deferMarkdown) {
const bubble = row.querySelector('.cui-bubble');
if (markdown) markdown.update(text, true);
- else if (bubble) renderMarkdown(bubble, text);
+ else if (bubble) renderMarkdown(bubble, text, this.markdown);
if (bubble) this.options.presentMessage?.(bubble, this.messageMetadata.get(row));
this.updateActivity();
if (bubble && this.following) this.finishAppendLayout(true);
@@ -754,7 +909,7 @@ export class ChatView {
if (this.disposed || !this.messagesEl.contains(row) || row.dataset.source !== text) return;
const bubble = row.querySelector('.cui-bubble');
if (markdown) markdown.update(text, true);
- else if (bubble) renderMarkdown(bubble, text);
+ else if (bubble) renderMarkdown(bubble, text, this.markdown);
if (bubble) this.options.presentMessage?.(bubble, this.messageMetadata.get(row));
this.scheduleLayout();
}, 0);
@@ -767,6 +922,7 @@ export class ChatView {
if (!this.running || this.stopping || this.disposed) return;
const restoreFocus = document.activeElement === this.sendBtn || document.activeElement === this.stopBtn;
this.stopping = true;
+ this.endReasoning({ interrupted: true });
this.messagesEl.setAttribute('aria-busy', 'true');
this.updateActivity();
this.updateSendState();
@@ -814,6 +970,7 @@ export class ChatView {
/** Mount a host-owned review card in chronological conversation order. */
appendContent(content: HTMLElement): void {
+ this.endReasoning();
this.emptyEl.remove();
const stick = this.following;
this.messagesEl.append(content);
@@ -823,11 +980,17 @@ export class ChatView {
/** Update run presentation; optional queue hosts retain an independent Send control. */
setRunning(running: boolean): void {
+ if (this.disposed) return;
+ if (!running) this.endReasoning({ interrupted: true });
const restore =
!running && this.running && (document.activeElement === this.sendBtn || document.activeElement === this.stopBtn);
if (running && !this.running) {
this.respondingHasText = false;
this.presentationCleared = false;
+ this.reasoningBytes = 0;
+ this.reasoningTruncated = false;
+ this.reasoningInterrupted = false;
+ this.lastRunReasoning = undefined;
}
this.running = running;
if (!running) this.stopping = false;
@@ -841,6 +1004,7 @@ export class ChatView {
/** Host-reported activity, not inferred model thoughts or estimated progress. */
setActivity(activity: 'responding' | 'routing' | 'reading' | 'planning' | 'reviewing'): void {
+ if (activity !== 'responding') this.endReasoning();
if (activity !== this.activity) this.respondingHasText = false;
this.activity = activity;
this.messagesEl.setAttribute('aria-busy', String(this.running && (this.stopping || activity !== 'reviewing')));
@@ -854,6 +1018,7 @@ export class ChatView {
!this.running ||
this.disposed ||
this.presentationCleared ||
+ (!!this.liveReasoning && !this.stopping) ||
(this.respondingHasText && this.activity === 'responding' && !this.stopping)
? ''
: this.stopping
@@ -903,6 +1068,7 @@ export class ChatView {
this.historyStart = 0;
this.earlierButton = undefined;
this.cancelInterruptedRenders();
+ this.discardReasoning();
// Clear the rendered feedback without claiming that the host's run stopped.
this.presentationCleared = true;
this.respondingHasText = false;
@@ -914,6 +1080,7 @@ export class ChatView {
this.liveMarkdown = undefined;
this.clearCopyFeedback();
this.resizeObserver?.disconnect();
+ this.markdownPresentation?.releaseTree(this.messagesEl);
this.messagesEl.replaceChildren(this.emptyEl);
this.messagesEl.scrollTop = 0;
this.emptyTitle.textContent = this.labels.empty ?? '';
@@ -1037,6 +1204,15 @@ export class ChatView {
/** Update labels (e.g. on a language change) and re-apply them live. */
setLabels(labels: ChatViewLabels): void {
this.labels = labels;
+ this.markdownPresentation?.setLabels(labels);
+ this.composerEl.setAttribute('aria-label', labels.composer?.trim() || 'Message composer');
+ this.composerBarEl.setAttribute('aria-label', labels.actions?.trim() || 'Message actions');
+ for (const phase of this.reasoningPhases) {
+ phase.element.label = phase.interrupted
+ ? (labels.reasoningInterrupted ?? 'Reasoning interrupted')
+ : (labels.reasoning ?? 'Reasoning');
+ if (phase.notice) phase.notice.textContent = labels.reasoningTruncated ?? 'Reasoning truncated';
+ }
this.messagesEl.setAttribute('aria-label', labels.conversation?.trim() || 'Conversation');
this.input.placeholder = labels.placeholder ?? '';
this.input.setAttribute('aria-label', labels.placeholder ?? 'Message');
@@ -1181,6 +1357,7 @@ export class ChatView {
this.restoredHistory = [];
this.earlierButton = undefined;
this.cancelInterruptedRenders();
+ this.discardReasoning();
this.running = false;
this.updateActivity();
this.messagesEl.setAttribute('aria-busy', 'false');
@@ -1191,6 +1368,7 @@ export class ChatView {
this.liveMarkdown = undefined;
this.resizeObserver?.disconnect();
this.clearCopyFeedback();
+ this.markdownPresentation?.dispose();
}
private observeLayout(): void {
@@ -1205,6 +1383,7 @@ export class ChatView {
this.layoutFrame = undefined;
if (this.disposed) return;
this.flushStream();
+ this.flushReasoning();
if (this.inputNeedsResize) {
this.inputNeedsResize = false;
this.autoGrow();
diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts
index 2b5474af5..84a1e0ec8 100644
--- a/packages/chat-ui/src/index.ts
+++ b/packages/chat-ui/src/index.ts
@@ -1,4 +1,5 @@
export { ChatView } from './chat-view';
+export { renderMarkdown as renderChatMarkdown } from './markdown';
export { CHAT_UI_CSS, ensureChatUiStyles } from './styles';
export type {
ChatNavigationSnapshot,
@@ -10,3 +11,6 @@ export type {
ChatViewOptions,
RestoreHistoryOptions,
} from './types';
+
+export { MarkdownPresentation } from './markdown-presentation';
+export type { MarkdownLabels } from './markdown-presentation';
diff --git a/packages/chat-ui/src/markdown-highlight.ts b/packages/chat-ui/src/markdown-highlight.ts
new file mode 100644
index 000000000..c48925f41
--- /dev/null
+++ b/packages/chat-ui/src/markdown-highlight.ts
@@ -0,0 +1,87 @@
+import type { HighlighterCore, ThemedToken } from 'shiki/core';
+
+/** Fixed imports: model language names can never choose a module URL. */
+const languages = {
+ javascript: () => import('shiki/langs/javascript.mjs'),
+ typescript: () => import('shiki/langs/typescript.mjs'),
+ jsx: () => import('shiki/langs/jsx.mjs'),
+ tsx: () => import('shiki/langs/tsx.mjs'),
+ python: () => import('shiki/langs/python.mjs'),
+ json: () => import('shiki/langs/json.mjs'),
+ html: () => import('shiki/langs/html.mjs'),
+ css: () => import('shiki/langs/css.mjs'),
+ bash: () => import('shiki/langs/bash.mjs'),
+ sql: () => import('shiki/langs/sql.mjs'),
+ yaml: () => import('shiki/langs/yaml.mjs'),
+ markdown: () => import('shiki/langs/markdown.mjs'),
+};
+type Language = keyof typeof languages;
+const aliases: Record = {
+ js: 'javascript',
+ javascript: 'javascript',
+ ts: 'typescript',
+ typescript: 'typescript',
+ jsx: 'jsx',
+ tsx: 'tsx',
+ py: 'python',
+ python: 'python',
+ json: 'json',
+ html: 'html',
+ css: 'css',
+ sh: 'bash',
+ shell: 'bash',
+ bash: 'bash',
+ sql: 'sql',
+ yml: 'yaml',
+ yaml: 'yaml',
+ md: 'markdown',
+ markdown: 'markdown',
+};
+const names: Record = {
+ javascript: 'JavaScript',
+ typescript: 'TypeScript',
+ jsx: 'JSX',
+ tsx: 'TSX',
+ python: 'Python',
+ json: 'JSON',
+ html: 'HTML',
+ css: 'CSS',
+ bash: 'Shell',
+ sql: 'SQL',
+ yaml: 'YAML',
+ markdown: 'Markdown',
+};
+export function codeLanguage(info: string | undefined): { id?: Language; label: string } {
+ const name = info?.trim().split(/\s/, 1)[0].slice(0, 64).toLowerCase() ?? '';
+ const id = Object.hasOwn(aliases, name) ? aliases[name] : undefined;
+ return { id, label: id ? names[id] : name };
+}
+export const CODE_HIGHLIGHT_LIMIT = 8000;
+let core: Promise | undefined;
+const loaded = new Map>();
+function getCore(): Promise {
+ return (core ??= Promise.all([
+ import('shiki/core'),
+ import('shiki/engine/javascript'),
+ import('shiki/themes/github-light.mjs'),
+ import('shiki/themes/github-dark.mjs'),
+ ]).then(([{ createHighlighterCore }, { createJavaScriptRegexEngine }, light, dark]) =>
+ createHighlighterCore({ engine: createJavaScriptRegexEngine(), themes: [light.default, dark.default], langs: [] }),
+ ));
+}
+/** Tokens only. Consumers append textContent and colors, never highlighter HTML. */
+export async function highlightCode(source: string, language: Language): Promise {
+ const highlighter = await getCore();
+ let ready = loaded.get(language);
+ if (!ready) {
+ ready = languages[language]().then((module) => highlighter.loadLanguage(module.default));
+ loaded.set(language, ready);
+ }
+ await ready;
+ return highlighter.codeToTokens(source, {
+ lang: language,
+ themes: { light: 'github-light', dark: 'github-dark' },
+ defaultColor: false,
+ includeExplanation: false,
+ }).tokens;
+}
diff --git a/packages/chat-ui/src/markdown-presentation.ts b/packages/chat-ui/src/markdown-presentation.ts
new file mode 100644
index 000000000..1334a2fb8
--- /dev/null
+++ b/packages/chat-ui/src/markdown-presentation.ts
@@ -0,0 +1,204 @@
+import { CODE_HIGHLIGHT_LIMIT, codeLanguage, highlightCode } from './markdown-highlight';
+import { ensureMarkdownStyles } from './markdown-styles';
+export interface MarkdownLabels {
+ copy?: string;
+ copied?: string;
+ copyFailed?: string;
+ codeBlock?: string;
+ table?: string;
+}
+interface CodePresentation {
+ frame: HTMLElement;
+ button: HTMLButtonElement;
+ label: HTMLElement;
+ language: HTMLElement;
+ pre: HTMLPreElement;
+ named: boolean;
+ state?: 'copied' | 'failed';
+ timer?: ReturnType;
+ pending: boolean;
+ abort: AbortController;
+}
+const COPY =
+ '';
+const COPIED =
+ '';
+/** Owns presentation work only: source, history, persistence and host actions stay outside. */
+export class MarkdownPresentation {
+ private readonly codes = new Set();
+ private readonly tables = new Set();
+ private disposed = false;
+ private disabled = false;
+ constructor(
+ private readonly root: HTMLElement,
+ private labels: MarkdownLabels = {},
+ ) {
+ ensureMarkdownStyles();
+ }
+ table(wrapper: HTMLElement): void {
+ wrapper.setAttribute('role', 'region');
+ wrapper.setAttribute('aria-label', this.labels.table ?? 'Table');
+ this.tables.add(wrapper);
+ }
+ code(source: string, info: string | undefined, complete: boolean): HTMLElement {
+ const frame = document.createElement('div');
+ frame.className = 'cui-code';
+ const header = document.createElement('div');
+ header.className = 'cui-code-header';
+ const language = document.createElement('span');
+ language.className = 'cui-code-language';
+ const { id, label: languageName } = codeLanguage(info);
+ language.textContent = languageName || (this.labels.codeBlock ?? 'Code');
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'cui-code-copy';
+ const icon = document.createElement('span');
+ icon.innerHTML = COPY; // Constant application icon, never model content.
+ const label = document.createElement('span');
+ label.className = 'cui-code-copy-label';
+ label.setAttribute('role', 'status');
+ label.setAttribute('aria-live', 'polite');
+ label.setAttribute('aria-atomic', 'true');
+ button.append(icon, label);
+ header.append(language, button);
+ const pre = document.createElement('pre');
+ pre.tabIndex = 0;
+ pre.setAttribute('role', 'region');
+ const code = document.createElement('code');
+ code.textContent = source;
+ pre.append(code);
+ frame.append(header, pre);
+ const record: CodePresentation = {
+ frame,
+ button,
+ label,
+ language,
+ pre,
+ named: !!languageName,
+ pending: false,
+ abort: new AbortController(),
+ };
+ this.codes.add(record);
+ button.disabled = this.disabled;
+ this.update(record);
+ button.addEventListener(
+ 'click',
+ async () => {
+ if (!this.owns(record) || record.pending || this.disabled) return;
+ clearTimeout(record.timer);
+ record.pending = true;
+ button.disabled = true;
+ let state: 'copied' | 'failed';
+ try {
+ await navigator.clipboard.writeText(source);
+ state = 'copied';
+ } catch {
+ state = 'failed';
+ }
+ if (!this.owns(record)) return;
+ record.pending = false;
+ button.disabled = this.disabled;
+ record.state = state;
+ this.update(record);
+ record.timer = setTimeout(() => {
+ if (!this.owns(record)) return;
+ record.state = undefined;
+ this.update(record);
+ }, 2000);
+ },
+ { signal: record.abort.signal },
+ );
+ if (complete && id && source.length <= CODE_HIGHLIGHT_LIMIT) {
+ // The synchronous render inserts its fragment before this microtask runs.
+ queueMicrotask(() => {
+ if (!this.owns(record)) return;
+ void highlightCode(source, id)
+ .then((lines) => {
+ if (!this.owns(record) || code.textContent !== source) return;
+ const selection = window.getSelection();
+ if (
+ selection &&
+ !selection.isCollapsed &&
+ (code.contains(selection.anchorNode) ||
+ code.contains(selection.focusNode) ||
+ (selection.rangeCount && selection.getRangeAt(0).intersectsNode(code)))
+ )
+ return;
+ const fragment = document.createDocumentFragment();
+ for (const [index, line] of lines.entries()) {
+ if (index) fragment.append(document.createTextNode('\n'));
+ for (const token of line) {
+ const span = document.createElement('span');
+ span.className = 'cui-code-token';
+ span.textContent = token.content;
+ const styles = token.htmlStyle;
+ if (styles && typeof styles !== 'string') {
+ if (styles['--shiki-light']) span.style.setProperty('--cui-token-light', styles['--shiki-light']);
+ if (styles['--shiki-dark']) span.style.setProperty('--cui-token-dark', styles['--shiki-dark']);
+ }
+ fragment.append(span);
+ }
+ }
+ // Preserve exact newlines/tabs even if a grammar normalizes a boundary.
+ if (fragment.textContent !== source) return;
+ const left = pre.scrollLeft,
+ top = pre.scrollTop;
+ code.replaceChildren(fragment);
+ pre.scrollLeft = left;
+ pre.scrollTop = top;
+ })
+ .catch(() => {
+ /* Plain literal code remains a complete fallback. */
+ });
+ });
+ }
+ return frame;
+ }
+ private owns(record: CodePresentation): boolean {
+ return !this.disposed && this.codes.has(record) && this.root.contains(record.frame) && record.frame.isConnected;
+ }
+ private update(record: CodePresentation): void {
+ record.label.textContent =
+ record.state === 'copied'
+ ? (this.labels.copied ?? 'Copied')
+ : record.state === 'failed'
+ ? (this.labels.copyFailed ?? 'Could not copy')
+ : (this.labels.copy ?? 'Copy');
+ record.button.title = record.label.textContent;
+ record.button.dataset.state = record.state ?? 'idle';
+ record.button.firstElementChild!.innerHTML = record.state === 'copied' ? COPIED : COPY;
+ record.button.setAttribute('aria-label', record.label.textContent);
+ const codeLabel = this.labels.codeBlock ?? 'Code';
+ if (!record.named) record.language.textContent = codeLabel;
+ record.pre.setAttribute('aria-label', record.named ? `${codeLabel}: ${record.language.textContent}` : codeLabel);
+ }
+ setDisabled(disabled: boolean): void {
+ this.disabled = disabled;
+ for (const record of this.codes) record.button.disabled = disabled || record.pending;
+ }
+ setLabels(labels: MarkdownLabels): void {
+ this.labels = labels;
+ this.reconcile();
+ for (const code of this.codes) this.update(code);
+ for (const table of this.tables) table.setAttribute('aria-label', labels.table ?? 'Table');
+ }
+ /** Called after node reconciliation, including stream cache reuse. */
+ reconcile(): void {
+ for (const record of this.codes) if (!this.root.contains(record.frame)) this.release(record);
+ for (const table of this.tables) if (!this.root.contains(table)) this.tables.delete(table);
+ }
+ releaseTree(target: Node): void {
+ for (const record of this.codes) if (target.contains(record.frame)) this.release(record);
+ for (const table of this.tables) if (target.contains(table)) this.tables.delete(table);
+ }
+ private release(record: CodePresentation): void {
+ clearTimeout(record.timer);
+ record.abort.abort();
+ this.codes.delete(record);
+ }
+ dispose(): void {
+ this.disposed = true;
+ for (const record of this.codes) this.release(record);
+ this.tables.clear();
+ }
+}
diff --git a/packages/chat-ui/src/markdown-styles.ts b/packages/chat-ui/src/markdown-styles.ts
new file mode 100644
index 000000000..ca89a181b
--- /dev/null
+++ b/packages/chat-ui/src/markdown-styles.ts
@@ -0,0 +1,34 @@
+/** Block presentation shared by answer prose and artifact readers. */
+export const MARKDOWN_BLOCK_CSS = `
+.cui-markdown { --cui-code-inset:var(--ran-space-4, 16px); --cui-code-surface:var(--ran-color-bg-subtle, #fafafa); --cui-code-ink:var(--ran-color-text, #171717); --cui-code-rule:var(--ran-color-border, #e5e5e5); --cui-code-muted:var(--ran-color-text-secondary, #737373); }
+.cui-markdown .cui-table { max-width:100%; box-sizing:border-box; overflow:auto; overscroll-behavior-x:contain; scrollbar-width:thin; margin-block:var(--ran-space-4, 16px); border-block:1px solid var(--cui-code-rule); }
+.cui-markdown .cui-table table { width:100%; border-collapse:collapse; font-size:var(--ran-text-copy-2, 14px); line-height:1.65; overflow-wrap:normal; font-variant-numeric:tabular-nums; }
+.cui-markdown .cui-table :is(th,td) { min-width:var(--ran-space-16, 64px); padding:var(--ran-space-3, 12px) var(--ran-space-4, 16px); text-align:left; vertical-align:top; border-bottom:1px solid var(--cui-code-rule); }
+.cui-markdown .cui-table :is(th,td):first-child { padding-inline-start:0; }
+.cui-markdown .cui-table :is(th,td):last-child { padding-inline-end:0; }
+.cui-markdown .cui-table th { color:var(--cui-code-muted); font-weight:var(--ran-text-label-weight, 500); font-size:var(--ran-text-label-2, 13px); background:transparent; }
+.cui-markdown .cui-table tbody tr:last-child td { border-bottom:0; }
+.cui-markdown .cui-code { min-width:0; max-width:100%; box-sizing:border-box; overflow:hidden; margin-block:var(--ran-space-4, 16px); border:1px solid var(--cui-code-rule); border-radius:var(--ran-radius-sm, 6px); background:var(--cui-code-surface); color:var(--cui-code-ink); }
+.cui-markdown .cui-code-header { display:flex; align-items:center; justify-content:space-between; gap:var(--ran-space-3, 12px); min-height:var(--ran-space-10, 40px); padding-inline:var(--cui-code-inset); border-bottom:1px solid var(--cui-code-rule); font-family:var(--ran-font-family, system-ui, sans-serif); font-size:var(--ran-text-label-3, 12px); line-height:1.5; color:var(--cui-code-muted); }
+.cui-markdown .cui-code-language { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.cui-markdown .cui-code-copy { display:inline-flex; align-items:center; justify-content:center; gap:var(--ran-space-2, 8px); flex-shrink:0; min-height:var(--ran-size-7, 32px); padding:var(--ran-space-1, 4px) var(--ran-space-2, 8px); margin-inline-end:calc(-1 * var(--ran-space-2, 8px)); border:0; border-radius:var(--ran-radius-sm, 6px); background:transparent; color:inherit; font:inherit; cursor:pointer; }
+.cui-markdown .cui-code-copy svg { display:block; width:var(--ran-size-1, 16px); height:var(--ran-size-1, 16px); flex-shrink:0; }
+.cui-markdown .cui-code-copy:hover:not(:disabled) { background:var(--ran-color-bg-muted, #f4f4f4); color:var(--cui-code-ink); }
+.cui-markdown .cui-code-copy:disabled { cursor:wait; }
+.cui-markdown .cui-code pre { box-sizing:border-box; max-width:100%; overflow:auto; overscroll-behavior-x:contain; scrollbar-width:thin; margin:0; padding:var(--cui-code-inset); border:0; border-radius:0; background:transparent; color:inherit; white-space:pre; text-wrap:nowrap; font:var(--ran-text-copy-2, 14px)/1.65 var(--ran-font-mono, ui-monospace, monospace); tab-size:2; }
+.cui-markdown .cui-code pre code { display:block; min-width:max-content; padding:0; border-radius:0; background:none; color:inherit; font:inherit; white-space:inherit; }
+.cui-markdown > :is(.cui-code,.cui-table):first-child { margin-block-start:0; }
+.cui-markdown > :is(.cui-code,.cui-table):last-child { margin-block-end:0; }
+.cui-markdown .cui-code-token { color:var(--cui-token-light, inherit); }
+.cui-markdown :is(.cui-code-copy,.cui-code pre,.cui-table):focus-visible { outline:2px solid var(--ran-color-link, #0070f3); outline-offset:-2px; }
+:root:is([data-ran-theme='dark'],[theme='dark'],.dark) .cui-code-token { color:var(--cui-token-dark, inherit); }
+@media (prefers-color-scheme:dark) { :root:not([data-ran-theme='light']):not([theme='light']):not(.light) .cui-code-token { color:var(--cui-token-dark, inherit); } }
+@media (pointer:coarse) { .cui-markdown .cui-code-copy { min-height:var(--cui-control-touch, 44px); } .cui-markdown .cui-code-header { min-height:var(--cui-control-touch, 44px); } }
+`;
+export function ensureMarkdownStyles(): void {
+ if (typeof document === 'undefined' || document.getElementById('cui-markdown-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'cui-markdown-styles';
+ style.textContent = MARKDOWN_BLOCK_CSS;
+ document.head.append(style);
+}
diff --git a/packages/chat-ui/src/markdown.ts b/packages/chat-ui/src/markdown.ts
index 3cd6dc727..6484d57f7 100644
--- a/packages/chat-ui/src/markdown.ts
+++ b/packages/chat-ui/src/markdown.ts
@@ -1,16 +1,59 @@
import { Lexer, type Token, type Tokens } from 'marked';
+import { MarkdownPresentation } from './markdown-presentation';
+const presentations = new WeakMap();
+/** Only a syntactically closed fence is safe to color while a response is still streaming. */
+export function closedCodeFence(raw: string): boolean {
+ const fence = raw.match(/^ {0,3}(`{3,}|~{3,})[^\n]*\n/);
+ return !!fence && new RegExp('(?:^|\\n) {0,3}' + fence[1][0] + '{' + fence[1].length + ',} *\\n?$').test(raw);
+}
+/** Whether finalization changes any provisional rendering decision, at every nesting level. */
+export function completeMarkdownTokens(tokens: Token[]): boolean {
+ return tokens.every((token) => {
+ if (token.type === 'code') return closedCodeFence(token.raw);
+ if (
+ token.type === 'link' &&
+ !(
+ (token.raw.startsWith('[') && token.raw.endsWith(')')) ||
+ (token.raw.startsWith('<') && token.raw.endsWith('>'))
+ )
+ )
+ return false;
+ if (token.type === 'list') return (token as Tokens.List).items.every((item) => completeMarkdownTokens(item.tokens));
+ if (token.type === 'table') {
+ const table = token as Tokens.Table;
+ return [...table.header, ...table.rows.flat()].every((cell) => completeMarkdownTokens(cell.tokens));
+ }
+ return !('tokens' in token) || !Array.isArray(token.tokens) || completeMarkdownTokens(token.tokens);
+ });
+}
+function alignCell(cell: HTMLElement, alignment: unknown): void {
+ if (alignment === 'left' || alignment === 'center' || alignment === 'right') cell.style.textAlign = alignment;
+}
/** Render Markdown tokens into DOM. No model-supplied HTML is parsed or executed. */
-export function renderMarkdown(target: HTMLElement, text: string): void {
+export function renderMarkdown(target: HTMLElement, text: string, presentation?: MarkdownPresentation): void {
+ let owner = presentation ?? presentations.get(target);
+ if (!owner) {
+ owner = new MarkdownPresentation(target);
+ presentations.set(target, owner);
+ }
+ owner.releaseTree(target);
+ target.classList.add('cui-markdown');
target.replaceChildren();
- renderMarkdownTokens(target, Lexer.lex(text));
+ renderMarkdownTokens(target, Lexer.lex(text), false, owner);
+ queueMicrotask(() => owner.reconcile());
}
-export function renderMarkdownTokens(parent: Node, tokens: Token[], provisional = false): void {
+export function renderMarkdownTokens(
+ parent: Node,
+ tokens: Token[],
+ provisional = false,
+ presentation?: MarkdownPresentation,
+): void {
for (const token of tokens) {
if (token.type === 'space') continue;
if (token.type === 'text' || token.type === 'escape') {
const value = token as Tokens.Text;
- if (value.tokens) renderMarkdownTokens(parent, value.tokens, provisional);
+ if (value.tokens) renderMarkdownTokens(parent, value.tokens, provisional, presentation);
else parent.appendChild(document.createTextNode(value.text));
continue;
}
@@ -20,7 +63,7 @@ export function renderMarkdownTokens(parent: Node, tokens: Token[], provisional
if (list.ordered && typeof list.start === 'number') el.setAttribute('start', String(list.start));
for (const item of list.items) {
const li = document.createElement('li');
- renderMarkdownTokens(li, item.tokens, provisional);
+ renderMarkdownTokens(li, item.tokens, provisional, presentation);
el.append(li);
}
parent.appendChild(el);
@@ -31,30 +74,43 @@ export function renderMarkdownTokens(parent: Node, tokens: Token[], provisional
const wrapper = document.createElement('div');
wrapper.className = 'cui-table';
wrapper.tabIndex = 0;
+ presentation?.table(wrapper);
const el = document.createElement('table');
const head = el.createTHead().insertRow();
- for (const cell of table.header) {
+ for (const [index, cell] of table.header.entries()) {
const th = document.createElement('th');
th.scope = 'col';
- renderMarkdownTokens(th, cell.tokens, provisional);
+ alignCell(th, table.align[index]);
+ renderMarkdownTokens(th, cell.tokens, provisional, presentation);
head.append(th);
}
const body = el.createTBody();
for (const row of table.rows) {
const tr = body.insertRow();
- for (const cell of row) renderMarkdownTokens(tr.insertCell(), cell.tokens, provisional);
+ for (const [index, cell] of row.entries()) {
+ const td = tr.insertCell();
+ alignCell(td, table.align[index]);
+ renderMarkdownTokens(td, cell.tokens, provisional, presentation);
+ }
}
wrapper.append(el);
parent.appendChild(wrapper);
continue;
}
if (token.type === 'code') {
- const pre = document.createElement('pre');
- pre.tabIndex = 0;
- const code = document.createElement('code');
- code.textContent = (token as Tokens.Code).text;
- pre.append(code);
- parent.appendChild(pre);
+ const value = token as Tokens.Code;
+ // A closing fence is required during streaming; an unfinished fence remains plain.
+ const fence = value.raw.match(/^ {0,3}(`{3,}|~{3,})[^\n]*\n/);
+ const closed = closedCodeFence(value.raw);
+ if (presentation) parent.appendChild(presentation.code(value.text, value.lang, fence ? closed : !provisional));
+ else {
+ const pre = document.createElement('pre');
+ pre.tabIndex = 0;
+ const code = document.createElement('code');
+ code.textContent = value.text;
+ pre.append(code);
+ parent.appendChild(pre);
+ }
continue;
}
if (token.type === 'html' || token.type === 'image') {
@@ -73,14 +129,14 @@ export function renderMarkdownTokens(parent: Node, tokens: Token[], provisional
}
// Explicitly allow only web/mail links. No relative, data or javascript URLs.
if (!/^(https?:\/\/|mailto:)/i.test(link.href)) {
- renderMarkdownTokens(parent, link.tokens, provisional);
+ renderMarkdownTokens(parent, link.tokens, provisional, presentation);
continue;
}
const a = document.createElement('a');
a.href = link.href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
- renderMarkdownTokens(a, link.tokens, provisional);
+ renderMarkdownTokens(a, link.tokens, provisional, presentation);
parent.appendChild(a);
continue;
}
@@ -100,7 +156,8 @@ export function renderMarkdownTokens(parent: Node, tokens: Token[], provisional
continue;
}
const el = document.createElement(tag);
- if ('tokens' in token && Array.isArray(token.tokens)) renderMarkdownTokens(el, token.tokens, provisional);
+ if ('tokens' in token && Array.isArray(token.tokens))
+ renderMarkdownTokens(el, token.tokens, provisional, presentation);
else if ('text' in token) el.textContent = String(token.text);
parent.appendChild(el);
}
diff --git a/packages/chat-ui/src/streaming-markdown.ts b/packages/chat-ui/src/streaming-markdown.ts
index f5096115c..4d2b11b22 100644
--- a/packages/chat-ui/src/streaming-markdown.ts
+++ b/packages/chat-ui/src/streaming-markdown.ts
@@ -1,5 +1,6 @@
import { Lexer, type Token, type TokensList } from 'marked';
-import { renderMarkdownTokens } from './markdown';
+import { completeMarkdownTokens, renderMarkdownTokens } from './markdown';
+import { MarkdownPresentation } from './markdown-presentation';
/** Input bound, not a promise that a synchronous lexer can be preempted. */
export const STREAMING_MARKDOWN_LIMIT = 8000;
@@ -23,7 +24,12 @@ export class StreamingMarkdown {
private label = false;
private bracket = false;
private escaped = false;
- constructor(private readonly target: HTMLElement) {}
+ constructor(
+ private readonly target: HTMLElement,
+ private readonly presentation = new MarkdownPresentation(target),
+ ) {
+ target.classList.add('cui-markdown');
+ }
update(source: string, complete = false): void {
if (source === this.source && !complete) return;
@@ -160,7 +166,12 @@ export class StreamingMarkdown {
const block = blocks[index];
const old = previous[index];
const key = block.tokens ? JSON.stringify(block.tokens) : undefined;
- if (old?.nodes && old.raw === block.raw && old.key === key && old.provisional === provisional) {
+ if (
+ old?.nodes &&
+ old.raw === block.raw &&
+ old.key === key &&
+ (old.provisional === provisional || (block.tokens && completeMarkdownTokens(block.tokens)))
+ ) {
block.nodes = old.nodes;
} else if (!block.tokens && !old?.tokens && old?.nodes && block.raw.startsWith(old.raw)) {
const text = old.nodes[0].firstChild as Text;
@@ -168,7 +179,7 @@ export class StreamingMarkdown {
block.nodes = old.nodes;
} else {
const fragment = document.createDocumentFragment();
- if (block.tokens) renderMarkdownTokens(fragment, block.tokens, provisional);
+ if (block.tokens) renderMarkdownTokens(fragment, block.tokens, provisional, this.presentation);
else {
const raw = document.createElement('span');
raw.className = 'cui-stream-raw';
@@ -192,5 +203,6 @@ export class StreamingMarkdown {
}
while (this.target.childNodes.length > index) this.target.lastChild!.remove();
this.blocks = [...prefix, ...blocks];
+ this.presentation.reconcile();
}
}
diff --git a/packages/chat-ui/src/styles.ts b/packages/chat-ui/src/styles.ts
index 47ea46e68..5c0116ecf 100644
--- a/packages/chat-ui/src/styles.ts
+++ b/packages/chat-ui/src/styles.ts
@@ -5,25 +5,26 @@ import { Style } from 'ranui/builder';
* (so consumers don't need a separate CSS import). All classes are `cui-`
* prefixed and variables are overridable via the `.cui-root` scope.
*
- * Visual language follows modern AI chat UIs: bubble-less, full-width assistant
- * text for readability; a compact accent bubble for the user; a rounded composer
+ * Visual language follows modern AI chat UIs: bubble-less, measured assistant
+ * text for readability; a compact neutral bubble for the user; a rounded composer
* with aligned send and stop controls; subtle tool/error chips.
*/
export const CHAT_UI_CSS = `
-.cui-root { --cui-control-touch:44px; --cui-accent:var(--ran-color-primary, #171717); --cui-accent-contrast:var(--ran-color-primary-text, #fff); --cui-bg:var(--ran-color-bg, #fff); --cui-user-bg:var(--ran-color-bg-muted, #f4f4f4); --cui-text:var(--ran-color-text, #171717); --cui-muted:var(--ran-color-text-secondary, #737373); --cui-border:var(--ran-color-border, #e5e5e5); --cui-surface:var(--ran-color-bg-subtle, var(--cui-user-bg)); position:relative; display:flex; flex-direction:column; min-height:0; height:100%; background:var(--cui-bg); color:var(--cui-text); font-family:var(--ran-font-family, system-ui, sans-serif); font-size:var(--ran-text-copy-2, 14px); line-height:1.7; -webkit-font-smoothing:antialiased; }
-.cui-messages { flex:1 1 auto; min-height:0; overflow:auto; overflow-x:hidden; padding:var(--ran-space-6, 24px) var(--ran-space-4, 16px); display:flex; flex-direction:column; gap:var(--ran-space-6, 24px); overscroll-behavior:contain; scrollbar-width:thin; }
+.cui-root { --cui-content-max-width:680px; --cui-prose-max-width:68ch; --cui-content-gutter:var(--ran-space-6, 24px); --cui-content-inset:max(var(--cui-content-gutter), calc((100% - var(--cui-content-max-width)) / 2)); --cui-control-touch:44px; --cui-accent:var(--ran-color-primary, #171717); --cui-accent-contrast:var(--ran-color-primary-text, #fff); --cui-bg:var(--ran-color-bg, #fff); --cui-user-bg:var(--ran-color-bg-muted, #f4f4f4); --cui-text:var(--ran-color-text, #171717); --cui-muted:var(--ran-color-text-secondary, #737373); --cui-border:var(--ran-color-border, #e5e5e5); --cui-surface:var(--ran-color-bg-subtle, var(--cui-user-bg)); position:relative; display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; container:cui-chat / inline-size; background:var(--cui-bg); color:var(--cui-text); font-family:var(--ran-font-family, system-ui, sans-serif); font-size:var(--ran-text-copy-1, 16px); line-height:1.75; -webkit-font-smoothing:antialiased; }
+.cui-messages { flex:1 1 auto; min-height:0; overflow:auto; overflow-x:hidden; box-sizing:border-box; padding:var(--ran-space-6, 24px) var(--cui-content-inset); display:flex; flex-direction:column; gap:var(--ran-space-4, 16px); overscroll-behavior:contain; scrollbar-width:thin; }
.cui-load-earlier, .cui-history-expand { align-self:flex-start; max-width:100%; padding:var(--ran-space-2, 8px) var(--ran-space-3, 12px); border:1px solid var(--cui-border); border-radius:var(--ran-radius-sm, 6px); background:transparent; color:var(--cui-muted); font:inherit; font-size:var(--ran-text-label-3, 12px); cursor:pointer; }
.cui-load-earlier:focus-visible, .cui-history-expand:focus-visible { outline:2px solid var(--ran-color-link, currentColor); outline-offset:2px; }
.cui-history-expand { display:block; margin-top:var(--ran-space-2, 8px); }
.cui-load-earlier[hidden] { display:none; }
-.cui-empty { margin:auto; max-width:260px; padding:var(--ran-space-8, 32px) var(--ran-space-2, 8px); text-align:left; font-size:var(--ran-text-heading-3, 20px); font-weight:500; line-height:1.45; letter-spacing:-.035em; color:var(--cui-text); }
+.cui-empty { box-sizing:border-box; width:100%; margin:auto 0; max-width:var(--cui-content-max-width); padding:var(--ran-space-8, 32px) 0; text-align:left; font-size:var(--ran-text-heading-3, 20px); font-weight:500; line-height:1.45; letter-spacing:-.035em; color:var(--cui-text); }
.cui-empty-actions { margin-top:var(--ran-space-4, 16px); font-size:var(--ran-text-label-3, 12px); letter-spacing:0; font-weight:400; }
.cui-empty-actions button { font:inherit; color:var(--cui-muted); border:1px solid var(--cui-border); background:transparent; padding:var(--ran-space-1, 4px) var(--ran-space-3, 12px); min-height:var(--ran-size-7, 32px); border-radius:var(--ran-radius-sm, 6px); cursor:pointer; }
-.cui-msg { display:flex; flex-direction:column; gap:var(--ran-space-2, 8px); min-width:0; }
+.cui-msg { display:flex; flex-direction:column; gap:var(--ran-space-3, 12px); min-width:0; flex:0 0 auto; }
.cui-bubble { white-space:pre-wrap; overflow-wrap:anywhere; }
-.cui-msg-user { align-self:flex-end; max-width:88%; }
-.cui-msg-user .cui-bubble { background:var(--cui-user-bg); padding:var(--ran-space-2, 8px) var(--ran-space-3, 12px); border-radius:var(--ran-radius-lg, 16px); }
-.cui-msg-agent { align-self:stretch; }
+.cui-msg-user { align-self:flex-end; max-width:min(88%, var(--cui-prose-max-width)); margin-block-start:0; }
+.cui-msg ~ .cui-msg-user { margin-block-start:var(--ran-space-4, 16px); }
+.cui-msg-user .cui-bubble { background:var(--cui-user-bg); padding:var(--ran-space-3, 12px) var(--ran-space-4, 16px); border-radius:var(--ran-radius-md, 12px) var(--ran-radius-md, 12px) var(--ran-radius-sm, 6px) var(--ran-radius-md, 12px); line-height:1.6; }
+.cui-msg-agent { align-self:stretch; width:100%; }
/* Host-marked drafts keep their identity header and full reading width.
Review receipts keep their separate disclosure without nesting surfaces. */
.cui-msg-agent[data-document-artifact] { box-sizing:border-box; padding:0; gap:var(--ran-space-3, 12px); border:0; border-radius:0; background:transparent; }
@@ -33,33 +34,28 @@ export const CHAT_UI_CSS = `
.cui-result-label { overflow-wrap:anywhere; }
.cui-msg-agent[data-document-artifact] .cui-message-actions { border-top:1px solid var(--cui-border); padding-top:var(--ran-space-2, 8px); }
.cui-msg-agent[data-interrupted] .cui-result-icon { opacity:.65; }
-.cui-msg-agent .cui-bubble { white-space:normal; }
+.cui-msg-agent .cui-bubble { white-space:normal; line-height:1.75; }
.cui-bubble.cui-stream-raw,.cui-stream-raw { white-space:pre-wrap; }
-.cui-bubble p { margin:0 0 var(--ran-space-3, 12px); }
+.cui-bubble > :is(p,h2,h3,h4,h5,h6,ul,ol,blockquote) { max-inline-size:var(--cui-prose-max-width, 68ch); }
+.cui-bubble p { margin:0 0 var(--ran-space-4, 16px); }
.cui-bubble p:last-child { margin-bottom:0; }
-.cui-bubble h2,.cui-bubble h3,.cui-bubble h4,.cui-bubble h5,.cui-bubble h6 { font-size:inherit; line-height:1.4; margin:var(--ran-space-6, 24px) 0 var(--ran-space-2, 8px); font-weight:var(--ran-text-heading-weight, 600); text-wrap:pretty; }
+.cui-bubble h2,.cui-bubble h3,.cui-bubble h4,.cui-bubble h5,.cui-bubble h6 { font-size:inherit; line-height:1.4; margin:var(--ran-space-8, 32px) 0 var(--ran-space-2, 8px); font-weight:var(--ran-text-heading-weight, 600); text-wrap:pretty; }
.cui-bubble h2 { font-size:var(--ran-text-heading-3, 20px); letter-spacing:var(--ran-text-heading-tracking, -.03em); }
.cui-bubble h3 { font-size:var(--ran-text-heading-4, 16px); }
+.cui-bubble :is(h4,h5,h6) { font-size:inherit; }
.cui-bubble :is(h2,h3,h4,h5,h6):first-child { margin-top:0; }
-.cui-bubble ul,.cui-bubble ol { padding-left:var(--ran-space-6, 24px); margin:var(--ran-space-2, 8px) 0 var(--ran-space-3, 12px); }
+.cui-bubble ul,.cui-bubble ol { box-sizing:border-box; padding-inline-start:var(--ran-space-6, 24px); margin:0 0 var(--ran-space-4, 16px); }
.cui-bubble li { padding-left:calc(var(--ran-space-1, 4px) / 2); margin:var(--ran-space-1, 4px) 0; }
-.cui-bubble blockquote { margin:var(--ran-space-3, 12px) 0; border-left:2px solid var(--cui-border); padding-left:var(--ran-space-3, 12px); color:var(--cui-muted); }
+.cui-bubble blockquote { box-sizing:border-box; margin:var(--ran-space-4, 16px) 0; border-left:2px solid var(--cui-border); padding-left:var(--ran-space-3, 12px); color:var(--cui-muted); }
.cui-bubble code { font-family:var(--ran-font-mono, ui-monospace, monospace); font-size:.88em; background:var(--cui-user-bg); padding:calc(var(--ran-space-1, 4px) / 2) var(--ran-space-1, 4px); border-radius:var(--ran-radius-sm, 6px); }
-.cui-bubble pre { margin:var(--ran-space-3, 12px) 0; border:1px solid var(--cui-border); background:var(--cui-user-bg); padding:var(--ran-space-3, 12px); border-radius:var(--ran-radius-sm, 6px); overflow:auto; white-space:pre; line-height:1.55; }
-.cui-bubble pre code { padding:0; background:none; }
.cui-bubble > :first-child { margin-top:0; }
.cui-bubble > :last-child { margin-bottom:0; }
.cui-bubble li > p { margin:0 0 var(--ran-space-2, 8px); }
.cui-bubble li > :last-child { margin-bottom:0; }
.cui-bubble li > :is(ul,ol) { margin-bottom:0; }
-.cui-bubble pre,.cui-table { max-width:100%; box-sizing:border-box; overscroll-behavior-x:contain; }
+.cui-bubble hr { width:100%; margin:var(--ran-space-6, 24px) 0; border:0; border-top:1px solid var(--cui-border); }
.cui-bubble a { color:var(--cui-text); text-underline-offset:3px; }
-.cui-table { overflow:auto; margin:var(--ran-space-3, 12px) 0; border:1px solid var(--cui-border); border-radius:var(--ran-radius-sm, 6px); }
-.cui-table table { border-collapse:collapse; font-size:inherit; min-width:100%; overflow-wrap:normal; }
-.cui-table th,.cui-table td { border-bottom:1px solid var(--cui-border); padding:var(--ran-space-2, 8px) var(--ran-space-3, 12px); text-align:left; vertical-align:top; }
-.cui-table th { background:var(--cui-user-bg); font-weight:var(--ran-text-label-weight, 500); font-size:var(--ran-text-label-2, 13px); }
-.cui-table tr:last-child td { border-bottom:0; }
-.cui-message-actions { display:flex; align-items:center; flex-wrap:wrap; gap:var(--ran-space-2, 8px); }
+.cui-message-actions { display:flex; align-items:center; flex-wrap:wrap; gap:var(--ran-space-1, 4px); min-width:0; }
.cui-message-actions button { border:0; background:transparent; color:var(--cui-muted); cursor:pointer; font:inherit; font-size:var(--ran-text-label-3, 12px); min-height:var(--ran-size-7, 32px); padding:var(--ran-space-1, 4px) var(--ran-space-2, 8px); border-radius:var(--ran-radius-sm, 6px); }
.cui-message-actions button:hover:not(:disabled) { color:var(--cui-text); background:var(--cui-user-bg); }
.cui-message-actions button:disabled { opacity:.5; cursor:default; }
@@ -74,48 +70,54 @@ export const CHAT_UI_CSS = `
.cui-streaming .cui-message-actions { display:none; }
.cui-streaming .cui-bubble { white-space:pre-wrap; }
.cui-streaming.cui-streaming-markdown .cui-bubble { white-space:normal; }
-.cui-activity { box-sizing:border-box; color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); line-height:1.6; padding:var(--ran-space-1, 4px) 0 var(--ran-space-1, 4px) var(--ran-space-3, 12px); border-left:1px solid var(--cui-border); white-space:pre-wrap; overflow-wrap:anywhere; }
+.cui-activity { box-sizing:border-box; flex:0 0 auto; max-width:100%; color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); line-height:1.6; padding:var(--ran-space-1, 4px) 0 var(--ran-space-1, 4px) var(--ran-space-3, 12px); border-left:1px solid var(--cui-border); white-space:pre-wrap; overflow-wrap:anywhere; }
.cui-activity summary { cursor:pointer; padding:var(--ran-space-1, 4px) 0; border-radius:var(--ran-radius-sm, 6px); }
.cui-activity ul { list-style:none; margin:var(--ran-space-1, 4px) 0 0; padding-left:var(--ran-space-4, 16px); }
.cui-activity li { padding:var(--ran-space-1, 4px) 0; }
.cui-msg-status { color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); }
-.cui-msg-error .cui-bubble { padding:var(--ran-space-3, 12px); border:1px solid var(--cui-border); border-radius:var(--ran-radius-sm, 6px); font-size:var(--ran-text-label-2, 13px); }
-.cui-progress { flex:0 0 auto; display:flex; align-items:baseline; gap:var(--ran-space-3, 12px); margin:0 var(--ran-space-4, 16px); padding:var(--ran-space-2, 8px) 0; min-width:0; }
+.cui-msg-error .cui-bubble { padding:var(--ran-space-2, 8px) var(--ran-space-3, 12px); border-left:2px solid var(--cui-border); border-radius:0 var(--ran-radius-sm, 6px) var(--ran-radius-sm, 6px) 0; background:var(--cui-surface); font-size:var(--ran-text-label-2, 13px); line-height:1.6; }
+.cui-reasoning { min-width:0; max-width:100%; flex:0 0 auto; --ran-reasoning-color:var(--cui-muted); --ran-reasoning-font-size:var(--ran-text-label-2, 13px); --ran-reasoning-border-color:var(--cui-border); --ran-disclosure-color:var(--cui-muted); --ran-disclosure-summary-color:var(--cui-muted); --ran-disclosure-font-size:var(--ran-text-label-2, 13px); --ran-disclosure-row-height:var(--ran-size-7, 32px); --ran-disclosure-transition:var(--ran-motion-duration-fast, 150ms); --ran-disclosure-toggle-duration:var(--ran-motion-duration-base, 200ms); }
+.cui-reasoning-notice { margin:var(--ran-space-1, 4px) 0 0 var(--ran-space-3, 12px); color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); }
+.cui-progress { flex:0 0 auto; display:flex; align-items:baseline; gap:var(--ran-space-3, 12px); margin:0; padding:var(--ran-space-2, 8px) var(--cui-content-inset); min-width:0; }
.cui-status { flex:1; min-width:0; display:flex; align-items:center; gap:var(--ran-space-2, 8px); color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); line-height:1.5; }
.cui-elapsed { flex:0 0 auto; margin-left:auto; color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); line-height:1.5; font-variant-numeric:tabular-nums; white-space:nowrap; }
.cui-progress[hidden],.cui-elapsed[hidden] { display:none; }
.cui-status:empty { display:none; }
-/* One activity cue at a time: the waiting row gives way to the live-text
- cursor when responding. A human review is a resting state, not loading. */
+/* One activity cue at a time: the waiting row gives way to provider reasoning
+ or the answer when responding. A human review is a resting state, not loading. */
.cui-root:is([data-activity="responding"],[data-activity="routing"],[data-activity="reading"],[data-activity="planning"]) .cui-status::before { content:''; flex:0 0 auto; width:12px; height:12px; box-sizing:border-box; border:1.5px solid var(--cui-border); border-top-color:currentColor; border-right-color:currentColor; border-radius:50%; animation:cui-loading-turn 1s linear infinite; }
-.cui-footer { position:relative; flex:0 0 auto; padding:var(--ran-space-3, 12px) var(--ran-space-4, 16px) var(--ran-space-4, 16px); }
+.cui-footer { position:relative; flex:0 0 auto; padding:var(--ran-space-3, 12px) var(--cui-content-inset) var(--ran-space-4, 16px); }
.cui-scroll-bottom { position:absolute; top:-40px; left:50%; transform:translateX(-50%); width:var(--ran-size-7, 32px); height:var(--ran-size-7, 32px); box-sizing:border-box; padding:0; display:flex; align-items:center; justify-content:center; border:1px solid var(--cui-border); border-radius:50%; background:var(--cui-bg); color:var(--cui-muted); cursor:pointer; box-shadow:var(--ran-shadow-elevated, none); }
.cui-scroll-bottom[hidden] { display:none; }
-.cui-composer { display:flex; flex-direction:column; padding:var(--ran-space-3, 12px); border:1px solid var(--cui-border); border-radius:var(--ran-radius-md, 12px); background:var(--cui-bg); }
+.cui-composer { display:flex; flex-direction:column; min-width:0; gap:var(--ran-space-3, 12px); padding:var(--ran-space-3, 12px); border:1px solid var(--cui-border); border-radius:var(--ran-radius-md, 12px); background:var(--cui-bg); }
.cui-pending:empty { display:none; }
.cui-stop[hidden] { display:none; }
.cui-root .cui-stop { background:transparent; color:var(--cui-text); border:0; }
-.cui-composer:focus-within { border-color:var(--cui-muted); }
-.cui-composer-bar { display:flex; align-items:center; gap:var(--ran-space-2, 8px); margin-top:var(--ran-space-3, 12px); }
+.cui-composer:focus-within { border-color:var(--cui-muted); outline:1px solid var(--cui-border); outline-offset:0; }
+.cui-composer-bar { display:flex; align-items:center; min-width:0; min-height:var(--ran-size-7, 32px); gap:var(--ran-space-2, 8px); }
.cui-actions { display:flex; flex:1; min-width:0; align-items:center; gap:var(--ran-space-1, 4px); font-size:var(--ran-text-label-3, 12px); flex-wrap:wrap; }
.cui-context:empty { display:none; }
-.cui-input { min-width:0; width:100%; box-sizing:border-box; resize:none; overflow-y:auto; border:0; outline:none; background:transparent; padding:0; font:inherit; line-height:1.5; color:inherit; max-height:160px; min-height:1.5em; }
+.cui-input { min-width:0; width:100%; box-sizing:border-box; resize:none; overflow-y:auto; border:0; outline:none; background:transparent; padding:0; font:inherit; line-height:1.5; color:inherit; max-height:160px; min-height:3em; scrollbar-width:thin; }
.cui-input::placeholder { color:var(--cui-muted); }
-.cui-send, .cui-stop { flex:0 0 auto; width:var(--ran-size-7, 32px); height:var(--ran-size-7, 32px); box-sizing:border-box; padding:0; display:flex; align-items:center; justify-content:center; border:0; border-radius:var(--ran-radius-sm, 6px); cursor:pointer; background:var(--cui-accent); color:var(--cui-accent-contrast);; }
+.cui-send, .cui-stop { flex:0 0 auto; width:var(--ran-size-7, 32px); height:var(--ran-size-7, 32px); box-sizing:border-box; padding:0; display:flex; align-items:center; justify-content:center; border:0; border-radius:var(--ran-radius-sm, 6px); cursor:pointer; background:var(--cui-accent); color:var(--cui-accent-contrast); transition:scale var(--ran-motion-duration-fast, 150ms) var(--ran-motion-ease-standard, ease); }
+.cui-send:active:not(:disabled), .cui-stop:active:not(:disabled) { scale:.94; }
.cui-send:hover:not(:disabled) { background:color-mix(in srgb, var(--cui-accent) 85%, var(--cui-bg)); }
.cui-root .cui-stop:hover:not(:disabled) { background:var(--cui-user-bg); border-color:var(--cui-muted); }
.cui-send:disabled, .cui-stop:disabled { background:var(--cui-surface); color:var(--cui-muted); opacity:.5; cursor:default; }
@media (pointer:coarse) {
+ .cui-reasoning { --ran-disclosure-row-height:var(--cui-control-touch); }
.cui-send, .cui-stop, .cui-scroll-bottom { width:var(--cui-control-touch); height:var(--cui-control-touch); }
.cui-message-actions button, .cui-empty-actions button, .cui-load-earlier, .cui-history-expand { min-height:var(--cui-control-touch); }
.cui-message-actions .cui-copy { width:var(--cui-control-touch); flex-basis:var(--cui-control-touch); }
}
-.cui-root .cui-send.cui-send-prepare { width:auto; max-width:100%; padding:0 var(--ran-space-2, 8px); font:inherit; font-size:var(--ran-text-label-3, 12px); background:transparent; color:var(--cui-text); text-decoration:underline; text-underline-offset:3px; }
+.cui-root .cui-send.cui-send-prepare { width:auto; max-width:100%; padding:0 var(--ran-space-2, 8px); font:inherit; font-size:var(--ran-text-label-3, 12px); background:transparent; color:var(--cui-text); border:1px solid var(--cui-border); }
.cui-streaming:not(.cui-streaming-markdown) .cui-bubble::after { content:''; display:inline-block; width:6px; height:6px; margin-left:var(--ran-space-1, 4px); border-radius:50%; background:currentColor; vertical-align:middle; }
-.cui-root[data-activity="responding"] .cui-streaming:not(.cui-streaming-markdown) .cui-bubble::after { animation:cui-answer-pulse 1.4s ease-in-out infinite; }
@keyframes cui-loading-turn { to { transform:rotate(360deg); } }
-@keyframes cui-answer-pulse { 0%,100% { opacity:.45; transform:scale(.85); } 50% { opacity:1; transform:scale(1.15); } }
+@container cui-chat (max-width:380px) {
+ .cui-messages, .cui-footer, .cui-progress { --cui-content-inset:var(--ran-space-4, 16px); }
+}
@media (prefers-reduced-motion:reduce) {
+ .cui-send, .cui-stop { transition:none; scale:1; }
.cui-root[data-activity] .cui-status::before,.cui-root[data-activity] .cui-streaming .cui-bubble::after { animation:none; transform:none; opacity:1; }
}
`;
diff --git a/packages/chat-ui/src/types.ts b/packages/chat-ui/src/types.ts
index 4cb722df3..5d58be711 100644
--- a/packages/chat-ui/src/types.ts
+++ b/packages/chat-ui/src/types.ts
@@ -16,6 +16,10 @@ export interface ChatMessage {
/** Text shown in the UI. Everything is optional so the component works untranslated. */
export interface ChatViewLabels {
+ /** Accessible name for the draft/context/control boundary. */
+ composer?: string;
+ /** Accessible name for the controls beside Send/Stop. */
+ actions?: string;
/** Accessible name for the conversation history. */
conversation?: string;
/** Send-button label (idle state). */
@@ -34,6 +38,9 @@ export interface ChatViewLabels {
savingMessage?: string;
savedMessage?: string;
saveFailed?: string;
+ /** Accessible names for rendered code and horizontally scrollable tables. */
+ codeBlock?: string;
+ table?: string;
copy?: string;
copied?: string;
copyFailed?: string;
@@ -42,6 +49,12 @@ export interface ChatViewLabels {
documentDraft?: string;
/** Identifies retained output from a response that did not complete. */
incomplete?: string;
+ /** Provider-exposed, ephemeral reasoning disclosure. */
+ reasoning?: string;
+ /** Reasoning that stopped before its inference phase completed. */
+ reasoningInterrupted?: string;
+ /** One notice when a run exceeds the 64 KiB UTF-8 display budget. */
+ reasoningTruncated?: string;
waiting?: string;
/** Actual host request classification, distinct from tool planning. */
routing?: string;
diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts
index 38c6c5aa4..9c0af70bd 100644
--- a/packages/shared/src/i18n/messages/de.ts
+++ b/packages/shared/src/i18n/messages/de.ts
@@ -92,8 +92,11 @@ export const de: Partial = {
agentUseServiceHint:
'Gesendeter Text geht an den gewählten Dienst. Eigene Zugangsdaten sind erforderlich; es können Kosten entstehen.',
agentPreferences: 'Weitere Einstellungen',
+ agentWorkspaceChat: 'Unterhaltung',
agentBackToChat: 'Zurück zum Gespräch',
agentExpand: 'Assistent vergrößern',
+ agentCollapse: 'Assistent verkleinern',
+ agentVoiceOptions: 'Sprachoptionen',
agentSetupFailed: 'Assistent konnte nicht geöffnet werden. Bitte erneut versuchen. Das Dokument ist unverändert.',
agentOffNote: 'Ausschalten stoppt die aktuelle Aufgabe und behält Dokument und Entwurf.',
agentResumeModel: 'Heruntergeladenes Modell verwenden',
@@ -397,12 +400,19 @@ export const de: Partial = {
'Hebe die Auswahl auf, um den aktuellen Absatz auszurichten, oder fordere die Ausrichtung der Auswahl an.',
agentNoSelection: 'Keine Auswahl erkannt – bitte zuerst Text im Dokument markieren.',
agentQuotePrefix: 'Bitte berücksichtige den von mir markierten Inhalt:',
+ agentCodeBlock: 'Quellcode',
+ agentMarkdownTable: 'Tabelle',
agentCopy: 'Kopieren',
agentCopied: 'Kopiert',
agentCopyFailed: 'Kopieren fehlgeschlagen',
agentRestoreRequest: 'Anfrage wiederherstellen',
agentRoutingRequest: 'Anfrage wird eingeordnet…',
agentWaitElapsed: '{seconds} s vergangen',
+ agentComposerLabel: 'Nachricht verfassen',
+ agentComposerActionsLabel: 'Nachrichtenaktionen',
+ agentReasoning: 'Überlegungen',
+ agentReasoningInterrupted: 'Überlegungen unterbrochen',
+ agentReasoningTruncated: 'Überlegungen gekürzt',
agentWaiting: 'Antwort wird erstellt…',
agentReadingContent: 'Aktueller Inhalt wird gelesen…',
agentPreparingTask: 'Anfrage wird vorbereitet…',
@@ -527,10 +537,14 @@ export const de: Partial = {
agentSourcesSearch: 'Quellen durchsuchen',
agentSourcesOpen: 'Quelle öffnen',
agentSourcesExcerpt: 'Quellen werden beim Senden als begrenzte Auszüge an den gewählten KI-Dienst gesendet.',
+ agentArtifactEdit: 'Bearbeiten',
+ agentArtifactCancelEditing: 'Bearbeitung abbrechen',
+ agentArtifactSourceData: 'Quelldaten',
+ agentArtifactUnsaved: 'Ungespeicherte Änderungen',
agentArtifactTitle: 'Artefakte',
agentArtifactEmpty: 'Nützliche Ergebnisse hier speichern, bearbeiten und lokal versionieren.',
agentArtifactBack: 'Zurück',
- agentArtifactBackToList: 'Zurück zu den Ergebnissen',
+ agentArtifactBackToList: 'Zurück zu den Artefakten',
agentArtifactTitleField: 'Titel',
agentArtifactTextField: 'Inhalt',
agentArtifactVersion: 'Version',
diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts
index 6df0965c5..f72b2f6fd 100644
--- a/packages/shared/src/i18n/messages/en.ts
+++ b/packages/shared/src/i18n/messages/en.ts
@@ -86,8 +86,11 @@ export const en: I18nMessages = {
agentUseServiceHint:
'Text you send goes to the selected service. Your own service details are required; service fees may apply.',
agentPreferences: 'More settings',
+ agentWorkspaceChat: 'Chat',
agentBackToChat: 'Back to conversation',
agentExpand: 'Expand assistant',
+ agentCollapse: 'Collapse assistant',
+ agentVoiceOptions: 'Voice options',
agentSetupFailed: 'Could not open the assistant. Try again; your document is unchanged.',
agentOffNote: 'Turning off stops the current task and keeps your document and draft.',
agentResumeModel: 'Use downloaded model',
@@ -376,12 +379,19 @@ export const en: I18nMessages = {
agentParagraphSelectionConflict: 'Clear the selection to align the current paragraph, or ask to align the selection.',
agentNoSelection: 'No selection detected — please select text in the document first.',
agentQuotePrefix: 'Please consider my selected content:',
+ agentCodeBlock: 'Code',
+ agentMarkdownTable: 'Table',
agentCopy: 'Copy',
agentCopied: 'Copied',
agentCopyFailed: 'Could not copy',
agentRestoreRequest: 'Restore request',
agentRoutingRequest: 'Understanding your request…',
agentWaitElapsed: '{seconds}s elapsed',
+ agentComposerLabel: 'Message composer',
+ agentComposerActionsLabel: 'Message actions',
+ agentReasoning: 'Reasoning',
+ agentReasoningInterrupted: 'Reasoning interrupted',
+ agentReasoningTruncated: 'Reasoning truncated',
agentWaiting: 'Generating a response…',
agentReadingContent: 'Reading current content…',
agentPreparingTask: 'Preparing your request…',
@@ -500,6 +510,10 @@ export const en: I18nMessages = {
agentSourcesSearch: 'Search sources',
agentSourcesOpen: 'Open source',
agentSourcesExcerpt: 'Sources are sent as bounded excerpts to the selected AI service when you send a request.',
+ agentArtifactEdit: 'Edit',
+ agentArtifactCancelEditing: 'Cancel editing',
+ agentArtifactSourceData: 'Source data',
+ agentArtifactUnsaved: 'Unsaved changes',
agentArtifactTitle: 'Artifacts',
agentArtifactEmpty: 'Save useful results here to edit and keep versions on this device.',
agentArtifactBack: 'Back',
diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts
index ded8e467d..72621f8f6 100644
--- a/packages/shared/src/i18n/messages/es.ts
+++ b/packages/shared/src/i18n/messages/es.ts
@@ -89,8 +89,11 @@ export const es: Partial = {
agentUseServiceHint:
'El texto enviado se procesa en el servicio elegido. Necesitas tus datos de servicio; puede haber cargos.',
agentPreferences: 'Más preferencias',
+ agentWorkspaceChat: 'Conversación',
agentBackToChat: 'Volver a la conversación',
agentExpand: 'Ampliar asistente',
+ agentCollapse: 'Reducir asistente',
+ agentVoiceOptions: 'Opciones de voz',
agentSetupFailed: 'No se pudo abrir el asistente. Inténtalo de nuevo. El documento no ha cambiado.',
agentOffNote: 'Desactivar detiene la tarea actual y conserva el documento y el borrador.',
agentResumeModel: 'Usar modelo descargado',
@@ -385,12 +388,19 @@ export const es: Partial = {
agentParagraphSelectionConflict: 'Quita la selección para alinear el párrafo actual, o pide alinear la selección.',
agentNoSelection: 'No hay nada seleccionado: selecciona primero texto en el documento.',
agentQuotePrefix: 'Ten en cuenta el contenido que he seleccionado:',
+ agentCodeBlock: 'Código',
+ agentMarkdownTable: 'Tabla',
agentCopy: 'Copiar',
agentCopied: 'Copiado',
agentCopyFailed: 'No se pudo copiar',
agentRestoreRequest: 'Recuperar solicitud',
agentRoutingRequest: 'Interpretando tu solicitud…',
agentWaitElapsed: '{seconds} s transcurridos',
+ agentComposerLabel: 'Redactar mensaje',
+ agentComposerActionsLabel: 'Acciones del mensaje',
+ agentReasoning: 'Razonamiento',
+ agentReasoningInterrupted: 'Razonamiento interrumpido',
+ agentReasoningTruncated: 'Razonamiento truncado',
agentWaiting: 'Generando respuesta…',
agentReadingContent: 'Leyendo el contenido actual…',
agentPreparingTask: 'Preparando tu solicitud…',
@@ -513,10 +523,14 @@ export const es: Partial = {
agentSourcesSearch: 'Buscar fuentes',
agentSourcesOpen: 'Abrir fuente',
agentSourcesExcerpt: 'Las fuentes se envían como extractos limitados al servicio de IA seleccionado.',
+ agentArtifactEdit: 'Editar',
+ agentArtifactCancelEditing: 'Cancelar edición',
+ agentArtifactSourceData: 'Datos de origen',
+ agentArtifactUnsaved: 'Cambios sin guardar',
agentArtifactTitle: 'Artefactos',
agentArtifactEmpty: 'Guarda resultados aquí para editarlos y conservar versiones en este dispositivo.',
agentArtifactBack: 'Volver',
- agentArtifactBackToList: 'Volver a los resultados',
+ agentArtifactBackToList: 'Volver a los Artefactos',
agentArtifactTitleField: 'Título',
agentArtifactTextField: 'Contenido',
agentArtifactVersion: 'Versión',
diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts
index c7fa93d99..6e49b7fda 100644
--- a/packages/shared/src/i18n/messages/ja.ts
+++ b/packages/shared/src/i18n/messages/ja.ts
@@ -88,8 +88,11 @@ export const ja: Partial = {
agentUseServiceHint:
'送信した文章は選択したサービスで処理します。サービス情報が必要で、料金が発生する場合があります。',
agentPreferences: 'その他の設定',
+ agentWorkspaceChat: 'チャット',
agentBackToChat: '会話に戻る',
agentExpand: 'アシスタントを広げる',
+ agentCollapse: 'アシスタントを縮小',
+ agentVoiceOptions: '音声オプション',
agentSetupFailed: '開けませんでした。再試行してください。文書は変更されていません。',
agentOffNote: 'オフにすると現在の作業を停止し、文書と下書きを保持します。',
agentResumeModel: 'ダウンロード済みモデルを使う',
@@ -380,12 +383,19 @@ export const ja: Partial = {
agentParagraphSelectionConflict: '現在の段落を整列するには選択を解除するか、選択範囲の整列を指定してください。',
agentNoSelection: '選択範囲が見つかりません。先にドキュメント内のテキストを選択してください。',
agentQuotePrefix: '選択した内容を踏まえてください:',
+ agentCodeBlock: 'コード',
+ agentMarkdownTable: '表',
agentCopy: 'コピー',
agentCopied: 'コピーしました',
agentCopyFailed: 'コピーできませんでした',
agentRestoreRequest: 'リクエストを復元',
agentRoutingRequest: 'リクエストを確認中…',
agentWaitElapsed: '{seconds} 秒経過',
+ agentComposerLabel: 'メッセージ入力',
+ agentComposerActionsLabel: 'メッセージ操作',
+ agentReasoning: '思考過程',
+ agentReasoningInterrupted: '思考を中断しました',
+ agentReasoningTruncated: '思考内容の表示を省略しました',
agentWaiting: '回答を生成中…',
agentReadingContent: '現在の内容を読み取り中…',
agentPreparingTask: 'リクエストを準備中…',
@@ -506,6 +516,10 @@ export const ja: Partial = {
agentSourcesSearch: 'ソースを検索',
agentSourcesOpen: 'ソースを開く',
agentSourcesExcerpt: '送信時に資料の抜粋が選択したAIサービスへ送られます。',
+ agentArtifactEdit: '編集',
+ agentArtifactCancelEditing: '編集を取り消す',
+ agentArtifactSourceData: 'ソースデータ',
+ agentArtifactUnsaved: '未保存の変更',
agentArtifactTitle: '成果物',
agentArtifactEmpty: '結果を保存して、この端末で編集・バージョン管理できます。',
agentArtifactBack: '戻る',
diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts
index 7bb10ff50..25d54cca3 100644
--- a/packages/shared/src/i18n/messages/ko.ts
+++ b/packages/shared/src/i18n/messages/ko.ts
@@ -87,8 +87,11 @@ export const ko: Partial = {
agentUseServiceHint:
'보낸 글은 선택한 서비스에서 처리됩니다. 본인의 서비스 정보가 필요하며 요금이 발생할 수 있습니다.',
agentPreferences: '추가 설정',
+ agentWorkspaceChat: '채팅',
agentBackToChat: '대화로 돌아가기',
agentExpand: '도우미 확대',
+ agentCollapse: '도우미 축소',
+ agentVoiceOptions: '음성 옵션',
agentSetupFailed: '도우미를 열 수 없습니다. 다시 시도하세요. 문서는 바뀌지 않았습니다.',
agentOffNote: '끄면 현재 작업이 중지되고 문서와 초안은 유지됩니다.',
agentResumeModel: '다운로드한 모델 사용',
@@ -369,12 +372,19 @@ export const ko: Partial = {
agentParagraphSelectionConflict: '현재 단락을 정렬하려면 선택을 해제하거나 선택 영역 정렬을 요청하세요.',
agentNoSelection: '선택한 영역이 없습니다. 문서에서 텍스트를 먼저 선택하세요.',
agentQuotePrefix: '선택한 내용을 참고해 주세요:',
+ agentCodeBlock: '코드',
+ agentMarkdownTable: '표',
agentCopy: '복사',
agentCopied: '복사됨',
agentCopyFailed: '복사 실패',
agentRestoreRequest: '요청 복원',
agentRoutingRequest: '요청을 파악하는 중…',
agentWaitElapsed: '{seconds}초 경과',
+ agentComposerLabel: '메시지 입력',
+ agentComposerActionsLabel: '메시지 작업',
+ agentReasoning: '생각 과정',
+ agentReasoningInterrupted: '생각 중단됨',
+ agentReasoningTruncated: '생각 내용이 잘렸습니다',
agentWaiting: '응답 생성 중…',
agentReadingContent: '현재 내용을 읽는 중…',
agentPreparingTask: '요청을 준비하는 중…',
@@ -492,10 +502,14 @@ export const ko: Partial = {
agentSourcesSearch: '소스 검색',
agentSourcesOpen: '소스 열기',
agentSourcesExcerpt: '요청 전송 시 자료의 제한된 발췌문을 선택한 AI 서비스에 보냅니다.',
+ agentArtifactEdit: '편집',
+ agentArtifactCancelEditing: '편집 취소',
+ agentArtifactSourceData: '원본 데이터',
+ agentArtifactUnsaved: '저장하지 않은 변경 사항',
agentArtifactTitle: '아티팩트',
agentArtifactEmpty: '결과를 저장하고 이 기기에서 편집하며 버전을 보관하세요.',
agentArtifactBack: '뒤로',
- agentArtifactBackToList: '결과물 목록으로 돌아가기',
+ agentArtifactBackToList: '아티팩트 목록으로 돌아가기',
agentArtifactTitleField: '제목',
agentArtifactTextField: '내용',
agentArtifactVersion: '버전',
diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts
index 166e59206..69f18cc70 100644
--- a/packages/shared/src/i18n/messages/pt.ts
+++ b/packages/shared/src/i18n/messages/pt.ts
@@ -88,8 +88,11 @@ export const pt: Partial = {
agentUseServiceHint:
'O texto enviado vai para o serviço escolhido. Seus dados de serviço são necessários; pode haver custos.',
agentPreferences: 'Mais preferências',
+ agentWorkspaceChat: 'Conversa',
agentBackToChat: 'Voltar à conversa',
agentExpand: 'Expandir assistente',
+ agentCollapse: 'Recolher assistente',
+ agentVoiceOptions: 'Opções de voz',
agentSetupFailed: 'Não foi possível abrir o assistente. Tente novamente. O documento não mudou.',
agentOffNote: 'Desativar interrompe a tarefa e mantém o documento e o rascunho.',
agentResumeModel: 'Usar modelo baixado',
@@ -381,12 +384,19 @@ export const pt: Partial = {
agentParagraphSelectionConflict: 'Remove a seleção para alinhar o parágrafo atual, ou pede para alinhar a seleção.',
agentNoSelection: 'Não há nada selecionado — selecione primeiro texto no documento.',
agentQuotePrefix: 'Tenha em conta o conteúdo que selecionei:',
+ agentCodeBlock: 'Código',
+ agentMarkdownTable: 'Tabela',
agentCopy: 'Copiar',
agentCopied: 'Copiado',
agentCopyFailed: 'Falha ao copiar',
agentRestoreRequest: 'Restaurar pedido',
agentRoutingRequest: 'Interpretando sua solicitação…',
agentWaitElapsed: '{seconds} s decorridos',
+ agentComposerLabel: 'Compor mensagem',
+ agentComposerActionsLabel: 'Ações da mensagem',
+ agentReasoning: 'Raciocínio',
+ agentReasoningInterrupted: 'Raciocínio interrompido',
+ agentReasoningTruncated: 'Raciocínio truncado',
agentWaiting: 'Gerando resposta…',
agentReadingContent: 'Lendo o conteúdo atual…',
agentPreparingTask: 'Preparando sua solicitação…',
@@ -508,10 +518,14 @@ export const pt: Partial = {
agentSourcesSearch: 'Pesquisar fontes',
agentSourcesOpen: 'Abrir fonte',
agentSourcesExcerpt: 'As fontes são enviadas como trechos limitados ao serviço de IA escolhido.',
+ agentArtifactEdit: 'Editar',
+ agentArtifactCancelEditing: 'Cancelar edição',
+ agentArtifactSourceData: 'Dados de origem',
+ agentArtifactUnsaved: 'Alterações não salvas',
agentArtifactTitle: 'Artefatos',
agentArtifactEmpty: 'Salve resultados aqui para editar e manter versões neste dispositivo.',
agentArtifactBack: 'Voltar',
- agentArtifactBackToList: 'Voltar aos resultados',
+ agentArtifactBackToList: 'Voltar aos Artefatos',
agentArtifactTitleField: 'Título',
agentArtifactTextField: 'Conteúdo',
agentArtifactVersion: 'Versão',
diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts
index 01ae562db..428e5e0f3 100644
--- a/packages/shared/src/i18n/messages/zh-CN.ts
+++ b/packages/shared/src/i18n/messages/zh-CN.ts
@@ -81,8 +81,11 @@ export const zhCN: I18nMessages = {
agentUseService: '连接 AI 服务',
agentUseServiceHint: '支持本机服务和云端 API。请求内容会发送给所选服务;费用以服务商说明为准。',
agentPreferences: '高级设置',
+ agentWorkspaceChat: '对话',
agentBackToChat: '返回对话',
agentExpand: '展开助手',
+ agentCollapse: '收起助手',
+ agentVoiceOptions: '语音选项',
agentSetupFailed: '暂时无法开启助手,请重试。你的文档没有改变。',
agentOffNote: '关闭后停止当前任务,保留文档和未发送的输入。',
agentResumeModel: '启用已下载模型',
@@ -355,12 +358,19 @@ export const zhCN: I18nMessages = {
agentParagraphSelectionConflict: '请取消选区后对齐当前段落,或明确要求对齐选区。',
agentNoSelection: '没有检测到选中的内容,请先在文档中选择文字。',
agentQuotePrefix: '请参考我选中的内容:',
+ agentCodeBlock: '代码',
+ agentMarkdownTable: '表格',
agentCopy: '复制',
agentCopied: '已复制',
agentCopyFailed: '复制失败',
agentRestoreRequest: '恢复请求',
agentRoutingRequest: '正在理解你的请求…',
agentWaitElapsed: '已等待 {seconds} 秒',
+ agentComposerLabel: '消息输入区',
+ agentComposerActionsLabel: '消息操作',
+ agentReasoning: '思考过程',
+ agentReasoningInterrupted: '思考已中断',
+ agentReasoningTruncated: '思考内容过长,已截断',
agentWaiting: '正在生成回复…',
agentReadingContent: '正在读取当前内容…',
agentPreparingTask: '正在准备任务…',
@@ -475,6 +485,10 @@ export const zhCN: I18nMessages = {
agentSourcesSearch: '搜索来源',
agentSourcesOpen: '打开来源',
agentSourcesExcerpt: '发送请求时,资料会以有长度限制的摘录发送给所选 AI 服务。',
+ agentArtifactEdit: '编辑',
+ agentArtifactCancelEditing: '取消编辑',
+ agentArtifactSourceData: '源数据',
+ agentArtifactUnsaved: '尚未保存',
agentArtifactTitle: '成果',
agentArtifactEmpty: '将有用的结果保存在这里,在此设备编辑并保留版本。',
agentArtifactBack: '返回',
diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts
index 5de8d7f44..10c09ec49 100644
--- a/packages/shared/src/i18n/types.ts
+++ b/packages/shared/src/i18n/types.ts
@@ -111,8 +111,11 @@ export interface I18nMessages {
agentUseService: string;
agentUseServiceHint: string;
agentPreferences: string;
+ agentWorkspaceChat: string;
agentBackToChat: string;
agentExpand: string;
+ agentCollapse: string;
+ agentVoiceOptions: string;
agentSetupFailed: string;
agentOffNote: string;
agentResumeModel: string;
@@ -366,10 +369,17 @@ export interface I18nMessages {
agentParagraphSelectionConflict: string;
agentNoSelection: string;
agentQuotePrefix: string;
+ agentCodeBlock: string;
+ agentMarkdownTable: string;
agentCopy: string;
agentCopied: string;
agentCopyFailed: string;
agentRestoreRequest: string;
+ agentComposerLabel: string;
+ agentComposerActionsLabel: string;
+ agentReasoning: string;
+ agentReasoningInterrupted: string;
+ agentReasoningTruncated: string;
agentWaiting: string;
agentRoutingRequest: string;
agentWaitElapsed: string;
@@ -490,6 +500,10 @@ export interface I18nMessages {
agentSourcesSearch: string;
agentSourcesOpen: string;
agentSourcesExcerpt: string;
+ agentArtifactEdit: string;
+ agentArtifactCancelEditing: string;
+ agentArtifactSourceData: string;
+ agentArtifactUnsaved: string;
agentArtifactTitle: string;
agentArtifactEmpty: string;
agentArtifactBack: string;
diff --git a/patches/README.md b/patches/README.md
new file mode 100644
index 000000000..cefd82d53
--- /dev/null
+++ b/patches/README.md
@@ -0,0 +1,22 @@
+# Dependency patches
+
+`ranui@0.5.0.patch` carries upstream corrections in the ES modules consumed by
+this application. The reasoning owner is
+`chaxus/ran:packages/ranui/components/reasoning/index.ts`: an open reasoning body
+already displays its text, so the header summary appears only while collapsed.
+It preserves native disclosure ownership, streaming state and public APIs.
+
+The select owner is `chaxus/ran:packages/ranui/components/select/index.ts`.
+Keyboard reopening uses the public `open` state, so a closing animation cannot
+consume the next Arrow, Enter, Home/End or typeahead action as if the menu were
+still open. Visual exit frames do not own interaction state.
+
+The matching source change, component regression tests and browser verification
+are maintained in the ran PR. Keep this patch until a ranui release includes these
+corrections; then upgrade the dependency and remove this patch and its
+`patchedDependencies` entry together. Do not recreate the behavior with host
+shadow-DOM queries or CSS overrides. Install with the lockfile before acceptance
+so screenshots exercise the same patched module as CI.
+
+The existing WebLLM and wllama patches retain their separately documented source
+comments and purposes in their patch files.
diff --git a/patches/ranui@0.5.0.patch b/patches/ranui@0.5.0.patch
new file mode 100644
index 000000000..6f37b27b1
--- /dev/null
+++ b/patches/ranui@0.5.0.patch
@@ -0,0 +1,26 @@
+diff --git a/dist/reasoning-Bjkl7E3y.js b/dist/reasoning-Bjkl7E3y.js
+index d216365b8755f0054a7ffe5e894a5cd1ca9aa739..1913af7f979330a8f49ed71fb7b7db3dd545214c 100644
+--- a/dist/reasoning-Bjkl7E3y.js
++++ b/dist/reasoning-Bjkl7E3y.js
+@@ -94,7 +94,7 @@ var b = class extends u {
+ const t = this.streaming, e = this.duration;
+ this._row.open = this.open, this._row.busy = t;
+ const r = e === null || e < 1e3 ? "" : `${(e / 1e3).toFixed(1)}s`;
+- this._row.heading = r === "" ? this.label : `${this.label} · ${r}`, this._row.summary = g(this._content, t);
++ this._row.heading = r === "" ? this.label : `${this.label} · ${r}`, this._row.summary = this.open ? "" : g(this._content, t);
+ }
+ };
+ d("r-reasoning", b);
+diff --git a/dist/select-BhJCgX3Q.js b/dist/select-BhJCgX3Q.js
+index 03cf7bf150d0e5443ee03341675c7063136f36e3..10ae4066dec35c78887b56116dd3991a9bea1109 100644
+--- a/dist/select-BhJCgX3Q.js
++++ b/dist/select-BhJCgX3Q.js
+@@ -349,7 +349,7 @@ var L = 0, T = class extends f {
+ label: i
+ } }))));
+ };
+- isDropdownOpen = () => this._selectionDropdown ? this._selectionDropdown.style.display === "block" : !1;
++ isDropdownOpen = () => this.open;
+ _nextEnabledIndex = (t, e) => {
+ const i = this.getDropdownOptions(), s = i.length;
+ if (s === 0) return -1;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bd935ccf8..1bb7829dc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,6 +13,7 @@ overrides:
patchedDependencies:
'@mlc-ai/web-llm@0.2.85': ab39c67b61b734014dc299766a9bffabb2e077b70bed48c7c4dec0af56af0599
'@wllama/wllama@3.6.1': 5eaac29afca7fbc57011df1f0d69005d9e590da3dd1e5ad74155e4f0525a1702
+ ranui@0.5.0: 6dffcad6e9c7caea44805bd930abf8f6cdbcbe17ac593befdbe38e5d0e519077
importers:
@@ -38,7 +39,7 @@ importers:
version: 5.4.624
ranui:
specifier: 0.5.0
- version: 0.5.0
+ version: 0.5.0(patch_hash=6dffcad6e9c7caea44805bd930abf8f6cdbcbe17ac593befdbe38e5d0e519077)
ranuts:
specifier: 0.5.0
version: 0.5.0
@@ -109,10 +110,13 @@ importers:
version: 16.4.2
ranui:
specifier: 0.5.0
- version: 0.5.0
+ version: 0.5.0(patch_hash=6dffcad6e9c7caea44805bd930abf8f6cdbcbe17ac593befdbe38e5d0e519077)
ranuts:
specifier: 0.5.0
version: 0.5.0
+ shiki:
+ specifier: 4.4.3
+ version: 4.4.3
devDependencies:
typescript:
specifier: ^6.0.3
@@ -125,7 +129,7 @@ importers:
version: link:../shared
ranui:
specifier: 0.5.0
- version: 0.5.0
+ version: 0.5.0(patch_hash=6dffcad6e9c7caea44805bd930abf8f6cdbcbe17ac593befdbe38e5d0e519077)
ranuts:
specifier: 0.5.0
version: 0.5.0
@@ -2237,7 +2241,7 @@ snapshots:
punycode@2.3.1: {}
- ranui@0.5.0:
+ ranui@0.5.0(patch_hash=6dffcad6e9c7caea44805bd930abf8f6cdbcbe17ac593befdbe38e5d0e519077):
dependencies:
'@alixex/ranview': 0.2.0
dompurify: 3.4.16
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 7ed75da23..8d4d6769e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -7,6 +7,7 @@ minimumReleaseAgeExclude:
patchedDependencies:
'@mlc-ai/web-llm@0.2.85': patches/@mlc-ai__web-llm@0.2.85.patch
'@wllama/wllama@3.6.1': patches/@wllama__wllama@3.6.1.patch
+ ranui@0.5.0: patches/ranui@0.5.0.patch
# Security fixes for transitive dependencies; remove when upstream ranges
# select patched releases (KaTeX currently remains on Mermaid's 0.16 range).
overrides:
diff --git a/styles/assistant-artifacts.css b/styles/assistant-artifacts.css
new file mode 100644
index 000000000..7b0ca9875
--- /dev/null
+++ b/styles/assistant-artifacts.css
@@ -0,0 +1,346 @@
+/* The section is a column in the shell; only its body owns vertical scrolling.
+ Width changes move the existing section without replacing its draft or reader. */
+.agent-panel .agent-artifacts {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ padding: 0;
+}
+.agent-panel .agent-artifacts[hidden] {
+ display: none;
+}
+.agent-artifacts > .agent-view-header {
+ flex: none;
+ margin: 0;
+ padding: var(--ran-space-4) var(--agent-content-inset);
+}
+.agent-artifacts-body {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+ padding: var(--ran-space-4) var(--agent-content-inset) var(--ran-space-6);
+}
+.agent-artifacts > .agent-artifact-status {
+ flex: none;
+ margin: 0;
+ padding: var(--ran-space-3) var(--agent-content-inset);
+ border-block-start: 1px solid var(--ran-color-border);
+ line-height: 1.5;
+}
+.agent-artifacts .agent-artifact-workbench {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ran-space-6);
+ min-width: 0;
+}
+.agent-artifact-toolbar {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ran-space-2);
+ min-width: 0;
+}
+.agent-artifact-identity,
+.agent-artifact-modebar,
+.agent-artifact-modes,
+.agent-artifact-delete-prompt,
+.agent-artifact-pagination {
+ display: flex;
+ align-items: center;
+ gap: var(--ran-space-2);
+ min-width: 0;
+}
+.agent-artifact-modebar,
+.agent-artifact-delete-prompt,
+.agent-artifact-pagination {
+ flex-wrap: wrap;
+}
+.agent-artifact-identity > .agent-artifact-title {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ border-color: transparent;
+ padding-inline: var(--ran-space-2);
+ font-size: var(--ran-text-label-2);
+ font-weight: var(--ran-text-heading-weight);
+ line-height: 1.5;
+ text-overflow: ellipsis;
+}
+.agent-artifact-identity > .agent-artifact-title[readonly] {
+ padding-inline-start: 0;
+ cursor: default;
+}
+.agent-artifact-identity > .agent-artifact-title:not([readonly]) {
+ border-color: var(--ran-color-border);
+}
+.agent-artifact-identity > select {
+ flex: none;
+ width: auto;
+ max-width: 50%;
+ min-height: var(--app-control-height, 32px);
+ border: 0;
+ padding-inline: var(--ran-space-2);
+ color: var(--ran-color-text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+.agent-artifact-modes {
+ flex-wrap: wrap;
+ gap: var(--ran-space-1);
+}
+.agent-artifacts .agent-artifact-modes > button {
+ border: 1px solid transparent;
+ padding-inline: var(--ran-space-2);
+}
+.agent-artifacts .agent-artifact-modes > button[aria-pressed='true'] {
+ border-color: var(--ran-color-border);
+ background: var(--ran-color-bg-muted);
+ font-weight: var(--ran-text-heading-weight);
+}
+.agent-artifact-unsaved {
+ margin-inline-start: auto;
+ color: var(--ran-color-text-secondary);
+ font-size: var(--ran-text-label-3);
+ line-height: 1.5;
+}
+.agent-artifact-workbench .agent-artifact-content,
+.agent-artifact-workbench .agent-artifact-presentation-host,
+.agent-artifact-workbench .agent-artifact-presentation {
+ min-width: 0;
+}
+.agent-artifact-workbench .agent-artifact-content > textarea,
+.agent-artifact-workbench .agent-artifact-source > textarea {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 22rem;
+ line-height: 1.75;
+ resize: vertical;
+ font-family: inherit;
+ font-size: var(--ran-text-copy-1, 16px);
+}
+.agent-artifact-workbench .agent-artifact-content > textarea {
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+}
+.agent-artifact-workbench .agent-artifact-source > textarea {
+ font-family: var(--ran-font-mono, monospace);
+ font-size: var(--ran-text-copy-2, 14px);
+ margin-block-start: var(--ran-space-2);
+}
+.agent-artifact-workbench .agent-artifact-reader {
+ color: var(--ran-color-text);
+ font-weight: var(--ran-text-copy-weight);
+ font-size: var(--ran-text-copy-1, 16px);
+ line-height: 1.75;
+ overflow-wrap: anywhere;
+ overflow-x: auto;
+}
+.agent-artifact-reader > :first-child {
+ margin-block-start: 0;
+}
+.agent-artifact-workbench .agent-artifact-reader > :last-child {
+ margin-block-end: 0;
+}
+.agent-artifact-workbench .agent-artifact-reader :is(h2, h3, h4, h5, h6) {
+ margin-block: var(--ran-space-8) var(--ran-space-2);
+ font-size: inherit;
+ line-height: 1.4;
+ font-weight: var(--ran-text-heading-weight);
+ text-wrap: pretty;
+}
+.agent-artifact-workbench .agent-artifact-reader h2 {
+ font-size: var(--ran-text-heading-3, 20px);
+ letter-spacing: var(--ran-text-heading-tracking);
+}
+.agent-artifact-workbench .agent-artifact-reader h3 {
+ font-size: var(--ran-text-heading-4, 16px);
+}
+.agent-artifact-workbench .agent-artifact-reader > :is(h2, h3, h4, h5, h6):first-child {
+ margin-block-start: 0;
+}
+.agent-artifact-reader :is(p, ul, ol) {
+ margin-block: 0 var(--ran-space-4);
+}
+.agent-artifact-workbench .agent-artifact-reader > p {
+ color: inherit;
+ white-space: normal;
+}
+/* The text measure stays independent of table and code viewports. */
+.agent-artifact-reader :is(p, h2, h3, h4, h5, h6, ul, ol, blockquote) {
+ max-inline-size: 42.5rem;
+}
+.agent-artifact-reader :is(ul, ol) {
+ box-sizing: border-box;
+ padding-inline-start: var(--ran-space-6);
+}
+.agent-artifact-reader li {
+ padding-inline-start: calc(var(--ran-space-1) / 2);
+ margin-block: var(--ran-space-1);
+}
+.agent-artifact-reader li > p {
+ margin-block: 0 var(--ran-space-2);
+}
+.agent-artifact-reader li > :last-child,
+.agent-artifact-reader li > :is(ul, ol) {
+ margin-block-end: 0;
+}
+.agent-artifact-reader a {
+ color: var(--ran-color-link);
+ text-decoration: underline;
+}
+.agent-artifact-reader blockquote {
+ box-sizing: border-box;
+ margin-block: var(--ran-space-4);
+ margin-inline: 0;
+ padding-inline-start: var(--ran-space-3);
+ border-inline-start: 2px solid var(--ran-color-border);
+ color: var(--ran-color-text-secondary);
+}
+.agent-artifact-reader code {
+ font-family: var(--ran-font-mono, monospace);
+ font-size: 0.88em;
+ background: var(--ran-color-bg-muted);
+ padding: calc(var(--ran-space-1) / 2) var(--ran-space-1);
+ border-radius: var(--ran-radius-sm);
+}
+.agent-artifact-reader hr {
+ width: 100%;
+ margin-block: var(--ran-space-6);
+ border: 0;
+ border-block-start: 1px solid var(--ran-color-border);
+}
+.agent-artifact-field-value {
+ white-space: pre-wrap;
+}
+.agent-artifact-reader > .agent-artifact-field-value:first-child {
+ margin-block-end: var(--ran-space-3);
+ font-size: var(--ran-text-heading-4);
+ font-weight: var(--ran-text-heading-weight);
+}
+.agent-artifact-pagination {
+ margin-block-start: var(--ran-space-4);
+ color: var(--ran-color-text-secondary);
+ font-size: var(--ran-text-label-3);
+ font-variant-numeric: tabular-nums;
+}
+.agent-artifact-footer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ran-space-3);
+ padding-block-start: var(--ran-space-4);
+ border-block-start: 1px solid var(--ran-color-border);
+ min-width: 0;
+}
+.agent-artifact-footer > .agent-artifact-actions {
+ /* Padded commands extend their hit frame into the gutter; labels keep the reader rail. */
+ margin-inline: calc(-1 * var(--ran-space-2));
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--ran-space-2);
+ min-width: 0;
+}
+.agent-artifact-footer > .agent-artifact-actions > button {
+ flex: 0 1 auto;
+ width: auto;
+}
+.agent-artifact-footer > .agent-artifact-actions > [data-action='delete'] {
+ margin-inline-start: auto;
+}
+.agent-artifact-footer .agent-artifact-workflow {
+ margin: 0;
+}
+.agent-artifact-footer [data-action='refine'] {
+ margin-inline-start: calc(-1 * var(--ran-space-2));
+}
+.agent-artifact-footer .agent-artifact-workflow form input {
+ border: 1px solid var(--ran-color-border);
+ border-radius: var(--ran-radius-sm);
+ padding-inline: var(--ran-space-2);
+}
+.agent-artifact-footer > .agent-artifact-provenance {
+ margin: 0;
+ color: var(--ran-color-text-secondary);
+ font-size: var(--ran-text-label-3);
+ line-height: 1.6;
+}
+.agent-artifact-delete-prompt > span {
+ flex: 1 1 100%;
+ font-size: var(--ran-text-label-2);
+}
+.agent-artifact-export {
+ max-width: 100%;
+}
+.agent-artifact-export > summary,
+.agent-artifact-source > summary {
+ box-sizing: border-box;
+ cursor: pointer;
+ font-size: var(--ran-text-label-3);
+ min-height: var(--app-control-height, 32px);
+ padding: var(--ran-space-1) var(--ran-space-2);
+ align-content: center;
+ border: 1px solid var(--ran-color-border);
+ border-radius: var(--ran-radius-sm);
+}
+.agent-artifact-export[open] {
+ flex-basis: 100%;
+}
+.agent-artifact-export[open] > summary {
+ width: fit-content;
+ margin-block-end: var(--ran-space-2);
+}
+.agent-artifact-workbench :is(.agent-artifact-reader, textarea, summary, select, input):focus-visible {
+ outline: 2px solid var(--ran-color-link);
+ outline-offset: 2px;
+}
+.agent-artifacts [hidden] {
+ display: none;
+}
+@media (pointer: coarse) {
+ .agent-artifact-workbench :is(button, summary, select, input) {
+ min-height: var(--app-control-touch, 44px);
+ }
+}
+
+/* Multiline suggestions are two readable texts; short changes remain inline. */
+.agent-artifact-workbench .agent-artifact-diff[data-layout='blocks'] {
+ display: grid;
+ gap: var(--ran-space-4);
+ max-height: none;
+ overflow: visible;
+ white-space: normal;
+}
+.agent-artifact-diff-block {
+ min-width: 0;
+}
+.agent-artifact-diff-block > .agent-artifact-diff-label {
+ margin: 0 0 var(--ran-space-2);
+ color: var(--ran-color-text-secondary);
+ font-size: var(--ran-text-label-3);
+ font-weight: var(--ran-text-heading-weight);
+ line-height: 1.4;
+}
+.agent-artifact-diff-text {
+ max-height: 16em;
+ overflow: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-size: var(--ran-text-label-2);
+ line-height: 1.65;
+}
+.agent-artifact-diff-block[data-artifact-diff-side='before'] .agent-artifact-diff-text {
+ color: var(--ran-color-text-secondary);
+}
+.agent-artifact-diff-block[data-artifact-diff-side='after'] .agent-artifact-diff-text {
+ padding-inline-start: var(--ran-space-3);
+ border-inline-start: 2px solid var(--ran-color-border);
+}
+
+.agent-artifact-diff-text:focus-visible {
+ outline: 2px solid var(--ran-color-link);
+ outline-offset: 2px;
+}
diff --git a/styles/assistant.css b/styles/assistant.css
index 553a4d5a3..b35566afa 100644
--- a/styles/assistant.css
+++ b/styles/assistant.css
@@ -1,5 +1,5 @@
/* ── Document assistant ───────────────────────────── */
-/* Dock mode: the editor gives up 360px so the panel sits beside it instead of
+/* Dock mode: the editor gives up the preferred panel width so it sits beside it instead of
overlaying the document. DocsAPI REPLACES the #iframe placeholder div with the
editor