diff --git a/docs/design-system.md b/docs/design-system.md index a61449f03..96659fdf5 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -218,21 +218,28 @@ The canonical composition rules are owned by 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 | +| 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 Chat/home target with 8px inline padding; secondary pages add a leading chevron | Text/icon rail, actual target width, hover/focus area and neighboring actions | +| Navigation | One header: Chat/home, Artifacts, Conversations, New and Close; Sources beside composition and Models beneath it | Accessible names/current states, real destination paths, draft/focus return and narrow overflow | +| Nested navigation | Labeled Back only inside artifact import/editor workflows, preceding the heading | Destination text, target bounds, draft/confirmation behavior | +| View heading | All top-level and nested h2 text returns to the 16px content rail, at the 16px heading role | Actual heading text Range, including long labels | +| 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 +Use one compact header rather than another row of destinations. Its Artifacts and Conversations +icons have accessible names and selected states. Sources and the selected model/settings trigger +belong beside composition. On narrow panes Sources becomes an icon with the same accessible +name, keeping room for the voice language, Stop and Send. At 360px and below, coarse input +places voice tools and execution in two stable rows so language remains readable. Coarse input +retains 44px targets. +Chat restores the displayed conversation and draft; it must not switch to the latest session +while returning from a secondary page. Open Chat even without a configured model, preserving +the draft through explicit preparation/settings. Runtime preparation does not precede unrelated views. +Top-level views have no Back row. Nested Back focus outlines remain inside their targets. +Populated result lists keep a 16px boundary before Import. 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 @@ -254,31 +261,53 @@ target expanded into free toolbar space, and a sibling view shifted off its cont 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. +### Assistant header and settings commands + +The fine-input title row is 40px: 32px targets with 4px above and below. Coarse input retains +44px targets and a 52px row. Keep the assistant identity at the 13px label role and medium +weight. Header actions remain quiet 32px targets on desktop, expanding to 44px for coarse input; +a fine-pointer pane at 360px or narrower also gives the home command a 44px target. Five destinations +remain reachable through their task contexts. There is no tab strip, inset underline or raised +shadow. Keep keyboard focus separate from selection. The secondary-page home chevron stays on +the content spine; its label follows after the icon and gap. + +History & data is a named command-list context. Restore, Import, Export and deletion commands +use equal-width native button rows with visible verb labels and leading icons. The resting +rows use the pane's canvas, without repeated frames, fills or separators. Hover/pressed states +supply restrained feedback; focus remains visible on keyboard input. This composition is specific +to a recognizable command group, not a rule to strip affordance from standalone commands. +Keep 8px between commands and 16px before deletion commands. + +Header/page/section text, saving checkbox and command icon viewports share the 16px leading +content spine. A 16px leading glyph and 8px gap put the command label at 40px; saving text and +its status use the same label column. Each row's 8px padding extends the invisible hit surface +into the existing gutter, while content stays on the declared spine. Targets remain equal width, +32px for fine input or 44px for coarse input, inside the pane and non-overlapping. Long labels +wrap naturally. Do not align only the button frames and leave visible leading content inset. + +Deletion commands use readable danger text/icons; only explicit confirmation has a solid danger +fill. Preserve draft, confirmation, busy and focus-return behavior. Acceptance checks text/icon +coordinates against the independently declared page spine, not just sibling equality. For +transparent resting rows, measure text contrast against the actual pane canvas; hover/press +contrast uses the painted control surface. Verify complete pane screenshots, resizing and input density. + +Related model/attachment helper copy sits 8px from its control. Separate model actions and +adjacent custom-model fields use 16px. Block-layout select hosts avoid anonymous inline baseline +space; verify painted region edges rather than adding declared margins. Source removal moves +keyboard focus to the next visible Remove action, then the previous one, or Attach when the +filtered list is empty. Source filenames and Remove use the same label role and first-line text +center; the expanded excerpt does not move the Remove action. Chat starter actions use the +adaptive control-height role, including 44px coarse targets. Artifact deletion reveals and focuses +Cancel; cancellation returns to Delete. Confirmation actions retain an 8px gap, including wrapping. + +Import and Attach open a workflow and retain secondary command affordance. Primary fill is +reserved for committing a form or accepting a reviewed result. A brief opacity-only heading +entry can mark navigation with the existing fast motion token; it must not delay focus, +move hit targets or animate overlay ancestors. Reduced motion disables this cue. 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. +separates task regions; named command rows identify history actions; standalone button surfaces identify workflow actions; 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 @@ -289,14 +318,47 @@ decorative group outline in acceptance. 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 | +| 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 uses a recognizable secondary command | +| Artifacts | Full-width saved-result rows; quiet ordinary review/export/page/revision controls; no blanket button outlines | Secondary Import, primary Save/accept commits, 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). + +### Conversation, public reasoning and artifact workbench + +The composer owns the draft, attachment chips and input actions. Sending clears only the submitted +draft; the reader may prepare the next draft while generation continues. Stop remains a stable, +separate action. Preserve IME composition, queue promotion, selection and focus. Never replay +already received tokens as decoration. Keep answer actions quiet and in a stable reading surface. + +Render only public reasoning explicitly returned by the configured provider, using native +`r-reasoning`. It is separate from answer text, 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. A reader's disclosure choice takes +precedence over later stream updates. Expanded content must 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 lifetime guards as answer delivery. + +Artifacts open in Preview. Use the existing bounded safe chat Markdown renderer for text; +structured content has a readable presentation with source JSON behind a disclosure. Explicit Edit +reveals the canonical input/textarea. Preview may reflect that unsaved draft but does not save it. +Saving creates an explicit version and returns to Preview. Keep dirty navigation/version guards, +late-save cancellation and document review separate. Multiline AI comparisons use separately labeled Original/Proposed blocks with preserved line breaks, +bounded readable regions and explicit expansion; short changes retain inline differences. +AI refinement produces an editable draft; +applying it to a document still requires the native review. Deletion asks a nearby question with +short Delete/Cancel controls and safe focus return. Export is a disclosure of actual export formats. + +Use existing ran fast/base motion roles only where they explain a state change. Native reasoning +height/disclosure motion owns interruption and reversal. Reduced motion removes interpolation; +focus and content availability never wait for decoration. Do not animate palette properties on +theme changes or transform ancestors of portaled overlays. Actual screenshots and frame-inspected +recordings of waiting, streaming, interruption, error, reader disclosure, preview/edit/dirty/save, +export and document review are required alongside behavioral tests. Fixture streams prove UI +behavior and isolation, not real-model quality or precise parity with another product. 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..509cd4fc6 --- /dev/null +++ b/docs/explorations/2026-10-21-assistant-conversation-craft.md @@ -0,0 +1,47 @@ +# Assistant conversation and artifact craft acceptance + +Accepted runtime source: `d6c0411372e168a5a1c124c82408b7499e7e3cc2`. +Build SHA-256: `23a96f4182123707557f63f43762b563cdfcd91f25f9a9aaccb20561081123b0`. +Upstream ran source: `72c44840707ee5dc88a3e60e5a4906f73a7fc98d` ([PR436](https://github.com/chaxus/ran/pull/436)). +Subsequent documentation/media commits do not change this accepted runtime. + +## 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) + +![Readable multiline comparison](../media/assistant-conversation-craft/artifact-suggestion-review-desktop-light.png) +![Touch composer with readable language and separate Stop/Send](../media/assistant-conversation-craft/waiting-pt-320-touch-light.png) + +## 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. +- Independent spec/code reviews and final screenshot/decoded-recording review approved this runtime. + +## 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 | +| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Compact light assistant header | Compact dark assistant header | + +| Light history | Dark history | +| -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Current light history pane with shared leading content | Current dark history pane with visible resting selection | + +[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 | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Custom model fields with no inter-field gap | Custom model fields with sixteen pixel group gaps | + +[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 | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| ![Previous history composition](../media/assistant-craft/2026-10-21-before-history-light.png) | ![Final history composition](../media/assistant-craft/2026-10-21-history-light.png) | +| ![Previous artifact hierarchy](../media/assistant-craft/2026-10-21-before-artifacts-light.png) | ![Final parallel Artifacts destination](../media/assistant-craft/2026-10-21-artifacts-light.png) | + +## 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/media/assistant-conversation-craft/acceptance.json b/docs/media/assistant-conversation-craft/acceptance.json new file mode 100644 index 000000000..3a3c535f5 --- /dev/null +++ b/docs/media/assistant-conversation-craft/acceptance.json @@ -0,0 +1,18 @@ +{ + "status": "accepted", + "sourceCommit": "d6c0411372e168a5a1c124c82408b7499e7e3cc2", + "ranCommit": "72c44840707ee5dc88a3e60e5a4906f73a7fc98d", + "summary": "Complete conversation and artifact workflows passed independent screenshot and decoded recording review.", + "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." + ] +} 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/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-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/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-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/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..3b39f6477 100644 --- a/lib/agent-plugin/ui/artifact-presentation.ts +++ b/lib/agent-plugin/ui/artifact-presentation.ts @@ -1,3 +1,4 @@ +import { renderChatMarkdown } from '@ranuts/chat-ui'; import { validatePayload, payloadText, type ArtifactPayload } from '../artifacts/payload'; import { artifactFilename, exportPayload, type ArtifactExportFormat } from '../artifacts/export'; export interface ArtifactPresentationLabels { @@ -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,15 @@ 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'; + 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 +38,15 @@ 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); + return; + } 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)); @@ -44,7 +55,7 @@ export function createArtifactPresentation( for (const [columnIndex, column] of data.columns.entries()) { const cell = document.createElement('th'); cell.append( - field(column, `${labels.preview} · ${columnIndex + 1}`, (value) => { + valueField(column, `${labels.preview} · ${columnIndex + 1}`, (value) => { data.columns[columnIndex] = value; commit(data); }), @@ -57,7 +68,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); }), @@ -71,11 +82,11 @@ export function createArtifactPresentation( } 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 +103,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 +122,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 +137,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,14 +201,25 @@ 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'; }, - dispose: () => abort.abort(), + dispose() { + abort.abort(); + disclosure.remove(); + }, setLabels(next: ArtifactPresentationLabels) { labels = next; summary.textContent = labels.export; diff --git a/lib/agent-plugin/ui/artifact-workflow.ts b/lib/agent-plugin/ui/artifact-workflow.ts index 0c82ff034..dc55777e1 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; @@ -375,6 +403,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..59fa00d16 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; @@ -63,6 +68,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 +80,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) { @@ -141,7 +158,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,6 +196,7 @@ export function createArtifactsView(options: { workflow?.dispose(); workflow = undefined; captureDraft = undefined; + setEditorMode = undefined; cancelConfirmation?.(); cancelConfirmation = undefined; buttons.clear(); @@ -226,6 +244,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'); @@ -244,15 +263,26 @@ export function createArtifactsView(options: { const presentationHost = document.createElement('div'); 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 +293,7 @@ export function createArtifactsView(options: { select.addEventListener('change', () => { const value = select.value; const load = () => { + clearFeedback(); invalidateDraft(); selectedVersion = value; title.value = a.title; @@ -276,10 +307,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 +339,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 +348,82 @@ 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 toolbar = Div().class('agent-artifact-toolbar').children(select, modes, dirtyNote).build(); + const content = Div() + .class('agent-artifact-content') + .children(...(source.hidden ? [text] : []), presentationHost, source) + .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(title, toolbar, actions, deletePrompt, content, origin) .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 +435,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 +467,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(); @@ -396,14 +487,14 @@ export function createArtifactsView(options: { 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 +507,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 +593,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 +600,7 @@ export function createArtifactsView(options: { } return { el, - refresh, + refresh: () => refresh(), save(text: string, origin?: ArtifactProvenance): Promise { if (disposed || busy) return Promise.resolve('cancelled'); return new Promise((resolve) => { @@ -551,6 +641,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, @@ -594,8 +685,13 @@ export function createArtifactsView(options: { labels = next; 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 +699,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'); 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-workspace.ts b/lib/agent-plugin/ui/panel-workspace.ts index 5ffecc4fd..142680b0e 100644 --- a/lib/agent-plugin/ui/panel-workspace.ts +++ b/lib/agent-plugin/ui/panel-workspace.ts @@ -12,6 +12,7 @@ interface WorkspaceSurface { export function createPanelWorkspace(options: { panel: HTMLElement; chat: Pick; + chatTrigger?: HTMLElement; navigation: Pick, 'capture' | 'restore'>; conversation: () => string; isOpen: () => boolean; @@ -21,27 +22,41 @@ export function createPanelWorkspace(options: { changed: (view: PanelWorkspaceView) => void; }) { const { panel, chat, views, runtime, navigation } = options; + let initialized = false; + 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); + initialized = true; if (panel.dataset.view === 'chat' && view !== 'chat') 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'; + 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.trigger.addEventListener('click', () => show(name as PanelWorkspaceView)); surface.el.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented || event.keyCode === 229) return; event.stopPropagation(); diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index c4605b468..ddc58e5b5 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'; @@ -315,8 +315,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 +333,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 +349,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,6 +447,8 @@ 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'), @@ -458,6 +464,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'), @@ -979,6 +988,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 +1220,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 +1255,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 +1270,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 +2474,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 +2753,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. @@ -2808,8 +2849,6 @@ export function createAgentPanel( 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')); @@ -2906,8 +2945,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 +2962,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 +3024,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 +3103,32 @@ 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); const workspace = createPanelWorkspace({ panel, chat, + chatTrigger: chatBtn, navigation, conversation: () => displayedConversationId, isOpen: () => open, @@ -3062,8 +3136,12 @@ 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.el.querySelector('h2')?.focus(), + }, }, beforeChange: (view) => { speech?.close(); @@ -3073,6 +3151,8 @@ export function createAgentPanel( viewRevision++; }, changed: (view) => { + syncHeaderIdentity(); + sourcesBtn.setAttribute('aria-expanded', String(view === 'sources')); if (view === 'artifacts') void artifactsView.refresh(); syncHistoryActivity(); syncRuntimeStatus(); @@ -3081,25 +3161,44 @@ export function createAgentPanel( function showView(view: PanelWorkspaceView): void { workspace.show(view); } + const headerIcons = { + artifacts: + '', + history: + '', + }; + function syncHeaderIdentity(): void { + const inChat = panel.dataset.view === 'chat'; + 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 +3221,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 +3232,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 +3245,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/task-entry.ts b/lib/agent-plugin/ui/task-entry.ts index 13bc9ce61..c7fda0ae0 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 = `${taskIcons[item.id] ?? taskIcons.read}`; + button.prepend(icon); button.addEventListener('click', () => { if (options.getInput().trim()) { help.textContent = t('agentEntryDraftPresent'); 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..3a892632a 100644 --- a/lib/agent-plugin/ui/workspace-labels.ts +++ b/lib/agent-plugin/ui/workspace-labels.ts @@ -51,6 +51,10 @@ export function artifactLabels(): ArtifactsLabels { import: t('agentArtifactImport'), importHint: t('agentArtifactImportHint'), preview: t('agentArtifactPreview'), + edit: t('agentArtifactEdit'), + cancelEditing: t('agentArtifactCancelEditing'), + sourceData: t('agentArtifactSourceData'), + unsaved: t('agentArtifactUnsaved'), next: t('agentArtifactNext'), previous: t('agentArtifactPrevious'), structuredHint: t('agentArtifactStructuredHint'), @@ -80,6 +84,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..0bef97f24 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,55 @@ 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 and styling; use a +`cui-bubble` container inside the existing ChatView style scope when sharing the +conversation typography. 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/src/chat-view.ts b/packages/chat-ui/src/chat-view.ts index 0817ed794..35e279bef 100644 --- a/packages/chat-ui/src/chat-view.ts +++ b/packages/chat-ui/src/chat-view.ts @@ -1,5 +1,6 @@ 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 { StreamingMarkdown, STREAMING_MARKDOWN_LIMIT } from './streaming-markdown'; @@ -28,6 +29,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 +90,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 +136,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 = ''; @@ -194,13 +230,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 +278,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 +334,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(); } @@ -661,12 +702,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) { @@ -691,6 +840,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); @@ -767,6 +917,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 +965,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 +975,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 +999,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 +1013,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 +1063,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; @@ -1037,6 +1198,14 @@ export class ChatView { /** Update labels (e.g. on a language change) and re-apply them live. */ setLabels(labels: ChatViewLabels): void { this.labels = 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 +1350,7 @@ export class ChatView { this.restoredHistory = []; this.earlierButton = undefined; this.cancelInterruptedRenders(); + this.discardReasoning(); this.running = false; this.updateActivity(); this.messagesEl.setAttribute('aria-busy', 'false'); @@ -1205,6 +1375,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..60c9fb09f 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, diff --git a/packages/chat-ui/src/styles.ts b/packages/chat-ui/src/styles.ts index 47ea46e68..b15031f5d 100644 --- a/packages/chat-ui/src/styles.ts +++ b/packages/chat-ui/src/styles.ts @@ -11,7 +11,7 @@ import { Style } from 'ranui/builder'; */ 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-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:calc(var(--ran-space-4, 16px) + var(--ran-space-1, 4px)); 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); } @@ -33,7 +33,7 @@ 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 p:last-child { margin-bottom:0; } @@ -46,7 +46,8 @@ export const CHAT_UI_CSS = ` .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 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 pre code { padding:0; background:none; font-size:inherit; } +.cui-bubble pre { font-size:var(--ran-text-label-2, 13px); tab-size:2; text-wrap:nowrap; } .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); } @@ -55,11 +56,11 @@ export const CHAT_UI_CSS = ` .cui-bubble pre,.cui-table { max-width:100%; box-sizing:border-box; overscroll-behavior-x:contain; } .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 table { border-collapse:collapse; font-size:var(--ran-text-label-2, 13px); line-height:1.55; 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); } .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; } @@ -79,7 +80,9 @@ export const CHAT_UI_CSS = ` .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-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; --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 var(--ran-space-4, 16px); padding:var(--ran-space-2, 8px) 0; 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; } @@ -91,31 +94,34 @@ export const CHAT_UI_CSS = ` .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-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); } } @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..04290ecf9 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). */ @@ -42,6 +46,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..b1a59f70e 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -92,6 +92,7 @@ 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', agentSetupFailed: 'Assistent konnte nicht geöffnet werden. Bitte erneut versuchen. Das Dokument ist unverändert.', @@ -403,6 +404,11 @@ export const de: Partial = { 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,6 +533,10 @@ 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', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index 6df0965c5..5baee557c 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -86,6 +86,7 @@ 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', agentSetupFailed: 'Could not open the assistant. Try again; your document is unchanged.', @@ -382,6 +383,11 @@ export const en: I18nMessages = { 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 +506,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..631ddf185 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -89,6 +89,7 @@ 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', agentSetupFailed: 'No se pudo abrir el asistente. Inténtalo de nuevo. El documento no ha cambiado.', @@ -391,6 +392,11 @@ export const es: Partial = { 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,6 +519,10 @@ 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', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index c7fa93d99..bf0ca44db 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -88,6 +88,7 @@ export const ja: Partial = { agentUseServiceHint: '送信した文章は選択したサービスで処理します。サービス情報が必要で、料金が発生する場合があります。', agentPreferences: 'その他の設定', + agentWorkspaceChat: 'チャット', agentBackToChat: '会話に戻る', agentExpand: 'アシスタントを広げる', agentSetupFailed: '開けませんでした。再試行してください。文書は変更されていません。', @@ -386,6 +387,11 @@ export const ja: Partial = { agentRestoreRequest: 'リクエストを復元', agentRoutingRequest: 'リクエストを確認中…', agentWaitElapsed: '{seconds} 秒経過', + agentComposerLabel: 'メッセージ入力', + agentComposerActionsLabel: 'メッセージ操作', + agentReasoning: '思考過程', + agentReasoningInterrupted: '思考を中断しました', + agentReasoningTruncated: '思考内容の表示を省略しました', agentWaiting: '回答を生成中…', agentReadingContent: '現在の内容を読み取り中…', agentPreparingTask: 'リクエストを準備中…', @@ -506,6 +512,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..113d34644 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -87,6 +87,7 @@ export const ko: Partial = { agentUseServiceHint: '보낸 글은 선택한 서비스에서 처리됩니다. 본인의 서비스 정보가 필요하며 요금이 발생할 수 있습니다.', agentPreferences: '추가 설정', + agentWorkspaceChat: '채팅', agentBackToChat: '대화로 돌아가기', agentExpand: '도우미 확대', agentSetupFailed: '도우미를 열 수 없습니다. 다시 시도하세요. 문서는 바뀌지 않았습니다.', @@ -375,6 +376,11 @@ export const ko: Partial = { agentRestoreRequest: '요청 복원', agentRoutingRequest: '요청을 파악하는 중…', agentWaitElapsed: '{seconds}초 경과', + agentComposerLabel: '메시지 입력', + agentComposerActionsLabel: '메시지 작업', + agentReasoning: '생각 과정', + agentReasoningInterrupted: '생각 중단됨', + agentReasoningTruncated: '생각 내용이 잘렸습니다', agentWaiting: '응답 생성 중…', agentReadingContent: '현재 내용을 읽는 중…', agentPreparingTask: '요청을 준비하는 중…', @@ -492,6 +498,10 @@ export const ko: Partial = { agentSourcesSearch: '소스 검색', agentSourcesOpen: '소스 열기', agentSourcesExcerpt: '요청 전송 시 자료의 제한된 발췌문을 선택한 AI 서비스에 보냅니다.', + agentArtifactEdit: '편집', + agentArtifactCancelEditing: '편집 취소', + agentArtifactSourceData: '원본 데이터', + agentArtifactUnsaved: '저장하지 않은 변경 사항', agentArtifactTitle: '아티팩트', agentArtifactEmpty: '결과를 저장하고 이 기기에서 편집하며 버전을 보관하세요.', agentArtifactBack: '뒤로', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index 166e59206..171156d99 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -88,6 +88,7 @@ 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', agentSetupFailed: 'Não foi possível abrir o assistente. Tente novamente. O documento não mudou.', @@ -387,6 +388,11 @@ export const pt: Partial = { 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,6 +514,10 @@ 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', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index 01ae562db..8cb2e73fe 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -81,6 +81,7 @@ export const zhCN: I18nMessages = { agentUseService: '连接 AI 服务', agentUseServiceHint: '支持本机服务和云端 API。请求内容会发送给所选服务;费用以服务商说明为准。', agentPreferences: '高级设置', + agentWorkspaceChat: '对话', agentBackToChat: '返回对话', agentExpand: '展开助手', agentSetupFailed: '暂时无法开启助手,请重试。你的文档没有改变。', @@ -361,6 +362,11 @@ export const zhCN: I18nMessages = { agentRestoreRequest: '恢复请求', agentRoutingRequest: '正在理解你的请求…', agentWaitElapsed: '已等待 {seconds} 秒', + agentComposerLabel: '消息输入区', + agentComposerActionsLabel: '消息操作', + agentReasoning: '思考过程', + agentReasoningInterrupted: '思考已中断', + agentReasoningTruncated: '思考内容过长,已截断', agentWaiting: '正在生成回复…', agentReadingContent: '正在读取当前内容…', agentPreparingTask: '正在准备任务…', @@ -475,6 +481,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..c649fe9f9 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -111,6 +111,7 @@ export interface I18nMessages { agentUseService: string; agentUseServiceHint: string; agentPreferences: string; + agentWorkspaceChat: string; agentBackToChat: string; agentExpand: string; agentSetupFailed: string; @@ -370,6 +371,11 @@ export interface I18nMessages { agentCopied: string; agentCopyFailed: string; agentRestoreRequest: string; + agentComposerLabel: string; + agentComposerActionsLabel: string; + agentReasoning: string; + agentReasoningInterrupted: string; + agentReasoningTruncated: string; agentWaiting: string; agentRoutingRequest: string; agentWaitElapsed: string; @@ -490,6 +496,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..cac5c5ab7 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,17 @@ +# Dependency patches + +`ranui@0.5.0.patch` carries the upstream `r-reasoning` summary correction in the +ES module consumed by this application. The 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 matching source change, component regression tests and browser verification +are maintained in the ran PR. Keep this patch until a ranui release includes that +correction; 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..a6c37e673 --- /dev/null +++ b/patches/ranui@0.5.0.patch @@ -0,0 +1,13 @@ +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/pnpm-lock.yaml b/pnpm-lock.yaml index bd935ccf8..db0a16b19 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: 41b5bf64a6e3a282cb9b160f94d475fd8b801d0d3ebaa38e7c4ea15a4264b79a 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=41b5bf64a6e3a282cb9b160f94d475fd8b801d0d3ebaa38e7c4ea15a4264b79a) ranuts: specifier: 0.5.0 version: 0.5.0 @@ -109,7 +110,7 @@ importers: version: 16.4.2 ranui: specifier: 0.5.0 - version: 0.5.0 + version: 0.5.0(patch_hash=41b5bf64a6e3a282cb9b160f94d475fd8b801d0d3ebaa38e7c4ea15a4264b79a) ranuts: specifier: 0.5.0 version: 0.5.0 @@ -125,7 +126,7 @@ importers: version: link:../shared ranui: specifier: 0.5.0 - version: 0.5.0 + version: 0.5.0(patch_hash=41b5bf64a6e3a282cb9b160f94d475fd8b801d0d3ebaa38e7c4ea15a4264b79a) ranuts: specifier: 0.5.0 version: 0.5.0 @@ -2237,7 +2238,7 @@ snapshots: punycode@2.3.1: {} - ranui@0.5.0: + ranui@0.5.0(patch_hash=41b5bf64a6e3a282cb9b160f94d475fd8b801d0d3ebaa38e7c4ea15a4264b79a): 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..fce23ce36 --- /dev/null +++ b/styles/assistant-artifacts.css @@ -0,0 +1,240 @@ +/* Artifact workbench: source owns edits; the reader owns the reading surface. */ +.agent-artifact-workbench { + display: flex; + flex-direction: column; + gap: var(--ran-space-3); + min-width: 0; +} +.agent-artifact-workbench > .agent-artifact-title { + margin: 0; + border: 0; + border-radius: 0; + padding-inline: 0; + min-width: 0; + font-size: var(--ran-text-heading-4); + font-weight: var(--ran-text-heading-weight); + line-height: 1.4; + padding-block: var(--ran-space-1); +} +.agent-artifact-workbench > .agent-artifact-title[readonly] { + cursor: default; +} +.agent-artifact-toolbar, +.agent-artifact-workbench > .agent-artifact-actions, +.agent-artifact-modes, +.agent-artifact-delete-prompt, +.agent-artifact-pagination { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--ran-space-2); + min-width: 0; +} +.agent-artifact-toolbar > select { + width: auto; + max-width: 100%; + min-height: var(--app-control-height, 32px); + border: 0; + padding-inline-start: 0; + color: var(--ran-color-text-secondary); +} +.agent-artifact-modes { + gap: var(--ran-space-1); +} +.agent-artifact-modes > button[aria-pressed='true'] { + background: var(--ran-color-bg-muted); + font-weight: var(--ran-text-heading-weight); +} +.agent-artifact-unsaved { + color: var(--ran-color-text-secondary); + font-size: var(--ran-text-label-3); +} +.agent-artifact-workbench > .agent-artifact-actions { + margin: 0; + padding-block-end: var(--ran-space-3); + border-bottom: 1px solid var(--ran-color-border); +} +.agent-artifact-workbench > .agent-artifact-actions > button, +.agent-artifact-workbench .agent-artifact-delete-prompt > button { + flex: 0 1 auto; + width: auto; +} +.agent-artifact-delete-prompt { + padding-block: var(--ran-space-2); +} +.agent-artifact-delete-prompt > span { + flex: 1 1 100%; + font-size: var(--ran-text-label-2); +} +.agent-artifact-workbench .agent-artifact-content, +.agent-artifact-workbench .agent-artifact-presentation { + min-width: 0; +} +.agent-artifact-workbench .agent-artifact-content > textarea, +.agent-artifact-workbench .agent-artifact-source > textarea { + width: 100%; + min-height: 18em; + line-height: 1.65; + resize: vertical; + font-family: inherit; + font-size: var(--ran-text-label-1); +} +.agent-artifact-workbench .agent-artifact-content > textarea { + border: 0; + border-radius: 0; + padding-inline: 0; +} +.agent-artifact-workbench .agent-artifact-source > textarea { + font-family: var(--ran-font-mono, monospace); + margin-block-start: var(--ran-space-2); +} +.agent-artifact-reader { + color: inherit; + font-size: var(--ran-text-label-1); + line-height: 1.7; + overflow-wrap: anywhere; + overflow-x: auto; +} +.agent-artifact-reader > :first-child { + margin-block-start: 0; +} +.agent-artifact-reader > :last-child { + margin-block-end: 0; +} +.agent-artifact-reader :is(h2, h3, h4, h5, h6) { + margin-block: var(--ran-space-6) var(--ran-space-2); + line-height: 1.4; + font-weight: var(--ran-text-heading-weight); +} +.agent-artifact-reader :is(p, ul, ol, blockquote, pre) { + margin-block: var(--ran-space-3); +} +.agent-artifact-reader :is(ul, ol) { + padding-inline-start: var(--ran-space-6); +} +.agent-artifact-reader a { + color: var(--ran-color-link); + text-decoration: underline; +} +.agent-artifact-reader blockquote { + 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 pre, +.agent-artifact-reader .cui-table { + overflow: auto; + max-width: 100%; +} +.agent-artifact-reader pre { + padding: var(--ran-space-3); + border-radius: var(--ran-radius-sm); + background: var(--ran-color-bg-muted); + font-size: var(--ran-text-label-2); + line-height: 1.55; +} +.agent-artifact-reader code { + font-family: var(--ran-font-mono, monospace); +} +.agent-artifact-reader table { + width: 100%; + border-collapse: collapse; + font-size: var(--ran-text-label-2); +} +.agent-artifact-reader :is(th, td) { + text-align: start; + vertical-align: top; + padding: var(--ran-space-2); + border-bottom: 1px solid var(--ran-color-border); +} +.agent-artifact-field-value { + white-space: pre-wrap; +} +.agent-artifact-reader > .agent-artifact-field-value:first-child { + font-size: var(--ran-text-heading-4); + font-weight: var(--ran-text-heading-weight); +} +.agent-artifact-pagination { + margin-block-start: var(--ran-space-3); + color: var(--ran-color-text-secondary); +} +.agent-artifact-export { + max-width: 100%; +} +.agent-artifact-export > summary, +.agent-artifact-source > summary { + cursor: pointer; + font-size: var(--ran-text-label-3); + min-height: var(--ran-size-7); + align-content: center; + border-radius: var(--ran-radius-sm); +} +.agent-artifact-workbench :is(.agent-artifact-reader, textarea, summary, select, input):focus-visible { + outline: 2px solid var(--ran-color-link); + outline-offset: 2px; +} +.agent-artifact-workbench [hidden] { + display: none; +} +.agent-artifact-workbench[data-mode='preview'] .agent-artifact-reader, +.agent-artifact-workbench[data-mode='edit'] .agent-artifact-content { + animation: agent-artifact-reveal var(--ran-motion-duration-fast) ease-out; +} +@keyframes agent-artifact-reveal { + from { + opacity: 0.55; + } + to { + opacity: 1; + } +} +@media (pointer: coarse) { + .agent-artifact-workbench :is(button, summary, select) { + min-height: var(--app-control-touch, 44px); + } +} +@media (prefers-reduced-motion: reduce) { + .agent-artifact-workbench[data-mode] :is(.agent-artifact-reader, .agent-artifact-content) { + animation: none; + } +} + +/* 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..8ec1ffe26 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -66,7 +66,7 @@ body.agent-panel-resizing #app iframe { display: none; } .agent-panel-clear:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-panel-clear { @@ -125,29 +125,40 @@ body.agent-panel-resizing #app iframe { display: none; } .agent-history-management > summary::after { - content: '+'; - font-size: var(--ran-text-copy-1); + content: ''; + width: 6px; + height: 6px; + margin-inline-end: var(--ran-space-1); + border-inline-end: 1.5px solid currentColor; + border-block-end: 1.5px solid currentColor; + transform: rotate(45deg); } .agent-history-management[open] > summary::after { - content: '−'; + transform: rotate(225deg); } .agent-history-management label { margin-top: var(--ran-space-3); } .agent-history-options label { display: flex; - align-items: flex-start; + align-items: center; gap: var(--ran-space-2); + padding: var(--ran-space-2); + margin-inline: calc(-1 * var(--ran-space-2)); + margin-block-start: var(--ran-space-2); + font-size: var(--ran-text-label-2); } .agent-history-save { flex: 0 0 var(--ran-size-1); width: var(--ran-size-1); height: var(--ran-size-1); - margin: var(--ran-space-1) 0 0; + margin: 0; accent-color: var(--ran-color-primary); } .agent-history-status { - margin: var(--ran-space-2) 0 var(--ran-space-4); + margin: 0 0 var(--ran-space-4); + padding-inline-start: var(--ran-space-6); + font-size: var(--ran-text-label-3); color: var(--ran-color-text-secondary); overflow-wrap: anywhere; } @@ -160,14 +171,14 @@ body.agent-panel-resizing #app iframe { display: flex; flex-direction: column; gap: var(--ran-space-4); - /* Own 8px of the panel gutter so the visible icon/label columns stay - aligned with the checkbox while each button keeps inline padding. */ - margin-inline-start: calc(-1 * var(--ran-space-2)); + /* Hit surfaces use the existing gutter. Visible leading icons stay at 16px, + with labels and their status at 40px, including the checkbox row above. */ + margin-inline: calc(-1 * var(--ran-space-2)); } .agent-history-transfer-actions, .agent-history-danger-actions { display: grid; - justify-items: start; + justify-items: stretch; gap: var(--ran-space-2); min-width: 0; } @@ -208,27 +219,30 @@ body.agent-panel-resizing #app iframe { border-color: var(--ran-color-border-active); background: var(--ran-color-bg-active); } -/* The pane establishes the region; only each native button owns a surface. - A resting control shape must remain visible without pointer discovery. */ +/* A named command group supplies context; resting rows need no repeated frame. + Hover/focus targets include 8px of the owned gutter without shifting content. */ .agent-history-actions button { - width: fit-content; - min-height: var(--app-control-touch); + width: 100%; + min-height: var(--app-control-height); justify-content: flex-start; gap: var(--ran-space-2); - padding: var(--ran-space-3) var(--ran-space-2); + padding: var(--ran-space-1) var(--ran-space-2); border: 0; border-radius: var(--ran-radius-sm); - background: var(--ran-color-bg-hover); - box-shadow: inset 0 0 0 1px var(--ran-color-border); + background: transparent; + box-shadow: none; text-align: start; - font-size: var(--ran-text-label-1); - font-weight: var(--ran-text-label-weight); + font-size: var(--ran-text-label-2); + font-weight: var(--ran-text-copy-weight); +} +.agent-history-options .agent-history-actions button:disabled { + background: transparent; } .agent-history-actions button:enabled:hover { - background: var(--ran-color-bg-active); + background: var(--ran-color-bg-hover); } .agent-history-actions button:enabled:active { - background: color-mix(in srgb, var(--ran-color-bg-active) 96%, var(--ran-color-text)); + background: var(--ran-color-bg-active); } .agent-history-actions button svg { flex: 0 0 var(--ran-size-1); @@ -275,7 +289,7 @@ body.agent-panel-resizing #app iframe { } .agent-history-options button:focus-visible, .agent-history-options input:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-history-actions button:focus-visible { @@ -326,7 +340,7 @@ body.agent-panel-resizing #app iframe { color: var(--ran-color-success); } .agent-panel .cui-message-actions .cui-apply:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-panel .cui-msg-tool .cui-bubble, @@ -539,8 +553,6 @@ r-dropdown[role='listbox'] { color: var(--ran-color-text); } .agent-panel-settings-toggle { - /* The title owns flex:1, so the gear already sits against the trailing - controls; a margin here would fight the close button beside it. */ border: none; background: none; font-size: var(--ran-text-copy-1); @@ -552,7 +564,7 @@ r-dropdown[role='listbox'] { .agent-panel-quote:focus-visible, .agent-writing-task:focus-visible, .agent-writing-language:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-panel-review { @@ -843,7 +855,7 @@ r-dropdown[role='listbox'] { font-size: var(--ran-text-label-3); } .agent-model-sources summary:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-model-sources r-input { @@ -856,14 +868,9 @@ r-dropdown[role='listbox'] { /* Content, text and navigation have different boxes but named shared rails. */ --agent-content-inset: var(--ran-space-4); --agent-title-inset: var(--ran-space-2); - --agent-navigation-size: var(--app-control-height); - --agent-navigation-inset: calc((var(--agent-navigation-size) - var(--ran-size-1)) / 2); - /* Status lines beside the composer (model state, readiness, write - destination) line up with the composer's *text*, not its edge: footer - padding plus the composer's own padding. Three rules must agree, so the - sum is named once here instead of repeated as arithmetic (DESIGN.md §11, - "When a value must equal another value, name it"). */ - --agent-composer-inset: calc(var(--ran-space-4) + var(--ran-space-1)); + /* The quoted selection follows the input text inside the composer border. */ + --agent-composer-padding: var(--ran-space-3); + --agent-composer-inset: calc(var(--agent-content-inset) + var(--agent-composer-padding) + 1px); position: fixed; top: 0; right: 0; @@ -887,23 +894,67 @@ r-dropdown[role='listbox'] { display: flex; align-items: center; justify-content: space-between; - border-bottom: 1px solid var(--ran-color-border); box-sizing: border-box; - min-height: 56px; + min-height: calc(var(--app-control-height) + var(--ran-space-2)); flex: 0 0 auto; - padding: var(--ran-space-2) var(--agent-content-inset); + padding: var(--ran-space-1) var(--agent-content-inset); gap: calc(var(--ran-space-1) / 2); } +.agent-workspace-nav { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: calc(var(--ran-space-1) / 2); + min-width: 0; +} +.agent-workspace-nav button { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: var(--app-control-height); + min-width: var(--app-control-height); + min-height: var(--app-control-height); + padding: var(--ran-space-1); + border: 0; + border-radius: var(--ran-radius-sm); + background: transparent; + color: var(--ran-color-text-secondary); + font: inherit; + font-size: var(--ran-text-label-3); + font-weight: var(--ran-text-copy-weight); + line-height: var(--ran-line-height); + white-space: nowrap; + cursor: pointer; +} +.agent-workspace-nav button:hover { + color: var(--ran-color-text); + background: var(--ran-color-bg-hover); +} +.agent-workspace-nav button[aria-current='page'] { + font-weight: var(--ran-text-label-weight); + color: var(--ran-color-text); + background: var(--ran-color-bg-active); +} +.agent-workspace-nav button:active { + background: var(--ran-color-bg-active); +} +.agent-panel .agent-workspace-nav button:focus-visible { + outline: 2px solid var(--ran-color-link); + outline-offset: -2px; +} .agent-panel-header .agent-panel-title { - display: block; + display: inline-flex; flex: 0 1 auto; width: auto; justify-content: flex-start; + gap: var(--ran-space-1); text-align: left; font-family: inherit; - font-weight: var(--ran-text-heading-weight); - font-size: var(--ran-text-copy-2); - letter-spacing: -0.02em; + font-weight: var(--ran-text-label-weight); + font-size: var(--ran-text-label-2); + letter-spacing: normal; min-width: 0; /* Extend the hit area into the gutter; text stays on the content rail. Trailing free space belongs to layout, never to the title's hover fill. */ @@ -916,6 +967,16 @@ r-dropdown[role='listbox'] { color: var(--ran-color-text); background: transparent; } +.agent-header-home-icon { + display: inline-flex; + flex: 0 0 auto; +} +.agent-panel-header .agent-panel-title[aria-current='page'] { + background: transparent; +} +.agent-panel-header .agent-panel-title:hover { + background: var(--ran-color-bg-hover); +} .agent-panel-settings { display: flex; flex-direction: column; @@ -931,13 +992,12 @@ r-dropdown[role='listbox'] { .agent-panel-settings-hidden { display: none; } -.agent-panel[data-view='settings'] > .agent-runtime-row, -.agent-panel[data-view='history'] > .agent-runtime-row { +.agent-panel:not([data-view='chat']) > .agent-runtime-row { display: none; } .agent-panel-settings h2 { margin: 0; - font-size: var(--ran-text-heading-3); + font-size: var(--ran-text-heading-4); font-weight: 600; letter-spacing: -0.045em; line-height: 1.3; @@ -1089,7 +1149,7 @@ r-dropdown[role='listbox'] { .agent-enable-switch:focus-visible, .agent-use-card summary:focus-visible, .agent-preferences summary:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 4px; } .agent-entry-note { @@ -1105,7 +1165,7 @@ r-dropdown[role='listbox'] { flex: 0 0 auto; order: 3; gap: var(--ran-space-2); - padding: 0 var(--agent-composer-inset) var(--ran-space-3); + padding: var(--ran-space-1) 0; margin: 0; font-size: var(--ran-text-label-3); line-height: 1.6; @@ -1118,11 +1178,51 @@ r-dropdown[role='listbox'] { border-top: 0; padding: var(--ran-space-3) var(--ran-space-4) var(--ran-space-4); } +.agent-composer-meta { + display: flex; + align-items: center; + min-width: 0; + padding-block-start: var(--ran-space-1); +} +.agent-model-shortcut { + display: inline-flex; + align-items: center; + gap: var(--ran-space-2); + min-width: 0; + max-width: 100%; + min-height: var(--app-control-height); + padding: var(--ran-space-1); + margin-inline-start: calc(-1 * var(--ran-space-1)); + border: 0; + border-radius: var(--ran-radius-sm); + background: transparent; + color: var(--ran-color-text-secondary); + font: inherit; + font-size: var(--ran-text-label-3); + cursor: pointer; +} +.agent-model-shortcut-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.agent-model-shortcut-icon { + display: inline-flex; + flex: 0 0 auto; +} +.agent-model-shortcut:hover { + color: var(--ran-color-text); + background: var(--ran-color-bg-hover); +} +.agent-model-shortcut:focus-visible { + outline: 2px solid var(--ran-color-link); + outline-offset: 2px; +} .agent-panel .cui-composer { border-color: var(--ran-color-border); background: var(--ran-color-bg-elevated); border-radius: var(--ran-radius-md); - padding: var(--ran-space-3); + padding: var(--agent-composer-padding); } .agent-panel .cui-msg-user .cui-bubble { padding: var(--ran-space-3) var(--ran-space-4); @@ -1160,9 +1260,6 @@ r-dropdown[role='listbox'] { } } @media (max-width: 480px) { - .agent-panel-header { - padding: var(--ran-space-2) var(--ran-space-4); - } /* No per-width button geometry: the shared 32px target stays, and the coarse-pointer block grows every header control to --app-control-touch together. A 34x40 box here made the row ragged against the 44px clear @@ -1171,11 +1268,6 @@ r-dropdown[role='listbox'] { .agent-history-view { padding: var(--ran-space-4); } - .agent-panel-header .agent-panel-title { - flex: 0 1 auto; - width: auto; - font-size: var(--ran-text-copy-2); - } .agent-panel .cui-footer { padding: var(--ran-space-3) var(--ran-space-4) max(var(--ran-space-3), env(safe-area-inset-bottom)); } @@ -1185,9 +1277,6 @@ r-dropdown[role='listbox'] { max-height: none; padding: var(--ran-space-4); } - .agent-panel-header { - min-height: 48px; - } .agent-readiness { padding-bottom: calc(var(--ran-space-3) / 2); } @@ -1198,10 +1287,29 @@ r-dropdown[role='listbox'] { } } +/* A brief content cue marks a real view change without moving hit targets or + creating a transformed ancestor for controls and overlays. */ +@keyframes agent-view-enter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@media (prefers-reduced-motion: no-preference) { + .agent-panel :is(.agent-view-header, .agent-history-management[open] > .agent-history-actions) { + animation: agent-view-enter var(--ran-motion-duration-fast) ease-out; + } + .agent-history-management > summary::after { + transition: transform var(--ran-motion-duration-fast) ease-out; + } +} + .agent-panel-write-destination { flex: 0 0 auto; order: 4; - padding: 0 var(--agent-composer-inset) var(--ran-space-3); + padding: var(--ran-space-1) 0; font-size: var(--ran-text-label-3); line-height: 1.6; color: var(--ran-color-text-secondary); @@ -1302,7 +1410,7 @@ r-dropdown[role='listbox'] { display: none; } .agent-use-card .agent-model-memory { - margin: var(--ran-space-3) 0 0; + margin: var(--ran-space-2) 0 0; font-size: var(--ran-text-label-3); display: flex; align-items: center; @@ -1326,10 +1434,6 @@ r-dropdown[role='listbox'] { margin: var(--ran-space-1) 0 0; accent-color: var(--ran-color-primary); } -.agent-panel-header button[aria-expanded='true']:not(.agent-panel-title) { - background: var(--ran-color-bg-muted); - color: var(--ran-color-text); -} .agent-panel-settings > .agent-panel-note { margin: calc(-1 * var(--ran-space-2)) 0 0; } @@ -1346,6 +1450,7 @@ r-dropdown[role='listbox'] { display: none; } .agent-model-choice r-select { + display: block; width: 100%; } .agent-model-cache { @@ -1398,7 +1503,7 @@ r-dropdown[role='listbox'] { margin-bottom: var(--ran-space-4); } .agent-panel :is(button, summary, r-select, r-input, r-button):focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-quote-context { @@ -1483,7 +1588,7 @@ r-dropdown[role='listbox'] { display: flex; flex-direction: column; align-items: stretch; - gap: var(--ran-space-3); + gap: var(--ran-space-4); padding: 0; margin: 0; border: 0; @@ -1621,7 +1726,7 @@ r-dropdown[role='listbox'] { opacity: 0.6; } .agent-speech :is(button, r-select):focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } @@ -1744,7 +1849,7 @@ r-dropdown[role='listbox'] { } .agent-plan-preview:focus-visible, .agent-plan-preview :is(summary, textarea, r-button):focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } @@ -1767,6 +1872,9 @@ r-dropdown[role='listbox'] { font-weight: 500; margin: 0 0 var(--ran-space-3); } +.agent-custom-sources .agent-settings-field + .agent-settings-field { + margin-block-start: var(--ran-space-4); +} .agent-model-actions { display: flex; @@ -1931,11 +2039,11 @@ r-dropdown[role='listbox'] { .agent-conversation-open:focus-visible, .agent-conversation-delete:focus-visible, .agent-history-management > summary:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-history-empty { - margin: var(--ran-space-8) 0; + margin: var(--ran-space-6) 0; text-align: center; color: var(--ran-color-text-secondary); font-size: var(--ran-text-label-1); @@ -1948,7 +2056,12 @@ r-dropdown[role='listbox'] { font-weight: 400; letter-spacing: 0; font-size: var(--ran-text-label-2); - width: 100%; + /* Own the padded command hit areas, so their shared gutter does not become + overflow inside this contextual task region. Visible text stays on its rail. */ + box-sizing: border-box; + width: calc(100% + var(--ran-space-2) * 2); + margin-inline: calc(-1 * var(--ran-space-2)); + padding-inline: var(--ran-space-2); min-width: 0; } .agent-task-scope { @@ -1968,17 +2081,20 @@ r-dropdown[role='listbox'] { .agent-task-list { display: grid; gap: var(--ran-space-1); - margin-top: var(--ran-space-3); + margin-top: var(--ran-space-2); } .agent-panel .agent-task-action { display: flex; align-items: center; + gap: var(--ran-space-3); text-align: start; - min-height: var(--ran-size-7); - padding: var(--ran-space-2) var(--ran-space-3); - border: 1px solid var(--ran-color-border); + min-height: var(--app-control-height); + padding: var(--ran-space-2); + margin-inline: calc(-1 * var(--ran-space-2)); + width: calc(100% + var(--ran-space-2) * 2); + border: 0; border-radius: var(--ran-radius-sm); - background: var(--ran-color-bg-elevated); + background: transparent; color: var(--ran-color-text); font: inherit; cursor: pointer; @@ -1992,8 +2108,15 @@ r-dropdown[role='listbox'] { .agent-panel .agent-task-action:hover { background: var(--ran-color-bg-muted); } +.agent-task-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 var(--ran-size-1); + color: var(--ran-color-text-secondary); +} .agent-panel .agent-task-action:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 3px; } .agent-task-help, @@ -2042,7 +2165,7 @@ r-dropdown[role='listbox'] { background: var(--ran-color-bg-hover); } .agent-history-more:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } @@ -2152,7 +2275,7 @@ r-dropdown[role='listbox'] { cursor: default; } .agent-request-queue button:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: -2px; } .agent-queue-actions { @@ -2234,7 +2357,7 @@ r-dropdown[role='listbox'] { .agent-workspace-view h2, .agent-artifacts h2 { margin: 0; - font-size: var(--ran-text-heading-3); + font-size: var(--ran-text-heading-4); } .agent-workspace-view h3 { margin: var(--ran-space-6) 0 var(--ran-space-2); @@ -2289,6 +2412,9 @@ r-dropdown[role='listbox'] { text-align: start; } .agent-panel .cui-composer .agent-sources-toggle { + display: inline-flex; + align-items: center; + gap: var(--ran-space-1); min-height: var(--ran-size-7); padding: var(--ran-space-1) var(--ran-space-2); font-size: var(--ran-text-label-3); @@ -2307,24 +2433,22 @@ r-dropdown[role='listbox'] { .agent-artifacts button:focus-visible, .agent-sources-toggle:focus-visible, .agent-workspace-toggle:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } /* View owners keep navigation callbacks; the chrome shares one token rhythm. */ .agent-panel .agent-view-header { display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; gap: var(--ran-space-2); margin-block-end: var(--ran-space-4); - /* The icon viewport returns to the content rail. Its centered hit target - occupies a separate leading navigation column, including coarse input. */ - margin-inline-start: calc(-1 * var(--agent-navigation-inset)); min-width: 0; } .agent-panel .agent-view-header h2 { margin: 0; min-width: 0; - font-size: var(--ran-text-heading-3); + font-size: var(--ran-text-heading-4); font-weight: var(--ran-text-heading-weight); letter-spacing: var(--ran-text-heading-tracking); line-height: 1.3; @@ -2336,22 +2460,23 @@ r-dropdown[role='listbox'] { .agent-panel .agent-view-back { display: inline-flex; align-items: center; - justify-content: center; + justify-content: flex-start; box-sizing: border-box; flex: 0 0 auto; - width: var(--agent-navigation-size); - height: var(--agent-navigation-size); - min-width: var(--agent-navigation-size); - min-height: var(--agent-navigation-size); - padding: var(--ran-space-2); + gap: var(--ran-space-1); + width: auto; + min-height: var(--app-control-height); + padding: var(--ran-space-1) var(--ran-space-2); border: 0; border-radius: var(--ran-radius-sm); color: var(--ran-color-text-secondary); background: transparent; + font: inherit; + font-size: var(--ran-text-label-3); cursor: pointer; } .agent-panel .agent-view-back:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 0; } .agent-workspace-view button:active:enabled, @@ -2364,40 +2489,26 @@ r-dropdown[role='listbox'] { cursor: default; background: transparent; } -.agent-sources-view .agent-source-attach:enabled, -.agent-artifacts - button:is( - [data-action='import'], - [data-action='saveVersion'], - [data-action='saveNew'], - [data-action='accept'] - ):enabled { +.agent-artifacts button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):enabled { background: var(--ran-color-primary); border-color: var(--ran-color-primary); color: var(--ran-color-primary-text); } -.agent-sources-view .agent-source-attach:hover:enabled, -.agent-artifacts - button:is( - [data-action='import'], - [data-action='saveVersion'], - [data-action='saveNew'], - [data-action='accept'] - ):hover:enabled { +.agent-artifacts button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):hover:enabled { background: var(--ran-color-primary-hover); border-color: var(--ran-color-primary-hover); } .agent-artifacts - button:is( - [data-action='import'], - [data-action='saveVersion'], - [data-action='saveNew'], - [data-action='accept'] - ):active:enabled, -.agent-sources-view .agent-source-attach:active:enabled { + button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):active:enabled { background: var(--ran-color-primary-active); border-color: var(--ran-color-primary-active); } +.agent-sources-view .agent-source-attach, +.agent-artifacts button[data-action='import'] { + background: var(--ran-color-bg-elevated); + box-shadow: inset 0 0 0 1px var(--ran-color-border); + color: var(--ran-color-text); +} .agent-source-remove:enabled, .agent-artifacts button[data-action='delete']:enabled { color: var(--app-danger-fill); @@ -2426,11 +2537,27 @@ r-dropdown[role='listbox'] { color: var(--ran-color-text-secondary); line-height: 1.6; } +.agent-artifact-import > p { + margin: 0; + color: var(--ran-color-text-secondary); + font-size: var(--ran-text-label-3); + font-weight: var(--ran-text-copy-weight); + line-height: var(--ran-line-height); + overflow-wrap: anywhere; +} .agent-artifacts-list button { width: 100%; font-size: var(--ran-text-label-2); } @media (pointer: coarse) { + .agent-workspace-nav button { + min-height: var(--app-control-touch); + min-width: var(--app-control-touch); + } + .agent-history-options label { + box-sizing: border-box; + min-height: var(--app-control-touch); + } .agent-enable-switch { min-width: var(--app-control-touch); min-height: var(--app-control-touch); @@ -2440,8 +2567,6 @@ r-dropdown[role='listbox'] { min-width: var(--app-control-touch); } .agent-workspace-view input:not([type='checkbox']):not([type='radio']):not([type='file']), - .agent-artifact-editor > select, - .agent-artifact-editor > input, .agent-artifact-import-field > input, .agent-artifact-workflow form input, .agent-history-search-row { @@ -2480,7 +2605,10 @@ r-dropdown[role='listbox'] { .agent-sources-view .agent-source-preview > summary { box-sizing: border-box; min-height: var(--app-control-height); - padding-block: var(--ran-space-1); + font-size: var(--ran-text-label-3); + line-height: var(--ran-line-height); + /* Center the first line in the density target; wrapped names keep that rail. */ + padding-block: max(0px, calc((var(--app-control-height) - 1lh) / 2)); } .agent-sources-attached:empty { display: none; @@ -2489,18 +2617,51 @@ r-dropdown[role='listbox'] { .agent-sources-status { margin: 0 0 var(--ran-space-4); } +.agent-sources-view > .agent-entry-note:last-child { + margin-block-start: var(--ran-space-2); +} .agent-sources-summary { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + display: flex; + flex-wrap: wrap; + gap: var(--ran-space-1); margin: 0; } +.agent-source-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: var(--app-control-height); + padding: var(--ran-space-1) var(--ran-space-2); + border: 1px solid var(--ran-color-border); + border-radius: var(--ran-radius-sm); + background: transparent; + color: var(--ran-color-text-secondary); + font: inherit; + font-size: var(--ran-text-label-3); + text-align: start; + overflow-wrap: anywhere; + cursor: pointer; +} +.agent-source-chip:hover { + color: var(--ran-color-text); + background: var(--ran-color-bg-hover); +} +.agent-source-chip:focus-visible { + outline: 2px solid var(--ran-color-link); + outline-offset: 2px; +} .agent-artifact-provenance, .agent-artifact-status { color: var(--ran-color-text-secondary); font-size: 0.875em; overflow-wrap: anywhere; } +.agent-artifact-status:has(button) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ran-space-2); +} .agent-sources-status:empty, .agent-artifact-status:empty { display: none; @@ -2515,35 +2676,6 @@ r-dropdown[role='listbox'] { .agent-artifacts-list:not(:empty) { margin-block-end: var(--ran-space-4); } -.agent-artifact-editor > input { - border: 0; - border-radius: 0; - font-weight: 600; - font-size: 1.125em; - padding-inline: 0; - margin-block: var(--ran-space-3); -} -.agent-artifact-editor > select { - border: 0; - width: auto; - max-width: 100%; - padding-inline: 0; - color: var(--ran-color-text-secondary); - margin-block-end: var(--ran-space-2); -} -.agent-artifact-editor > textarea { - field-sizing: content; - min-height: 6em; - max-height: 40vh; - border: 0; - border-radius: 0; - padding-inline: 0; -} -.agent-artifact-editor > input:focus-visible, -.agent-artifact-editor > textarea:focus-visible { - outline: 2px solid var(--ran-color-primary); - outline-offset: 3px; -} .agent-artifact-import-field { display: grid; gap: var(--ran-space-2); @@ -2555,7 +2687,7 @@ r-dropdown[role='listbox'] { max-height: 40vh; } .agent-artifact-import-field :is(input, textarea):focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .agent-artifact-actions button[data-action='apply'] { @@ -2566,25 +2698,17 @@ r-dropdown[role='listbox'] { /* A narrow toolbar keeps a real language label when Stop joins Send. Use the select's public spacing hooks rather than losing its text lane. */ @container agent-panel (max-width: 360px) { - /* Keep the title readable when five touch targets no longer fit beside it. - A separate row preserves both a text-sized hover area and full targets. */ - .agent-runtime-panel .agent-panel-header { - display: grid; - grid-template-columns: repeat(5, var(--app-control-touch)); - justify-content: space-between; - } - .agent-runtime-panel .agent-panel-header .agent-panel-title { - grid-column: 1 / -1; - justify-self: start; - max-width: calc(100% + var(--agent-title-inset)); - margin-inline-end: 0; - } .agent-panel .cui-composer-bar, .agent-panel .cui-actions { gap: var(--ran-space-1); } .agent-panel .cui-composer .agent-sources-toggle { - padding-inline: var(--ran-space-1); + padding-inline: 0; + width: var(--app-control-height); + min-width: var(--app-control-height); + } + .agent-sources-label { + display: none; } .agent-speech-language { --ran-select-padding: 0 var(--ran-space-1); @@ -2642,7 +2766,7 @@ r-dropdown[role='listbox'] { font-weight: 500; } .agent-result-choice button:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } @@ -2681,7 +2805,7 @@ r-dropdown[role='listbox'] { cursor: pointer; } .agent-source-citation:focus-visible { - outline: 2px solid var(--ran-color-primary); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } .cui-bubble .agent-source-preview pre { @@ -2789,15 +2913,6 @@ r-dropdown[role='listbox'] { .agent-artifact-data-preview > textarea.agent-artifact-cell { min-height: 3em; } -.agent-artifact-editor > details > textarea { - font-family: monospace; - font-size: var(--ran-text-label-3); -} -.agent-artifact-editor > details > summary { - cursor: pointer; - color: var(--ran-color-text-secondary); - padding-block: var(--ran-space-2); -} .agent-plan-undo-feedback { display: block; @@ -2857,6 +2972,9 @@ r-dropdown[role='listbox'] { .agent-panel .cui-empty-actions .agent-configure { min-height: var(--app-control-touch); } + .agent-panel .cui-composer .agent-sources-toggle { + min-width: var(--app-control-touch); + } .agent-speech-cancel[hidden] { display: none; } @@ -2867,3 +2985,26 @@ r-dropdown[role='listbox'] { line-height: var(--app-control-touch); } } + +/* On narrow touch panes the voice controls need a real text lane alongside + 44px targets. Keep execution in a stable second row across idle/running. */ +@media (pointer: coarse) { + @container agent-panel (max-width: 360px) { + .agent-panel .cui-composer-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--app-control-touch) var(--app-control-touch); + gap: var(--ran-space-1); + } + .agent-panel .cui-actions { + grid-column: 1 / -1; + } + .agent-panel .cui-stop { + grid-column: 2; + grid-row: 2; + } + .agent-panel .cui-send { + grid-column: 3; + grid-row: 2; + } + } +} diff --git a/test/e2e/agent-artifact-revision.spec.ts b/test/e2e/agent-artifact-revision.spec.ts index 8dd0e34cf..6f88dbc47 100644 --- a/test/e2e/agent-artifact-revision.spec.ts +++ b/test/e2e/agent-artifact-revision.spec.ts @@ -1,4 +1,5 @@ import { mockApi } from './lib/mock-api'; +import { editArtifact } from './lib/artifact-view'; import { expect, test } from './lib/l0'; test('artifact AI revisions stay in an explicit draft review and preserve newer edits', async ({ page }, testInfo) => { @@ -81,7 +82,8 @@ test('artifact AI revisions stay in an explicit draft review and preserve newer await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); await page.locator('.agent-workspace-toggle').click(); await page.locator(`[data-artifact="${artifactId}"]`).click(); - const text = page.locator('.agent-artifact-editor > textarea'); + await page.locator('.agent-artifact-editor [data-action=edit]').click(); + const text = page.locator('.agent-artifact-editor [data-artifact-source]'); await text.evaluate((el: HTMLTextAreaElement) => { el.focus(); el.setSelectionRange(3, 11); @@ -125,6 +127,7 @@ test('artifact AI revisions stay in an explicit draft review and preserve newer await page.getByRole('button', { name: 'Suggest changes', exact: true }).click(); await expect.poll(() => requests.length).toBe(2); expect(JSON.stringify(requests[1])).toContain('前😀新后'); + await editArtifact(page); await text.fill('My newer draft'); release(); await expect(text).toHaveValue('My newer draft'); diff --git a/test/e2e/agent-composer-ux.spec.ts b/test/e2e/agent-composer-ux.spec.ts index a637ac080..24cd7324a 100644 --- a/test/e2e/agent-composer-ux.spec.ts +++ b/test/e2e/agent-composer-ux.spec.ts @@ -177,7 +177,7 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { await input.evaluate((el: HTMLTextAreaElement) => el.setSelectionRange(4, 17, 'backward')); await page.locator('.agent-sources-toggle').click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'sources'); - await page.getByRole('button', { name: 'Back to conversation' }).click(); + await page.locator('.agent-chat-toggle').click(); await expect(input).toHaveValue(draft + draft); expect( await input.evaluate((el: HTMLTextAreaElement) => [el.selectionStart, el.selectionEnd, el.selectionDirection]), diff --git a/test/e2e/agent-content-review.spec.ts b/test/e2e/agent-content-review.spec.ts index e14cde08a..a9218a0ff 100644 --- a/test/e2e/agent-content-review.spec.ts +++ b/test/e2e/agent-content-review.spec.ts @@ -57,7 +57,7 @@ async function open( await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Configured'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); } const undo = (page: Page) => page.evaluate(() => { diff --git a/test/e2e/agent-conversation-shell.spec.ts b/test/e2e/agent-conversation-shell.spec.ts new file mode 100644 index 000000000..5bb145b52 --- /dev/null +++ b/test/e2e/agent-conversation-shell.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from './lib/l0'; + +for (const width of [1280, 320]) { + test.describe(`${width}px conversation shell`, () => { + test.use({ viewport: { width, height: 900 }, hasTouch: width === 320 }); + test('one header keeps primary routes visible and composition owns model/source context', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + const panel = page.locator('.agent-runtime-panel'); + const header = panel.locator('.agent-panel-header'); + await expect(header.locator('.agent-workspace-nav button')).toHaveCount(3); + await expect(panel).toHaveAttribute('data-view', 'chat'); + expect((await header.boundingBox())!.height).toBe(width === 320 ? 52 : 40); + await expect(panel.locator(':scope > .agent-workspace-nav')).toHaveCount(0); + await expect(panel.locator('.cui-composer .agent-sources-nav')).toBeVisible(); + await expect(panel.locator('.cui-footer .agent-panel-settings-toggle')).toBeVisible(); + await panel.locator('.cui-input').fill('Keep this draft'); + for (const [trigger, view] of [ + ['.agent-history-toggle', 'history'], + ['.agent-workspace-toggle', 'artifacts'], + ] as const) { + await header.locator(trigger).click(); + await expect(panel).toHaveAttribute('data-view', view); + await expect(header.locator(trigger)).toHaveAttribute('aria-current', 'page'); + await header.locator('.agent-chat-toggle').click(); + await expect(panel.locator('.cui-input')).toHaveValue('Keep this draft'); + } + await panel.locator('.agent-panel-settings-toggle').click(); + await expect(panel).toHaveAttribute('data-view', 'settings'); + await header.locator('.agent-chat-toggle').click(); + await panel.locator('.agent-sources-nav').click(); + await expect(panel).toHaveAttribute('data-view', 'sources'); + await panel.locator('.agent-sources-view input[type=file]').setInputFiles({ + name: 'project-notes.txt', + mimeType: 'text/plain', + buffer: Buffer.from('A local source preview.'), + }); + await expect(panel.locator('.agent-source-row')).toContainText('project-notes.txt'); + await header.locator('.agent-chat-toggle').click(); + const chip = panel.locator('.agent-source-chip'); + await expect(chip).toHaveText('project-notes.txt'); + await chip.click(); + await expect(panel.locator('.agent-source-preview')).toHaveAttribute('open', ''); + await expect(panel.locator('.agent-source-preview summary')).toBeFocused(); + await expect(panel.locator('.agent-source-preview pre')).toHaveText('A local source preview.'); + await header.locator('.agent-chat-toggle').click(); + await expect(panel.locator('.cui-input')).toHaveValue('Keep this draft'); + expect(await panel.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + }); + }); +} + +test('secondary pages return to an older selected conversation and its own draft', async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent_conversation_saving_v2', 'true'); + const ids = [crypto.randomUUID(), crypto.randomUUID()]; + const request = indexedDB.open('document-ai-conversations', 2); + request.onupgradeneeded = () => { + request.result.createObjectStore('conversations'); + request.result.createObjectStore('sessions'); + }; + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const tx = db.transaction(['conversations', 'sessions'], 'readwrite'); + tx.objectStore('conversations').put({ version: 2, revision: 1, activeId: ids[1], sessionIds: ids }, 'state'); + for (const [index, id] of ids.entries()) + tx.objectStore('sessions').put( + { + id, + title: index ? 'Newer conversation' : 'Older conversation', + createdAt: Date.now() - 2000 + index * 1000, + updatedAt: Date.now() - 2000 + index * 1000, + messages: [{ role: 'user', content: index ? 'Newer request' : 'Older request' }], + }, + id, + ); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + db.close(); + }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + const panel = page.locator('.agent-runtime-panel'); + await panel.locator('.agent-history-toggle').click(); + await panel + .locator('.agent-conversation-title') + .filter({ hasText: /^Older conversation$/ }) + .click(); + await expect(panel.locator('.cui-msg-user')).toHaveText('Older request'); + await panel.locator('.cui-input').fill('Only the older conversation owns this draft'); + for (const selector of [ + '.agent-sources-nav', + '.agent-panel-settings-toggle', + '.agent-workspace-toggle', + '.agent-history-toggle', + ]) { + await panel.locator(selector).click(); + await panel.locator('.agent-chat-toggle').click(); + await expect(panel.locator('.cui-msg-user')).toHaveText('Older request'); + await expect(panel.locator('.cui-input')).toHaveValue('Only the older conversation owns this draft'); + } +}); diff --git a/test/e2e/agent-detail-polish.spec.ts b/test/e2e/agent-detail-polish.spec.ts new file mode 100644 index 000000000..4af5c8080 --- /dev/null +++ b/test/e2e/agent-detail-polish.spec.ts @@ -0,0 +1,132 @@ +import { expect, test } from './lib/l0'; + +for (const variant of [ + { width: 1280, locale: 'en', coarse: false }, + { width: 320, locale: 'pt', coarse: true }, +]) { + test.describe(`${variant.width}px ${variant.locale} detail polish`, () => { + test.use({ viewport: { width: variant.width, height: 900 }, hasTouch: variant.coarse }); + test('related helper copy and separate fields keep their own vertical rhythm', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + }); + await page.goto(`/editor?new=docx&locale=${variant.locale}`); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-sources-nav').click(); + const helperGap = await page + .locator('.agent-sources-view') + .evaluate( + (el) => + el.lastElementChild!.getBoundingClientRect().top - + el.querySelector('.agent-source-attach')!.getBoundingClientRect().bottom, + ); + expect(helperGap, 'Attach and its explanation form an 8px group').toBe(8); + await page.locator('.agent-chat-toggle').click(); + await page.locator('.agent-panel-settings-toggle').click(); + const gaps = await page.locator('.agent-panel-model-row').evaluate((el) => { + const rect = (selector: string) => el.querySelector(selector)!.getBoundingClientRect(); + return { + helper: rect('.agent-model-memory').top - rect('r-select').bottom, + action: rect('.agent-model-actions').top - rect('.agent-model-memory').bottom, + }; + }); + expect(gaps).toEqual({ helper: 8, action: 16 }); + await page.locator('.agent-preferences > summary').click(); + const fieldGaps = await page.locator('.agent-custom-sources').evaluate((el) => { + const fields = [...el.querySelectorAll('.agent-settings-field')]; + return fields + .slice(1) + .map((field, i) => field.getBoundingClientRect().top - fields[i].getBoundingClientRect().bottom); + }); + expect(fieldGaps).toEqual([16, 16]); + expect( + await page.locator('.agent-panel-settings').evaluate((el) => el.scrollWidth - el.clientWidth), + ).toBeLessThanOrEqual(1); + }); + }); +} + +test('keyboard source removal and long artifact deletion keep a visible focus destination', async ({ page }) => { + test.setTimeout(90000); + await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); + await page.setViewportSize({ width: 320, height: 700 }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-sources-nav').click(); + for (const name of ['one.txt', 'two.txt']) { + await page + .locator('.agent-sources-view input[type=file]') + .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from(name) }); + await expect(page.locator('.agent-source-preview summary').filter({ hasText: name })).toBeVisible(); + } + await page.locator('.agent-source-remove').first().focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('button', { name: 'Remove: two.txt', exact: true })).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.locator('.agent-source-attach')).toBeFocused(); + await page.locator('.agent-workspace-toggle').click(); + await page.locator('[data-action=import]').click(); + await page.locator('.agent-artifact-import input').fill('Long draft'); + await page + .locator('.agent-artifacts textarea') + .fill(JSON.stringify({ schemaVersion: 1, payload: { kind: 'text', text: 'A long draft line\n'.repeat(150) } })); + await page.locator('[data-action=saveNew]').click(); + const remove = page.locator('.agent-artifacts [data-action=delete]'); + await remove.focus(); + await page.keyboard.press('Enter'); + const cancel = page.locator('.agent-artifacts [data-action=cancel]'); + await expect(cancel).toBeFocused(); + const confirmationGap = await cancel.evaluate((el) => { + const confirm = el.parentElement!.querySelector('[data-action=confirmDelete]')!.getBoundingClientRect(); + const cancel = el.getBoundingClientRect(); + return Math.abs(cancel.top - confirm.top) < 1 ? cancel.left - confirm.right : cancel.top - confirm.bottom; + }); + expect(confirmationGap, 'confirmation actions keep an 8px separation').toBe(8); + const bounds = await cancel.boundingBox(); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(700); + await page.keyboard.press('Enter'); + await expect(remove).toBeFocused(); + await expect(page.locator('.agent-artifact-editor [data-artifact-source]')).toHaveValue( + 'A long draft line\n'.repeat(150), + ); +}); + +test.describe('touch starter actions and attached-source labels', () => { + test.use({ viewport: { width: 320, height: 900 }, hasTouch: true }); + test('uses physical targets and a shared first-line text center', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + }); + await page.goto('/editor?new=docx&locale=pt'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-chat-toggle').click(); + await expect(page.locator('.agent-task-action')).toHaveCount(3); + for (const action of await page.locator('.agent-task-action').all()) + expect.soft((await action.boundingBox())!.height, 'starter action target').toBeGreaterThanOrEqual(44); + await page.locator('.agent-sources-nav').click(); + await page + .locator('.agent-sources-view input[type=file]') + .setInputFiles({ name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('Local excerpt') }); + const row = page.locator('.agent-source-row'); + await expect(row).toBeVisible(); + const centers = await row.evaluate((el) => { + const center = (selector: string) => { + const range = document.createRange(); + range.selectNodeContents(el.querySelector(selector)!); + const firstLine = range.getClientRects()[0]; + return (firstLine.top + firstLine.bottom) / 2; + }; + return { filename: center('summary'), remove: center('button') }; + }); + expect.soft(Math.abs(centers.filename - centers.remove), 'filename and Remove text centers').toBeLessThanOrEqual(1); + await row.locator('summary').click(); + await expect(row.locator('pre')).toHaveText('Local excerpt'); + expect((await row.locator('summary').boundingBox())!.height).toBeGreaterThanOrEqual(44); + }); +}); diff --git a/test/e2e/agent-history-actions.spec.ts b/test/e2e/agent-history-actions.spec.ts index 83537beee..e905a3a02 100644 --- a/test/e2e/agent-history-actions.spec.ts +++ b/test/e2e/agent-history-actions.spec.ts @@ -23,6 +23,7 @@ async function surface(button: Locator) { borderStyle: css.borderTopStyle, outline: css.outlineStyle, outlineWidth: parseFloat(css.outlineWidth), + shadow: css.boxShadow, }; }); } @@ -82,34 +83,40 @@ for (const scheme of ['light', 'dark'] as const) { const transfer = options.locator('.agent-history-restore, .agent-history-import, .agent-history-export'); const danger = options.locator('.agent-history-delete-current, .agent-history-delete-all'); const group = options.locator('.agent-history-transfer-actions'); - // Native button surfaces signal action before hover. Icon/checkbox and - // label columns align independently of the gutter-extended hit target. + // Compare leading content across regions, not just equal frames inside the group. + // The target can use the gutter while icons and the checkbox share the heading's spine. const groupBox = (await group.boundingBox())!; - const optionsBox = (await options.boundingBox())!; - expect(groupBox.x).toBeCloseTo(optionsBox.x - 8, 1); - expect(await frameLeft(options.locator('.agent-history-save'))).toBeCloseTo(16, 1); - expect((await textGeometry(options.locator('label > span'))).textLeft).toBeCloseTo(40, 1); + const panel = page.locator('.agent-runtime-panel'); + const panelBox = (await panel.boundingBox())!; + const contentSpine = (await textGeometry(page.locator('.agent-history-view h2'))).textLeft; + expect(contentSpine).toBeCloseTo(16, 1); + expect(await frameLeft(group)).toBeCloseTo(contentSpine - 8, 1); + expect(await frameLeft(options.locator('.agent-history-save'))).toBeCloseTo(contentSpine, 1); + expect((await textGeometry(options.locator('label > span'))).textLeft).toBeCloseTo(contentSpine + 24, 1); const canvas = await page .locator('.agent-runtime-panel') .evaluate((el) => getComputedStyle(el).backgroundColor); let previousBottom = 0; for (const button of await transfer.or(danger).all()) { const css = await surface(button); - expect(css.background).not.toBe('rgba(0, 0, 0, 0)'); - expect(css.background, 'resting button must differ from the canvas').not.toBe(canvas); - expect(contrast(css.color, css.background)).toBeGreaterThanOrEqual(4.5); + expect(css.background).toBe('rgba(0, 0, 0, 0)'); + expect(css.shadow).toBe('none'); + // Transparent rows paint on the actual panel canvas; transparent black is not a background. + expect(contrast(css.color, canvas)).toBeGreaterThanOrEqual(4.5); const box = (await button.boundingBox())!; - expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(32); expect(box.x).toBeCloseTo(groupBox.x, 1); + expect(box.x).toBeGreaterThanOrEqual(panelBox.x); + expect(box.x + box.width).toBeLessThanOrEqual(panelBox.x + panelBox.width); expect(box.y).toBeGreaterThanOrEqual(previousBottom); previousBottom = box.y + box.height; const icon = button.locator('svg[aria-hidden="true"]'); await expect(icon).toBeVisible(); - expect(await frameLeft(icon)).toBeCloseTo(16, 1); + expect(await frameLeft(icon)).toBeCloseTo(contentSpine, 1); const label = button.locator('.agent-history-action-label'); const text = await textGeometry(label); - expect(text.textLeft).toBeCloseTo(40, 1); - expect(box.width).toBeLessThanOrEqual(text.textWidth + 41); + expect(text.textLeft).toBeCloseTo(contentSpine + 24, 1); + expect(box.width).toBeCloseTo(groupBox.width, 1); await expect(button).toHaveAccessibleName((await label.innerText()).trim()); expect(await button.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); } @@ -120,7 +127,7 @@ for (const scheme of ['light', 'dark'] as const) { ...(await danger.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top))), ); expect(dangerTop - transferBottom).toBeGreaterThanOrEqual(16); - expect(await options.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + expect(await panel.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); } await page.setViewportSize({ width: 1280, height: 900 }); const restore = options.locator('.agent-history-restore'); diff --git a/test/e2e/agent-history-rendering.spec.ts b/test/e2e/agent-history-rendering.spec.ts index 3edc91b4f..e330b7abb 100644 --- a/test/e2e/agent-history-rendering.spec.ts +++ b/test/e2e/agent-history-rendering.spec.ts @@ -1,6 +1,6 @@ import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; -import { assertTitleRail, contentGeometry, textGeometry } from './lib/layout-geometry'; +import { contentGeometry, frameLeft, textGeometry } from './lib/layout-geometry'; test('restores a long saved conversation and switches sessions with completed answer actions', async ({ page }) => { test.setTimeout(90_000); @@ -136,18 +136,19 @@ test('restores a long saved conversation and switches sessions with completed an const history = page.locator('.agent-history-view'); const viewHeader = history.locator('.agent-view-header'); await expect(viewHeader.locator('.agent-history-heading')).toHaveCount(1); - const back = viewHeader.getByRole('button', { name: 'Back to conversation', exact: true }); - await expect(back).toHaveCount(1); + await expect(viewHeader.locator('.agent-view-back')).toHaveCount(0); await page.evaluate(() => document.fonts.ready); - await assertTitleRail(page.locator('.agent-runtime-panel .agent-panel-title')); + // On a secondary page the home command owns a leading chevron on the + // content spine, followed by its label; the page heading stays on the spine. + const home = page.locator('.agent-runtime-panel .agent-panel-title'); + expect(await frameLeft(home.locator('svg'))).toBeCloseTo(16, 1); + expect((await textGeometry(home)).textLeft).toBeCloseTo(36, 1); const geometry = await contentGeometry(history); const row = await textGeometry(history.locator('.agent-conversation-title').first()); - // The Back hit area extends into the gutter; its SVG and row text share the content rail. + // A parallel view's heading and list text return to the same content rail. expect(geometry.contentLeft).toBeCloseTo(16, 1); - expect(geometry.iconLeft).toBeCloseTo(geometry.contentLeft, 1); + expect(geometry.headingLeft).toBeCloseTo(geometry.contentLeft, 1); expect(row.textLeft).toBeCloseTo(geometry.contentLeft, 1); - // The owned title follows the compact Back target and the shared 8px gap. - expect(Math.abs(geometry.headingLeft - (geometry.backLeft + geometry.backWidth + 8))).toBeLessThanOrEqual(1); await page.locator('.agent-runtime-panel').screenshot({ path: test.info().outputPath('history-light.png') }); await page.emulateMedia({ colorScheme: 'dark' }); await page.locator('.agent-runtime-panel').screenshot({ path: test.info().outputPath('history-dark.png') }); diff --git a/test/e2e/agent-imported-formats.spec.ts b/test/e2e/agent-imported-formats.spec.ts index 594dcdc68..869fe5526 100644 --- a/test/e2e/agent-imported-formats.spec.ts +++ b/test/e2e/agent-imported-formats.spec.ts @@ -151,7 +151,7 @@ for (const fixture of fixtures) { await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const input = page.locator('.cui-input'); // Imported content enters the request only through an explicit trusted read. // The second request below still exercises semantic tools and continuation. diff --git a/test/e2e/agent-interface-affordances.spec.ts b/test/e2e/agent-interface-affordances.spec.ts index 6146c413e..b23bba90f 100644 --- a/test/e2e/agent-interface-affordances.spec.ts +++ b/test/e2e/agent-interface-affordances.spec.ts @@ -52,12 +52,13 @@ for (const width of [360, 1280]) { background = luminance(css.backgroundColor); return { background: css.backgroundColor, + shadow: css.boxShadow, canvas: getComputedStyle(el.closest('.agent-panel')!).backgroundColor, contrast: (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05), }; }); expect(surface.background).not.toBe('rgba(0, 0, 0, 0)'); - expect(surface.background).not.toBe(surface.canvas); + expect(surface.background !== surface.canvas || surface.shadow !== 'none').toBe(true); expect(surface.contrast).toBeGreaterThanOrEqual(4.5); await command.focus(); await page.keyboard.press('Tab'); diff --git a/test/e2e/agent-layout-rails.spec.ts b/test/e2e/agent-layout-rails.spec.ts index 0fc5221f7..c594a0fd3 100644 --- a/test/e2e/agent-layout-rails.spec.ts +++ b/test/e2e/agent-layout-rails.spec.ts @@ -106,8 +106,10 @@ for (const variant of variants) { for (const theme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: theme }); const titleBox = await assertTitleRail(title); - if (variant.width <= 360 || variant.coarse) - expect(titleBox.width).toBeGreaterThanOrEqual(Math.min(titleBox.textWidth + 16, variant.width - 24)); + // The single row reserves space for four primary commands; translated + // identity text can ellipsize while its actual hit target stays usable. + expect(titleBox.width).toBeGreaterThanOrEqual(variant.coarse ? 44 : 32); + await expect(title).toHaveAttribute('aria-label', /.+/); await title.hover(); await expect(title).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); await title.click(); @@ -152,19 +154,16 @@ for (const variant of variants) { for (const [toggle, selector, frame] of [ ['.agent-panel-settings-toggle', '.agent-panel-settings', '.agent-source-picker'], ['.agent-history-toggle', '.agent-history-view', '.agent-history-search-row'], - ['.agent-sources-toggle', '.agent-sources-view', 'input[type="search"]'], + ['.agent-sources-nav', '.agent-sources-view', 'input[type="search"]'], ['.agent-workspace-toggle', '.agent-artifacts', ':scope > div:not(.agent-view-header)'], ]) { + await panel.locator('.agent-chat-toggle').click(); await panel.locator(toggle).click(); const view = panel.locator(selector); - const back = view.locator('.agent-view-back'); - await expect(back).toBeVisible(); + await expect(view.locator('.agent-view-back')).toHaveCount(0); const geometry = await contentGeometry(view); expect(geometry.contentLeft, selector).toBeCloseTo(16, 1); - expect(geometry.iconLeft, selector).toBeCloseTo(16, 1); - expect(geometry.headingLeft, selector).toBeCloseTo(variant.coarse ? 54 : 48, 1); - expect(geometry.backWidth, selector).toBe(variant.coarse ? 44 : 32); - expect(geometry.backLeft, selector).toBeGreaterThanOrEqual(0); + expect(geometry.headingLeft, selector).toBeCloseTo(16, 1); expect(geometry.overflow, selector).toBeLessThanOrEqual(1); expect(await frameLeft(view.locator(frame).first()), selector).toBeCloseTo(16, 1); const firstContent = view @@ -201,17 +200,17 @@ for (const variant of variants) { expect(gap, 'source intro and search form a separate group').toBe(16); } await panel.screenshot({ path: testInfo.outputPath(`${selector.slice(1)}-${theme}.png`) }); + const chatNavigation = panel.locator('.agent-chat-toggle'); await page.keyboard.press('Tab'); - await back.focus(); - expect(await back.evaluate((el) => el.matches(':focus-visible'))).toBe(true); - const focus = await back.evaluate((el) => { + await chatNavigation.focus(); + expect(await chatNavigation.evaluate((el) => el.matches(':focus-visible'))).toBe(true); + const focus = await chatNavigation.evaluate((el) => { const style = getComputedStyle(el); return { width: Number.parseFloat(style.outlineWidth), offset: Number.parseFloat(style.outlineOffset) }; }); - expect(geometry.backLeft, `${selector} focus ring inside scrollport`).toBeGreaterThanOrEqual( - focus.width + focus.offset, - ); - await back.press('Enter'); + expect(focus.width, `${selector} keyboard navigation outline`).toBeGreaterThanOrEqual(2); + expect(focus.offset).toBeLessThanOrEqual(0); + await chatNavigation.press('Enter'); await expect(panel).toHaveAttribute('data-view', 'chat'); await expect(page.locator('.cui-input')).toHaveValue('Preserve this draft while checking every view'); } @@ -237,7 +236,8 @@ for (const variant of variants) { await expect(input).toBeVisible(); for (const field of await form.locator('input, textarea, label > span').all()) expect(await frameLeft(field)).toBeCloseTo(16, 1); - expect((await contentGeometry(artifacts)).iconLeft).toBeCloseTo(16, 1); + expect((await contentGeometry(artifacts)).headingLeft).toBeCloseTo(16, 1); + await expect(artifacts.locator('.agent-view-back span')).toBeVisible(); await input.fill('Layout fixture'); await form .locator('textarea') @@ -246,13 +246,16 @@ for (const variant of variants) { ); await form.locator('[data-action="saveNew"]').click(); const editor = artifacts.locator('.agent-artifact-editor:not(.agent-artifact-import)'); + await expect(editor.locator('[data-artifact-reader]')).toBeVisible(); + await expect(editor).toHaveAttribute('data-mode', 'preview'); + await editor.locator('[data-action=edit]').click(); await expect(editor.locator('textarea').first()).toBeVisible(); expect(await frameLeft(editor.locator('input').first())).toBeCloseTo(16, 1); - expect((await contentGeometry(artifacts)).headingLeft).toBeCloseTo(variant.coarse ? 54 : 48, 1); + expect((await contentGeometry(artifacts)).headingLeft).toBeCloseTo(16, 1); await panel.screenshot({ path: testInfo.outputPath(`artifact-editor-${theme}.png`) }); await artifacts.locator('.agent-view-back').click(); await expect(artifacts.locator('[data-action="import"]')).toBeVisible(); - await artifacts.locator('.agent-view-back').click(); + await panel.locator('.agent-chat-toggle').click(); } }); }); diff --git a/test/e2e/agent-navigation-craft.spec.ts b/test/e2e/agent-navigation-craft.spec.ts new file mode 100644 index 000000000..2335eb43f --- /dev/null +++ b/test/e2e/agent-navigation-craft.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from './lib/l0'; +import { frameLeft, textGeometry } from './lib/layout-geometry'; + +const variants = [ + { width: 1280, locale: 'en', theme: 'light', coarse: false }, + { width: 1280, locale: 'en', theme: 'dark', coarse: false }, + { width: 320, locale: 'pt', theme: 'light', coarse: true }, + { width: 360, locale: 'pt', theme: 'dark', coarse: true }, +] as const; + +for (const variant of variants) { + test.describe(`${variant.width}px ${variant.locale} ${variant.theme}`, () => { + test.use({ viewport: { width: variant.width, height: 1000 }, hasTouch: variant.coarse }); + test('primary navigation and contextual settings form one coherent pane', async ({ page }, info) => { + test.setTimeout(120_000); + await page.emulateMedia({ colorScheme: variant.theme, reducedMotion: 'reduce' }); + await page.addInitScript((theme) => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + localStorage.setItem('agent_conversation_saving_v2', 'true'); + localStorage.setItem('ran-theme', theme); + }, variant.theme); + await page.goto(`/editor?new=docx&locale=${variant.locale}`); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + const panel = page.locator('.agent-runtime-panel'); + await expect(page.locator('html')).toHaveAttribute('data-ran-theme', variant.theme); + const header = panel.locator('.agent-panel-header'); + expect((await header.boundingBox())!.height, 'compact header preserves input target density').toBe( + variant.coarse ? 52 : 40, + ); + await expect(header.locator('.agent-panel-title')).toHaveCSS('font-size', '13px'); + await page.locator('.agent-history-toggle').click(); + const history = page.locator('.agent-history-view'); + await history.locator('.agent-history-management > summary').click(); + await page.evaluate(() => document.fonts.ready); + const options = history.locator('.agent-history-options'); + const buttons = options.locator('.agent-history-actions button'); + const widths: number[] = []; + const contentSpine = (await textGeometry(history.locator('.agent-view-header h2'))).textLeft; + expect(contentSpine).toBeCloseTo(16, 1); + expect((await textGeometry(history.locator('.agent-history-management > summary'))).textLeft).toBeCloseTo( + contentSpine, + 1, + ); + for (const button of await buttons.all()) { + expect(await frameLeft(button.locator('svg')), 'command icon shares the heading spine').toBeCloseTo( + contentSpine, + 1, + ); + // The quiet command's hit area extends into the owned gutter; visible icons stay on the page spine. + expect(await frameLeft(button)).toBeCloseTo(contentSpine - 8, 1); + const box = (await button.boundingBox())!; + widths.push(box.width); + expect(box.height).toBeGreaterThanOrEqual(variant.coarse ? 44 : 32); + if (!variant.coarse) expect(box.height).toBe(32); + expect((await textGeometry(button.locator('span'))).textLeft).toBeCloseTo(contentSpine + 24, 1); + } + expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(0.5); + expect(await frameLeft(options.locator('.agent-history-save')), 'checkbox shares the heading spine').toBeCloseTo( + contentSpine, + 1, + ); + expect((await textGeometry(options.locator('label > span'))).textLeft).toBeCloseTo(contentSpine + 24, 1); + expect((await textGeometry(options.locator('.agent-history-status'))).textLeft).toBeCloseTo(contentSpine + 24, 1); + await page.mouse.move(0, 0); + await panel.screenshot({ path: info.outputPath('history-full-pane.png') }); + + const nav = panel.locator('.agent-workspace-nav'); + await expect(nav.getByRole('button')).toHaveCount(3); + if (variant.coarse) { + for (const button of await nav.getByRole('button').all()) { + const box = (await button.boundingBox())!; + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(44); + } + expect((await options.locator('label').boundingBox())!.height).toBeGreaterThanOrEqual(44); + } + const draft = 'Keep the current conversation draft'; + await panel.locator('.agent-chat-toggle').click(); + await panel.locator('.cui-input').fill(draft); + for (const [trigger, view] of [ + ['.agent-workspace-toggle', '.agent-artifacts'], + ['.agent-sources-nav', '.agent-sources-view'], + ['.agent-panel-settings-toggle', '.agent-panel-settings'], + ['.agent-history-toggle', '.agent-history-view'], + ]) { + const button = panel.locator(trigger); + await button.click(); + await expect(button).toHaveAttribute('aria-current', 'page'); + const primary = trigger === '.agent-workspace-toggle' || trigger === '.agent-history-toggle'; + if (primary) { + // Pointer hover must not supply the selected fill we are verifying. + await page.mouse.move(0, 0); + await expect(button).toHaveCSS('box-shadow', 'none'); + await expect(button).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(button).not.toHaveCSS( + 'background-color', + await panel.evaluate((el) => getComputedStyle(el).backgroundColor), + ); + } + const current = panel.locator(view); + await expect(current).toBeVisible(); + await expect(current.locator('.agent-view-back')).toHaveCount(0); + const heading = current.locator('.agent-view-header h2'); + expect((await textGeometry(heading)).textLeft).toBeCloseTo(16, 1); + await expect(heading).toHaveCSS('font-size', '16px'); + await expect(panel.locator(':scope > .agent-runtime-row')).toBeHidden(); + if (primary) { + // Reselecting a primary destination preserves its state. + await button.click(); + await expect(current).toBeVisible(); + await button.focus(); + const visible = await button.evaluate((el) => { + const box = el.getBoundingClientRect(), + parent = el.parentElement!.getBoundingClientRect(); + return box.left >= parent.left && box.right <= parent.right + 1; + }); + expect(visible).toBe(true); + } + expect(await current.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + await page.mouse.move(0, 0); + await panel.screenshot({ path: info.outputPath(`${view.slice(1)}-full-pane.png`) }); + if (primary) await panel.locator('.agent-chat-toggle').click(); + else { + await page.keyboard.press('Escape'); + await expect(button).toBeFocused(); + } + await expect(panel.locator('.cui-input')).toHaveValue(draft); + } + await panel.locator('.agent-chat-toggle').click(); + await expect(panel.locator('.cui-input')).toHaveValue(draft); + await page.setViewportSize({ width: variant.width === 1280 ? 360 : 1280, height: 1000 }); + await panel.locator('.agent-sources-nav').click(); + await expect(panel.locator('.agent-sources-view')).toBeVisible(); + expect(await panel.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + }); + }); +} diff --git a/test/e2e/agent-product-entry.spec.ts b/test/e2e/agent-product-entry.spec.ts index 92ad6da54..106ef5f6d 100644 --- a/test/e2e/agent-product-entry.spec.ts +++ b/test/e2e/agent-product-entry.spec.ts @@ -51,15 +51,13 @@ test('default off and remembered on never prepare models or connect services on await expect(page.locator('.agent-runtime-panel')).toHaveCount(0); await page.locator('.agent-onboarding .agent-enable-switch').click(); await expect(page.locator('.agent-runtime-panel')).toBeVisible(); - await expect(page.locator('.agent-panel-settings')).toBeVisible(); - await page.locator('.agent-panel-settings-toggle').click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); await page.locator('.cui-input').fill('My draft'); await expect(page.locator('.cui-send')).toHaveClass(/cui-send-prepare/); await expect(page.locator('.cui-send')).toBeEnabled(); await expect(page.locator('.cui-msg-user')).toHaveCount(0); await page.locator('.agent-panel-settings-toggle').click(); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); await expect(page.locator('.cui-input')).toHaveValue('My draft'); await page.locator('.agent-runtime-panel .agent-panel-close').click(); @@ -93,14 +91,16 @@ for (const width of [1280, 375, 320]) { await page.evaluate(() => window.__toggleAgentPanel?.()); await page.locator('.agent-onboarding .agent-enable-switch').click(); await expect(page.locator('.agent-runtime-panel')).toBeVisible(); + await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); + await page.locator('.agent-panel-settings-toggle').click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'settings'); await page.screenshot({ path: testInfo.outputPath('settings.png') }); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); await page.locator('.cui-input').fill('Keep this draft while switching views'); await page.locator('.agent-history-toggle').click(); await expect(page.locator('.agent-history-view')).toBeVisible(); - await page.locator('.agent-history-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); await expect(page.locator('.cui-input')).toHaveValue('Keep this draft while switching views'); const fits = await page.locator('.agent-runtime-panel').evaluate((panel) => { diff --git a/test/e2e/agent-public-reasoning.spec.ts b/test/e2e/agent-public-reasoning.spec.ts new file mode 100644 index 000000000..1a7c5cd7e --- /dev/null +++ b/test/e2e/agent-public-reasoning.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from './lib/l0'; +import { installConversationFixture } from './lib/conversation-fixture'; + +test('public reasoning is a reader-owned transient phase and never a saved answer', async ({ page }, info) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await page.addInitScript(installConversationFixture); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + const panel = page.locator('.agent-runtime-panel'); + const input = panel.locator('.cui-input'); + await input.fill('Hello'); + await panel.locator('.cui-send').click(); + await expect(panel.locator('.agent-panel-endpoint-status')).toContainText('Connected'); + await panel.locator('.cui-send').click(); + const reasoning = panel.locator('r-reasoning'); + await expect(reasoning).toHaveCount(1); + await expect + .poll(() => reasoning.evaluate((el) => (el as HTMLElement & { content: string }).content)) + .toContain('project notes'); + await expect(panel.locator('.cui-msg-agent')).toHaveCount(0); + // The native disclosure uses a closed shadow root; click its visible header. + await reasoning.click({ position: { x: 32, y: 16 } }); + await expect(reasoning).not.toHaveAttribute('open'); + await page.evaluate(() => window.__conversationFixture.advance()); + await expect + .poll(() => reasoning.evaluate((el) => (el as HTMLElement & { content: string }).content)) + .toContain('next action'); + await expect(reasoning).not.toHaveAttribute('open'); + await page.evaluate(() => window.__conversationFixture.advance()); + await expect(panel.locator('.cui-msg-agent .cui-bubble')).toContainText('Project update'); + await expect(reasoning).not.toHaveAttribute('streaming'); + expect(await page.evaluate(() => window.__conversationFixture.completed)).toBe(false); + for (let i = 0; i < 6; i++) await page.evaluate(() => window.__conversationFixture.advance()); + await expect(panel.locator('.cui-stop')).toBeHidden(); + await expect(panel.locator('.cui-msg-agent .cui-bubble table')).toHaveCount(1); + await expect(panel.locator('.cui-msg-agent .cui-bubble pre')).toContainText('const status'); + expect(await panel.locator('.cui-msg-agent').getAttribute('data-source')).not.toContain('supplied project notes'); + await panel.locator('.cui-msg-agent .cui-copy').click(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain('Project update'); + expect(copied).not.toContain('checking the supplied project notes'); + await panel.locator('.cui-msg-agent .cui-save').click(); + await expect(panel.locator('[data-artifact-reader]')).toBeVisible(); + await expect(panel.locator('[data-artifact-source]')).toHaveValue(/Project update/); + expect(await panel.locator('[data-artifact-source]').inputValue()).not.toContain( + 'checking the supplied project notes', + ); + await panel.locator('.agent-chat-toggle').click(); + await panel.screenshot({ path: info.outputPath('public-reasoning-completed.png') }); + await input.fill('Hi'); + await panel.locator('.cui-send').click(); + await expect(reasoning).toHaveCount(2); + expect(JSON.stringify(await page.evaluate(() => window.__conversationFixture.requests))).not.toContain( + 'checking the supplied project notes', + ); + await panel.locator('.cui-stop').click(); + await expect(panel.locator('.cui-stop')).toBeHidden(); + await expect(reasoning.last()).not.toHaveAttribute('streaming'); + const before = await reasoning.last().evaluate((el) => (el as HTMLElement & { content: string }).content); + await page.evaluate(() => window.__conversationFixture.advance()); + expect(await reasoning.last().evaluate((el) => (el as HTMLElement & { content: string }).content)).toBe(before); + await input.fill('Hello again'); + await page.evaluate(() => { + window.__conversationFixture.mode = 'answer-only'; + }); + await panel.locator('.cui-send').click(); + await expect(panel.locator('.cui-msg-agent')).toHaveCount(2); + await expect(reasoning).toHaveCount(2); + await panel.locator('.cui-stop').click(); +}); + +test.describe('touch streaming composer', () => { + test.use({ hasTouch: true }); + for (const width of [320, 360]) { + test(`keeps voice language readable beside Stop and Send at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.addInitScript(installConversationFixture); + await page.goto('/editor?new=docx&locale=pt'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.cui-input').fill('Hello'); + await page.locator('.cui-send').click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Ligado'); + await page.locator('.cui-send').click(); + await expect(page.locator('.cui-stop')).toBeVisible(); + const language = await page.locator('.agent-speech-language').evaluate((el) => { + const root = el.shadowRoot ?? (el as HTMLElement & { _shadowDom: ShadowRoot })._shadowDom; + const label = root.querySelector('[part="selection-item"]')!; + const range = document.createRange(); + range.selectNodeContents(label); + return { + text: label.textContent, + width: label.getBoundingClientRect().width, + textWidth: range.getBoundingClientRect().width, + }; + }); + expect(language.text).toBe('Português'); + expect(language.width).toBeGreaterThanOrEqual(language.textWidth); + for (const control of [ + '.agent-sources-toggle', + '.agent-speech-mic', + '.agent-speech-privacy button', + '.cui-stop', + '.cui-send', + ]) { + const box = (await page.locator(control).boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + } + expect(await page.locator('.cui-composer').evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); + await page.locator('.cui-stop').click(); + }); + } +}); diff --git a/test/e2e/agent-request-queue.spec.ts b/test/e2e/agent-request-queue.spec.ts index 052477780..0f83ea936 100644 --- a/test/e2e/agent-request-queue.spec.ts +++ b/test/e2e/agent-request-queue.spec.ts @@ -108,14 +108,14 @@ for (const ext of ['docx', 'pptx'] as const) { const english = await languageMetrics(); expect(english.text).toBe('English'); expect(english.width).toBeGreaterThanOrEqual(english.textWidth); - // Longer locale names may truncate; the label must retain a readable lane. + // Narrow Sources collapses to its icon, leaving both tested locale names readable. await language.evaluate((el) => { (el as HTMLElement & { value: string }).value = 'pt-BR'; }); const longer = await languageMetrics(); expect(longer.text).toBe('Português'); expect(longer.width).toBeGreaterThanOrEqual(32); - expect(longer.scroll).toBeGreaterThan(longer.client); + expect(longer.width).toBeGreaterThanOrEqual(longer.textWidth); await language.evaluate((el) => { (el as HTMLElement & { value: string }).value = 'en-US'; }); diff --git a/test/e2e/agent-sidebar-layout.spec.ts b/test/e2e/agent-sidebar-layout.spec.ts index e080200f3..3b3ffd76c 100644 --- a/test/e2e/agent-sidebar-layout.spec.ts +++ b/test/e2e/agent-sidebar-layout.spec.ts @@ -25,14 +25,15 @@ for (const width of [320, 1280]) { expect(switchGeometry.knobTop).toBe(switchGeometry.trackTop); await page.locator('.agent-onboarding .agent-enable-switch').click(); const panel = page.locator('.agent-runtime-panel'); - await expect(panel).toHaveAttribute('data-view', 'settings'); - await page.locator('.agent-panel-settings-toggle').click(); await expect(panel).toHaveAttribute('data-view', 'chat'); await expect(page.locator('.cui-composer')).toBeVisible(); await expect(page.locator('.cui-empty')).toBeVisible(); const suggestion = page.locator('.agent-task-action').first(); - await expect(suggestion).toHaveCSS('border-top-width', '1px'); - await expect(suggestion).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(suggestion.locator('.agent-task-icon')).toBeVisible(); + await suggestion.click(); + await expect(page.locator('.cui-input')).not.toHaveValue(''); + await expect(page.locator('.cui-msg-user')).toHaveCount(0); + await page.locator('.cui-input').fill(''); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`empty-${width}.png`) }); const alignment = await page.locator('.agent-runtime-panel').evaluate((panel) => { const left = (selector: string) => panel.querySelector(selector)!.getBoundingClientRect().left; @@ -44,8 +45,12 @@ for (const width of [320, 1280]) { await page.locator('.agent-panel-settings-toggle').click(); await expect(page.locator('.agent-panel-settings')).toBeVisible(); const settingsToggle = page.locator('.agent-panel-settings-toggle'); - await page.keyboard.press('Tab'); - await settingsToggle.focus(); + const size = await page.locator('.agent-panel-load').evaluate((button) => button.getBoundingClientRect().height); + expect(size).toBeGreaterThanOrEqual(30); + expect(size).toBeLessThanOrEqual(34); + const overflow = await page.locator('.agent-panel-settings').evaluate((el) => el.scrollWidth - el.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + await page.keyboard.press('Escape'); await expect(settingsToggle).toBeFocused(); expect( await settingsToggle.evaluate((button) => { @@ -53,10 +58,5 @@ for (const width of [320, 1280]) { return style.outlineStyle !== 'none' && Number.parseFloat(style.outlineWidth) >= 2; }), ).toBe(true); - const size = await page.locator('.agent-panel-load').evaluate((button) => button.getBoundingClientRect().height); - expect(size).toBeGreaterThanOrEqual(30); - expect(size).toBeLessThanOrEqual(34); - const overflow = await page.locator('.agent-panel-settings').evaluate((el) => el.scrollWidth - el.clientWidth); - expect(overflow).toBeLessThanOrEqual(1); }); } diff --git a/test/e2e/agent-sources-artifacts.spec.ts b/test/e2e/agent-sources-artifacts.spec.ts index 140b72491..bb8b3d381 100644 --- a/test/e2e/agent-sources-artifacts.spec.ts +++ b/test/e2e/agent-sources-artifacts.spec.ts @@ -1,3 +1,4 @@ +import { editArtifact } from './lib/artifact-view'; import { mockApi, originalApiUrl } from './lib/mock-api'; import { readFile } from 'node:fs/promises'; import { expect, test } from './lib/l0'; @@ -61,12 +62,13 @@ test('local sources and versioned artifacts remain explicit, compact and reviewe await page.locator('.agent-source-row summary').click(); await expect(page.locator('.agent-source-preview pre')).toHaveText('Project reference'); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('sources.png') }); - await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.agent-sources-summary')).toHaveText('notes.md'); await page.locator('.cui-save').click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'artifacts'); const text = page.locator('.agent-artifact-editor textarea'); await expect(text).toHaveValue(source); + await editArtifact(page); await text.fill('Edited release note'); await page.getByRole('button', { name: 'Save version', exact: true }).click(); await expect(page.locator('.agent-artifact-status')).toHaveText('Saved on this device'); @@ -75,7 +77,7 @@ test('local sources and versioned artifacts remain explicit, compact and reviewe await expect(text).toHaveValue(source); await page.getByRole('button', { name: 'Save version', exact: true }).click(); await expect(page.locator('.agent-artifact-editor select option')).toHaveCount(3); - await page.locator('.agent-artifact-presentation > details > summary').click(); + await page.locator('.agent-artifact-export > summary').click(); await page.setViewportSize({ width: 360, height: 700 }); for (const theme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: theme }); @@ -185,7 +187,7 @@ test('attached reference excerpts reach only the explicitly selected AI service' await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('source-search-preview.png') }); await sourceSearch.fill(''); expect(bodies).toEqual([]); - await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await page.locator('.cui-input').fill('Hello'); await page.locator('.cui-send').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); @@ -195,7 +197,7 @@ test('attached reference excerpts reach only the explicitly selected AI service' expect(bodies[0]).toContain('Only this reference is attached.'); await page.locator('.agent-sources-toggle').click(); await page.getByRole('button', { name: 'Remove: notes.txt', exact: true }).click(); - await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await page.getByRole('button', { name: 'Open source: notes.txt', exact: true }).click(); await expect(page.locator('.cui-bubble .agent-source-preview pre')).toContainText('Only this reference is attached.'); await page.locator('.cui-bubble .agent-source-preview summary').click(); @@ -242,14 +244,15 @@ test('attached reference excerpts reach only the explicitly selected AI service' expect(bodies).toHaveLength(1); await page.locator('.cui-save').click(); await expect(page.locator('.agent-artifact-provenance')).toContainText('notes.txt'); + await editArtifact(page); await page.locator('.agent-artifact-editor textarea').fill('Keep artifact draft'); - await page.getByRole('button', { name: 'Artifacts', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.cui-save')).toHaveText('Saved'); await page.locator('.cui-save').click(); await expect(page.locator('.agent-artifact-status')).toContainText('Discard unsaved changes'); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); await expect(page.locator('.agent-artifact-editor textarea')).toHaveValue('Keep artifact draft'); - await page.getByRole('button', { name: 'Artifacts', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.cui-save')).toHaveText('Save as artifact'); await page.setViewportSize({ width: 360, height: 700 }); await page.emulateMedia({ reducedMotion: 'reduce' }); @@ -378,7 +381,7 @@ test('offline workspace keeps local sources and hides download controls without }); await expect(page.locator('.agent-source-row')).toContainText('offline.txt'); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('sources-offline.png') }); - await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.agent-sources-summary')).toHaveText('offline.txt'); await page.locator('.agent-panel-settings-toggle').click(); await expect(page.locator('.agent-panel-settings > .agent-offline-hint')).toBeVisible(); @@ -388,24 +391,25 @@ test('offline workspace keeps local sources and hides download controls without await expect(page.locator('.agent-panel-provider')).toBeVisible(); await expect(page.locator('.agent-custom-sources')).toBeHidden(); await expect(page.locator('.agent-model-cache')).toBeVisible(); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await page.getByRole('button', { name: 'Artifacts', exact: true }).click(); await expect(page.locator('.agent-artifacts')).toBeVisible(); await page.getByRole('button', { name: 'Offline draft', exact: true }).click(); + await editArtifact(page); await page.locator('.agent-artifact-editor textarea').fill('Saved without a network'); await page.getByRole('button', { name: 'Save version', exact: true }).click(); await expect(page.locator('.agent-artifact-editor select option')).toHaveCount(2); await expect(page.locator('.agent-artifact-status')).toHaveText('Saved on this device'); await context.setOffline(false); offline = false; - await page.getByRole('button', { name: 'Artifacts', exact: true }).click(); - await page.locator('.agent-sources-toggle').click(); + await page.locator('.agent-chat-toggle').click(); + await page.locator('.agent-sources-nav').click(); await expect( page.locator('.agent-sources-view input[type=url], .agent-sources-view input[type=password]'), ).toHaveCount(0); await expect(page.locator('.agent-source-row')).toContainText('offline.txt'); expect(connectorRequests).toEqual([]); - await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.cui-input')).toHaveValue('Keep this draft offline'); expect([...failedWhileOffline].filter((url) => core.has(url))).toEqual([]); expect( @@ -493,12 +497,14 @@ test('discard restores artifact titles and a full version history can continue a const text = page.locator('.agent-artifact-editor textarea'); const versions = page.locator('.agent-artifact-editor select'); await versions.selectOption('1'); + await editArtifact(page); await title.fill('Unsaved title'); await versions.selectOption('20'); await page.locator('[data-action=discardChanges]').click(); await expect(title).toHaveValue('Saved title'); await expect(text).toHaveValue('Version 20'); await expect(page.locator('[data-action=saveVersion]')).toBeDisabled(); + await editArtifact(page); await text.fill('Continue this draft'); await page.locator('[data-action=saveVersion]').click(); await expect(page.locator('.agent-artifact-status')).toContainText('20 versions'); @@ -557,7 +563,7 @@ test('switching conversations releases a pending source read without attaching i await expect(page.locator('.agent-source-row')).toHaveText('fresh.txtRemove'); await page.evaluate(() => (window as Window & { releaseOldReference?: () => void }).releaseOldReference!()); await expect(page.locator('.agent-source-row')).toHaveText('fresh.txtRemove'); - await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.agent-sources-summary')).toHaveText('fresh.txt'); }); @@ -654,7 +660,7 @@ test('structured results validate explicitly, preview compactly and export witho await page.getByRole('button', { name: 'Suggest changes', exact: true }).click(); await expect(page.locator('.agent-artifact-workflow')).toContainText('The revision could not finish'); await expect(page.getByRole('button', { name: 'Use in draft', exact: true })).toBeHidden(); - await expect(page.locator('.agent-artifact-data-preview td textarea').first()).toHaveValue('row 0'); + await expect(page.locator('.agent-artifact-data-preview td').first()).toHaveText('row 0'); await page.getByRole('button', { name: 'Suggest changes', exact: true }).click(); await expect(page.getByRole('button', { name: 'Use in draft', exact: true })).toBeVisible(); expect(JSON.stringify(requests[1])).toContain('10000 cells'); @@ -667,15 +673,13 @@ test('structured results validate explicitly, preview compactly and export witho for (const theme of ['light', 'dark'] as const) { await page.setViewportSize({ width: 360, height: 760 }); await page.emulateMedia({ colorScheme: theme }); - await page.locator('.agent-artifact-editor > input').scrollIntoViewIfNeeded(); + await page.locator('.agent-artifact-editor .agent-artifact-title').scrollIntoViewIfNeeded(); await page .locator('.agent-runtime-panel') .screenshot({ path: testInfo.outputPath(`structured-default-${theme}.png`) }); } await page.getByRole('button', { name: 'Next', exact: true }).click(); - await expect( - page.locator('.agent-artifact-data-preview tbody textarea, .agent-artifact-data-preview tr textarea').nth(2), - ).toHaveValue('row 10'); + await expect(page.locator('.agent-artifact-data-preview td').first()).toHaveText('row 10'); await context.setOffline(true); await page.getByText('Export', { exact: true }).click(); const download = page.waitForEvent('download'); @@ -715,6 +719,7 @@ test('structured results validate explicitly, preview compactly and export witho }), ); await page.getByRole('button', { name: 'Save as new artifact', exact: true }).click(); + await editArtifact(page); const slideBody = page.locator('.agent-artifact-data-preview > textarea').nth(1); await slideBody.focus(); await slideBody.press('End'); @@ -723,7 +728,7 @@ test('structured results validate explicitly, preview compactly and export witho await page.getByRole('button', { name: 'Save version', exact: true }).click(); for (const theme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: theme }); - await page.locator('.agent-artifact-editor > input').scrollIntoViewIfNeeded(); + await page.locator('.agent-artifact-editor .agent-artifact-title').scrollIntoViewIfNeeded(); await expect .poll(() => page.locator('.agent-runtime-panel').evaluate((el) => el.scrollWidth - el.clientWidth)) .toBeLessThan(2); diff --git a/test/e2e/agent-task-loop.spec.ts b/test/e2e/agent-task-loop.spec.ts index f0611dcb2..650744085 100644 --- a/test/e2e/agent-task-loop.spec.ts +++ b/test/e2e/agent-task-loop.spec.ts @@ -91,7 +91,7 @@ for (const native of [false, true]) await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText(native ? 'Configured' : 'Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const readSlides = () => page.evaluate(() => { const win = window.__ooFrames.readyEditor() as any; @@ -187,7 +187,7 @@ for (const cancel of [false, true]) { await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const input = page.locator('.cui-input'); await input.fill('Create a new slide and add a text box containing Budget 120.'); await input.press('Enter'); diff --git a/test/e2e/agent-workspace-consistency.spec.ts b/test/e2e/agent-workspace-consistency.spec.ts index 6dd24e91a..d31002d8a 100644 --- a/test/e2e/agent-workspace-consistency.spec.ts +++ b/test/e2e/agent-workspace-consistency.spec.ts @@ -20,15 +20,13 @@ test('workspace pages share compact token geometry and a keyboard return path', expect( (await section.boundingBox())!.y - ((await search.boundingBox())!.y + (await search.boundingBox())!.height), ).toBeGreaterThanOrEqual(24); - expect((await source.getByRole('button', { name: 'Back to conversation', exact: true }).boundingBox())!.height).toBe( - 32, - ); + await expect(source.locator('.agent-view-back')).toHaveCount(0); for (const width of [1280, 360, 320]) { await page.setViewportSize({ width, height: 800 }); for (const scheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: scheme }); for (const [open, view] of [ - ['.agent-sources-toggle', '.agent-sources-view'], + ['.agent-sources-nav', '.agent-sources-view'], ['.agent-panel-settings-toggle', '.agent-panel-settings'], ['.agent-history-toggle', '.agent-history-view'], ['.agent-workspace-toggle', '.agent-artifacts'], @@ -37,8 +35,8 @@ test('workspace pages share compact token geometry and a keyboard return path', const current = page.locator(view); const header = current.locator('.agent-view-header'); await expect(header).toBeVisible(); - expect(await header.locator('h2').evaluate((el) => getComputedStyle(el).fontSize)).toBe('20px'); - await expect(current.locator('.agent-view-back')).toHaveCount(1); + expect(await header.locator('h2').evaluate((el) => getComputedStyle(el).fontSize)).toBe('16px'); + await expect(current.locator('.agent-view-back')).toHaveCount(0); if (view === '.agent-history-view') { expect((await current.locator('.agent-history-search-row').boundingBox())!.height).toBe(32); } @@ -52,8 +50,8 @@ test('workspace pages share compact token geometry and a keyboard return path', for (const summary of await current.locator('details > summary:visible').all()) { expect((await summary.boundingBox())!.height).toBe(32); } - const back = header.getByRole('button', { name: 'Back to conversation', exact: true }); - expect((await back.boundingBox())!.height).toBe(32); + const back = page.locator('.agent-chat-toggle'); + expect((await back.boundingBox())!.height).toBe(width <= 480 ? 44 : 32); expect(await current.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); await page .locator('.agent-runtime-panel') @@ -79,8 +77,9 @@ test('workspace pages share compact token geometry and a keyboard return path', await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); await page.locator('.agent-sources-toggle').click(); const translated = page.locator('.agent-sources-view'); - await expect(translated.locator('.agent-view-back')).toHaveAttribute('aria-label', /.+/); - expect(await translated.locator('.agent-view-back').getAttribute('aria-label')).not.toBe('Back to conversation'); + const chatNavigation = page.locator('.agent-chat-toggle'); + await expect(chatNavigation).toHaveAttribute('aria-label', /.+/); + expect(await chatNavigation.getAttribute('aria-label')).not.toBe('Chat'); expect(await translated.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); for (const button of await translated.locator('button:visible').all()) { expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(32); @@ -112,6 +111,9 @@ test('artifact commands distinguish disabled, ordinary and explicit save states' const disabled = await save.evaluate((el) => getComputedStyle(el).backgroundColor); await save.hover(); expect(await save.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe(disabled); + await expect(artifacts.locator('.agent-artifact-workbench')).toHaveAttribute('data-mode', 'preview'); + await expect(artifacts.getByRole('textbox', { name: 'Title', exact: true })).not.toBeEditable(); + await artifacts.locator('[data-action=edit]').click(); await artifacts.getByRole('textbox', { name: 'Title', exact: true }).fill('Renamed table'); await expect(save).toBeEnabled(); expect(await save.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(disabled); @@ -140,7 +142,7 @@ test.describe('coarse pointer workspace', () => { await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); await page.locator('.agent-sources-toggle').click(); const source = page.locator('.agent-sources-view'); - const back = source.locator('.agent-view-back'); + const back = page.locator('.agent-chat-toggle'); expect((await back.boundingBox())!.height).toBeGreaterThanOrEqual(44); expect((await back.boundingBox())!.width).toBeGreaterThanOrEqual(44); expect( diff --git a/test/e2e/agent-workspace.spec.ts b/test/e2e/agent-workspace.spec.ts index fc3e15e4e..38dfc2e34 100644 --- a/test/e2e/agent-workspace.spec.ts +++ b/test/e2e/agent-workspace.spec.ts @@ -78,7 +78,7 @@ test('selected Word task stages without calls, skips classification, reviews and await page.locator('.cui-send').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await expect(page.locator('.cui-send')).toHaveAccessibleName('Send'); await expect(page.locator('.cui-input')).toHaveValue(original); await page.locator('.cui-input').fill(''); @@ -149,7 +149,7 @@ test('stale selected task retains draft and requests reselection before any mode await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await selectWord(page); await page.locator('[data-task="polish"]').click(); const draft = await page.locator('.cui-input').inputValue(); @@ -218,7 +218,7 @@ test('native provider still routes staged polish through writing and review', as await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Configured'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await selectWord(page); await page.locator('[data-task="polish"]').click(); await page.locator('.cui-send').click(); diff --git a/test/e2e/agent-write-tools.spec.ts b/test/e2e/agent-write-tools.spec.ts index b15d5129b..8868eab5b 100644 --- a/test/e2e/agent-write-tools.spec.ts +++ b/test/e2e/agent-write-tools.spec.ts @@ -43,14 +43,7 @@ test.describe('agent document writes (real editor)', () => { await page.evaluate(() => window.__toggleAgentPanel?.()); await page.locator('.agent-enable-switch').click(); await expect(page.locator('.agent-runtime-panel')).not.toHaveClass(/agent-panel-hidden/); - const configuredService = await page.evaluate( - () => !!JSON.parse(localStorage.getItem('agent-writing-endpoint') ?? '{}').model, - ); - await expect(page.locator('.agent-runtime-panel')).toHaveAttribute( - 'data-view', - configuredService ? 'chat' : 'settings', - ); - if (!configuredService) await page.locator('.agent-panel-settings-toggle').click(); + // Configuration is an explicit composer action; enabling always starts in Chat. await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); }; @@ -229,7 +222,7 @@ test.describe('agent document writes (real editor)', () => { await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await send(page, 'Insert exactly this plain text at the cursor: Initial draft'); const first = page.locator('.agent-plan-preview').first(); await expect(first.locator('.agent-plan-primary-buttons r-button')).toHaveCount(2); @@ -514,7 +507,7 @@ test.describe('agent document writes (real editor)', () => { await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const shapeTexts = () => page.evaluate(() => { const win = window.__ooFrames.readyEditor() as unknown as { @@ -583,7 +576,7 @@ test.describe('agent document writes (real editor)', () => { await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const before = await countSlides(page); await send(page, 'Please create another page in this presentation and let me review the change'); const review = page.locator('.agent-plan-preview').last(); @@ -670,7 +663,7 @@ test.describe('agent document writes (real editor)', () => { await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); await send(page, 'Insert exactly this plain text at the cursor: Owned paste'); const review = page.locator('.agent-plan-preview').last(); await review.locator('.agent-plan-apply').click(); diff --git a/test/e2e/assistant-loading-locale.spec.ts b/test/e2e/assistant-loading-locale.spec.ts index 56621033a..0b0f54882 100644 --- a/test/e2e/assistant-loading-locale.spec.ts +++ b/test/e2e/assistant-loading-locale.spec.ts @@ -123,7 +123,7 @@ for (const width of [360, 640]) { await page.locator('.agent-panel-load').press('Enter'); await expect.poll(() => heldRequests).toBeGreaterThan(beforeRetry); await expect(page.locator('.agent-panel-load-stop')).toBeVisible(); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const chat = await geometry(); expect(chat).toMatchObject({ aligned: true, diff --git a/test/e2e/lib/artifact-view.ts b/test/e2e/lib/artifact-view.ts new file mode 100644 index 000000000..8572bc1d8 --- /dev/null +++ b/test/e2e/lib/artifact-view.ts @@ -0,0 +1,9 @@ +import { expect, type Page } from '@playwright/test'; + +/** Enter the explicit source editor through its visible product control. */ +export async function editArtifact(page: Page): Promise { + const workbench = page.locator('.agent-artifact-workbench'); + await expect(workbench).toBeVisible(); + if ((await workbench.getAttribute('data-mode')) !== 'edit') await workbench.locator('[data-action=edit]').click(); + await expect(workbench).toHaveAttribute('data-mode', 'edit'); +} diff --git a/test/e2e/lib/conversation-fixture.ts b/test/e2e/lib/conversation-fixture.ts new file mode 100644 index 000000000..204d69bef --- /dev/null +++ b/test/e2e/lib/conversation-fixture.ts @@ -0,0 +1,95 @@ +interface ConversationFixture { + mode: string; + requests: unknown[]; + completed: boolean; + autoFirst: boolean; + streams: number; + advance(): void; +} +declare global { + interface Window { + __conversationFixture: ConversationFixture; + } +} +/** Deterministic public provider events for UI acceptance; no model-quality claims. */ +export function installConversationFixture() { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind: 'loopback', + baseUrl: 'http://localhost:11434', + model: 'craft-fixture', + preference: 'device-first', + localWritingConsent: false, + }), + ); + const state = { + mode: 'reasoning', + requests: [] as unknown[], + completed: false, + autoFirst: true, + streams: 0, + advance: () => {}, + }; + window.__conversationFixture = state; + const nativeFetch = window.fetch.bind(window); + const encoder = new TextEncoder(); + window.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.includes('localhost:11434/api/')) return nativeFetch(input, init); + if (url.endsWith('/tags')) return Response.json({ models: [{ name: 'craft-fixture' }] }); + const body = JSON.parse(String(init?.body ?? '{}')); + state.requests.push(body); + if (body.format?.properties?.task) + return Response.json({ done: true, message: { content: JSON.stringify({ task: 'chat', language: 'en' }) } }); + if (state.mode === 'failure') return new Response('Fixture service failure', { status: 400 }); + state.completed = false; + const steps: Array<{ thinking?: string; content?: string }> = + state.mode === 'artifact' + ? [ + { content: '## Project update\n\nThe migration is on track.\n\n' }, + { content: '- Confirm pilot owners before Friday.\n- Publish the release notes.\n' }, + ] + : [ + ...(state.mode === 'answer-only' + ? [] + : [ + { thinking: 'I’m checking the supplied project notes. ' }, + { thinking: 'I’ll separate progress, risks and the next action.' }, + ]), + { content: '## Project update\n\n' }, + { content: 'The migration is **on track**. The next milestone is the pilot release.\n\n' }, + { content: '| Area | Status |\n| --- | --- |\n| Build | Complete |\n| Pilot | In progress |\n\n' }, + { content: '### Next action\n\n- Confirm the pilot owners.\n- Review the release notes.\n\n' }, + { content: '```ts\nconst status = "ready";\n```\n' }, + ]; + return new Response( + new ReadableStream({ + start(controller) { + state.streams++; + let index = 0; + state.advance = () => { + try { + if (index < steps.length) { + controller.enqueue(encoder.encode(JSON.stringify({ done: false, message: steps[index++] }) + '\n')); + } else { + controller.enqueue( + encoder.encode(JSON.stringify({ done: true, done_reason: 'stop', message: { content: '' } }) + '\n'), + ); + state.completed = true; + controller.close(); + state.advance = () => {}; + } + } catch { + state.advance = () => {}; + } + }; + if (state.autoFirst) state.advance(); + }, + }), + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }; +} diff --git a/test/e2e/lib/layout-geometry.ts b/test/e2e/lib/layout-geometry.ts index 756c03044..dcb0da40a 100644 --- a/test/e2e/lib/layout-geometry.ts +++ b/test/e2e/lib/layout-geometry.ts @@ -45,15 +45,15 @@ export async function contentGeometry(view: Locator) { const origin = panel.getBoundingClientRect().left + Number.parseFloat(getComputedStyle(panel).borderLeftWidth); const left = (node: Element) => node.getBoundingClientRect().left - origin; const header = element.querySelector('.agent-view-header')!; - const back = header.querySelector('.agent-view-back')!; + const back = header.querySelector('.agent-view-back'); const heading = header.querySelector('h2')!; const style = getComputedStyle(element); return { contentLeft: left(element) + Number.parseFloat(style.paddingLeft), headingLeft: left(heading), - iconLeft: left(back.querySelector('svg')!), - backLeft: left(back), - backWidth: back.getBoundingClientRect().width, + iconLeft: back ? left(back.querySelector('svg')!) : undefined, + backLeft: back ? left(back) : undefined, + backWidth: back?.getBoundingClientRect().width, overflow: element.scrollWidth - element.clientWidth, headerBottom: header.getBoundingClientRect().bottom, }; diff --git a/test/e2e/pdf-agent-contract.spec.ts b/test/e2e/pdf-agent-contract.spec.ts index 3cee958ef..fb4212bdb 100644 --- a/test/e2e/pdf-agent-contract.spec.ts +++ b/test/e2e/pdf-agent-contract.spec.ts @@ -121,7 +121,7 @@ test('native PDF preflight rejects body edits, preserves ordinary chat and permi await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Configured'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const input = page.locator('.cui-input'); const annotations = () => page.evaluate(() => @@ -382,7 +382,7 @@ test('PDF assistant reads scoped content and reviews a page note before a revers await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-endpoint-connect').click(); await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); - await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-chat-toggle').click(); const input = page.locator('.cui-input'); await input.fill('What does the current page say?'); await input.press('Enter'); diff --git a/test/unit/agent-artifact-presentation.test.ts b/test/unit/agent-artifact-presentation.test.ts index 646540658..f7efe3131 100644 --- a/test/unit/agent-artifact-presentation.test.ts +++ b/test/unit/agent-artifact-presentation.test.ts @@ -1,3 +1,4 @@ +import * as chatUi from '@ranuts/chat-ui'; import { expect, it, vi } from 'vitest'; import { createArtifactPresentation } from '../../lib/agent-plugin/ui/artifact-presentation'; const labels = { @@ -40,6 +41,7 @@ it('updates canonical slide fields without replacing the active editor', () => { text.value = JSON.stringify(initial); const view = createArtifactPresentation(text, labels, initial); document.body.append(view.el); + view.setEditing(true); const field = view.el.querySelector('textarea')!; field.focus(); field.value = 'Updated'; @@ -66,7 +68,7 @@ it('localizes an invalid preserved source safely and clamps pages after shrinkin text.value = JSON.stringify({ ...p, rows: [['only']] }); text.dispatchEvent(new Event('input')); expect(view.el.textContent).toContain('1 / 1'); - expect(view.el.querySelector('td textarea')).not.toBeNull(); + expect(view.el.querySelector('td')?.textContent).toBe('only'); text.value = 'broken source'; text.dispatchEvent(new Event('input')); expect(() => view.setLabels({ ...labels, importHint: 'Repair this data' })).not.toThrow(); @@ -74,3 +76,56 @@ it('localizes an invalid preserved source safely and clamps pages after shrinkin expect(text.value).toBe('broken source'); view.dispose(); }); + +it('renders structured previews without writable fields until Edit is explicit', () => { + const text = document.createElement('textarea'); + const initial = { kind: 'table' as const, columns: ['Name'], rows: [['one']] }; + text.value = JSON.stringify(initial); + const view = createArtifactPresentation(text, labels, initial); + expect(view.el.querySelector('td')?.textContent).toBe('one'); + expect(view.el.querySelector('textarea')).toBeNull(); + view.setEditing(true); + expect(view.el.querySelector('td textarea')).not.toBeNull(); + view.setEditing(false); + expect(view.el.querySelector('textarea')).toBeNull(); + expect(text.value).toBe(JSON.stringify(initial)); + view.dispose(); +}); + +it('disables exported toolbar controls during a pending save after relocation', () => { + const text = document.createElement('textarea'); + text.value = 'draft'; + const view = createArtifactPresentation(text, labels); + const toolbar = document.createElement('div'); + toolbar.append(view.exportEl); + view.setDisabled(true); + expect(Array.from(toolbar.querySelectorAll('button')).every((button) => button.disabled)).toBe(true); + view.setDisabled(false); + expect(Array.from(toolbar.querySelectorAll('button')).every((button) => !button.disabled)).toBe(true); + view.dispose(); +}); + +it('preserves the hidden reader while typing and renders the latest source only on Preview', () => { + const text = document.createElement('textarea'); + text.value = '**Saved**'; + const view = createArtifactPresentation(text, labels); + const reader = view.el.querySelector('[data-artifact-reader]')!; + const paragraph = reader.firstElementChild; + const render = vi.spyOn(chatUi, 'renderChatMarkdown'); + view.setEditing(true); + expect(reader.hidden).toBe(true); + expect(reader.firstElementChild).toBe(paragraph); + text.value = '**First edit**'; + text.dispatchEvent(new Event('input')); + text.value = '**Latest edit**'; + text.dispatchEvent(new Event('input')); + expect(render).not.toHaveBeenCalled(); + expect(reader.firstElementChild).toBe(paragraph); + expect(reader.textContent).toBe('Saved'); + view.setEditing(false); + expect(render).toHaveBeenCalledTimes(1); + expect(reader.hidden).toBe(false); + expect(reader.querySelector('strong')?.textContent).toBe('Latest edit'); + view.dispose(); + render.mockRestore(); +}); diff --git a/test/unit/agent-artifact-workflow.test.ts b/test/unit/agent-artifact-workflow.test.ts index 043eae994..0826c1b63 100644 --- a/test/unit/agent-artifact-workflow.test.ts +++ b/test/unit/agent-artifact-workflow.test.ts @@ -303,3 +303,80 @@ it('requires a valid same-kind whole structured AI result and tells the provider }); expect(await runArtifactRevision(options)).toContain('two'); }); + +it('separates multiline Original and Proposed text exactly before acceptance', async () => { + const before = 'Confirm the pilot.\nAsk owners before Friday.\n'; + const after = + 'Confirm pilot owners before Friday.\nShare the rollout checklist.\n'; + const bound = captureArtifactDraft({ ...draft, text: before, start: 0, end: 0 }); + let request!: import('../../lib/agent-plugin/ui/artifact-workflow').ArtifactRevisionRequest; + const accept = vi.fn().mockResolvedValue(true); + const flow = createArtifactWorkflow({ + labels, + capture: () => bound, + isCurrent: () => true, + accept, + submit: (r) => { + request = r; + }, + stop: vi.fn(), + }); + document.body.append(flow.el); + flow.el.querySelector('[data-action=refine]')!.click(); + flow.el.querySelector('input')!.value = 'Clarify actions'; + flow.el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + request.complete(after); + expect(flow.el.querySelector('[data-artifact-diff-side=before] .agent-artifact-diff-text')?.textContent).toBe(before); + expect(flow.el.querySelector('[data-artifact-diff-side=after] .agent-artifact-diff-text')?.textContent).toBe(after); + expect(flow.el.querySelector('[data-artifact-diff-label=before]')?.textContent).toBe('Original'); + expect(flow.el.querySelector('[data-artifact-diff-label=after]')?.textContent).toBe('Proposed'); + expect( + flow.el.querySelectorAll( + '.agent-artifact-diff del,.agent-artifact-diff ins,.agent-artifact-diff img,.agent-artifact-diff script', + ), + ).toHaveLength(0); + expect(accept).not.toHaveBeenCalled(); + flow.el.querySelector('[data-action=accept]')!.click(); + await vi.waitFor(() => expect(accept).toHaveBeenCalledWith(bound, after, expect.any(Function))); + flow.dispose(); +}); + +it('bounds multiline excerpts and localizes expanded captions without losing content or focus', () => { + const before = 'Original\n' + 'x'.repeat(10000), + after = 'Proposed\n' + 'y'.repeat(9000); + const bound = captureArtifactDraft({ ...draft, text: before, start: 0, end: 0 }); + let request!: import('../../lib/agent-plugin/ui/artifact-workflow').ArtifactRevisionRequest; + const flow = createArtifactWorkflow({ + labels: { ...labels, partial: 'Partial preview', showFull: 'Show full' }, + capture: () => bound, + isCurrent: () => true, + accept: vi.fn(), + submit: (r) => { + request = r; + }, + stop: vi.fn(), + }); + document.body.append(flow.el); + flow.el.querySelector('[data-action=refine]')!.click(); + const input = flow.el.querySelector('input')!; + input.value = 'Clarify'; + flow.el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + request.complete(after); + expect(flow.el.querySelector('.agent-artifact-diff')!.textContent!.length).toBeLessThan(8000); + const showFull = flow.el.querySelector('[data-action=showFull]')!; + expect(showFull.hidden).toBe(false); + showFull.click(); + const original = flow.el.querySelector('[data-artifact-diff-side=before] .agent-artifact-diff-text')!; + const proposed = flow.el.querySelector('[data-artifact-diff-side=after] .agent-artifact-diff-text')!; + expect(original?.textContent).toBe(before); + expect(proposed?.textContent).toBe(after); + input.focus(); + flow.setLabels({ ...labels, before: '原文', after: '建议修改', partial: '部分预览', showFull: '显示全文' }); + expect(flow.el.querySelector('[data-artifact-diff-label=before]')?.textContent).toBe('原文'); + expect(flow.el.querySelector('[data-artifact-diff-label=after]')?.textContent).toBe('建议修改'); + expect(flow.el.querySelector('[data-artifact-diff-side=before] .agent-artifact-diff-text')).toBe(original); + expect(flow.el.querySelector('[data-artifact-diff-side=after] .agent-artifact-diff-text')).toBe(proposed); + expect(showFull.hidden).toBe(true); + expect(document.activeElement).toBe(input); + flow.dispose(); +}); diff --git a/test/unit/agent-artifacts-view.test.ts b/test/unit/agent-artifacts-view.test.ts index 65bea68dd..6060337ce 100644 --- a/test/unit/agent-artifacts-view.test.ts +++ b/test/unit/agent-artifacts-view.test.ts @@ -42,6 +42,7 @@ it('saves explicitly, preserves a conflicting edit, and applies selected text', document.body.append(view.el); await view.save('original'); const a = (await store.list())[0]; + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'my draft'; text.dispatchEvent(new Event('input')); @@ -84,9 +85,12 @@ it('keeps an unsaved editor when a new result or back navigation is requested', const view = createArtifactsView({ store, labels, onApply: vi.fn() }); document.body.append(view.el); await view.save('first'); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'edited'; text.dispatchEvent(new Event('input')); + await view.refresh(); + expect(view.el.querySelector('textarea')).toBe(text); const pendingSave = view.save('second'); expect(await store.list()).toHaveLength(1); expect(text.value).toBe('edited'); @@ -104,6 +108,7 @@ it('reports storage failures without discarding edited text', async () => { const view = createArtifactsView({ store, labels, onApply: () => Promise.reject(new Error('private')) }); document.body.append(view.el); await view.save('first'); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'draft'; text.dispatchEvent(new Event('input')); @@ -132,6 +137,7 @@ it('disables editing during an in-flight save and focuses the new editor on sett }); return original(...args); }); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'second'; text.dispatchEvent(new Event('input')); @@ -139,7 +145,7 @@ it('disables editing during an in-flight save and focuses the new editor on sett expect(text.disabled).toBe(true); release(); await vi.waitFor(() => expect(view.el.querySelector('textarea')?.value).toBe('second')); - await vi.waitFor(() => expect(document.activeElement).toBe(view.el.querySelector('textarea'))); + await vi.waitFor(() => expect(document.activeElement).toBe(view.el.querySelector('[data-artifact-reader]'))); view.dispose(); }); it('keeps provenance human-readable after a language change', async () => { @@ -168,6 +174,7 @@ it('discards both the edited title and text when returning to the latest version const select = view.el.querySelector('select')!; select.value = '1'; select.dispatchEvent(new Event('change')); + view.el.querySelector('[data-action=edit]')?.click(); const title = view.el.querySelector('input')!; title.value = 'Unsaved title'; title.dispatchEvent(new Event('input')); @@ -193,6 +200,7 @@ it('offers a working save-as-new recovery when an artifact reaches twenty versio await view.refresh(); (view.el.querySelector('[data-artifact]') as HTMLButtonElement).click(); await vi.waitFor(() => expect(view.el.querySelector('textarea')).not.toBeNull()); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'New draft'; text.dispatchEvent(new Event('input')); @@ -217,6 +225,7 @@ it.each([ const view = createArtifactsView({ store, labels, onApply: vi.fn() }); document.body.append(view.el); await view.save('Saved body'); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'Keep this draft'; text.dispatchEvent(new Event('input')); @@ -241,6 +250,7 @@ it('canceling a version switch preserves the previously selected version and edi const select = view.el.querySelector('select')!; select.value = '1'; select.dispatchEvent(new Event('change')); + view.el.querySelector('[data-action=edit]')?.click(); const title = view.el.querySelector('input')!; title.value = 'Keep my title'; title.dispatchEvent(new Event('input')); @@ -258,6 +268,7 @@ it('reports cancelled save after keeping a dirty draft and settles pending confi const view = createArtifactsView({ store, labels, onApply: vi.fn() }); document.body.append(view.el); expect(await view.save('first')).toBe('saved'); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'unsaved'; text.dispatchEvent(new Event('input')); @@ -288,6 +299,7 @@ it('settles replaced confirmations and cancels navigation without adopting a lat const view = createArtifactsView({ store, labels, onApply: vi.fn() }); document.body.append(view.el); await view.save('first'); + view.el.querySelector('[data-action=edit]')!.click(); const text = view.el.querySelector('textarea')!; text.value = 'draft'; text.dispatchEvent(new Event('input')); @@ -298,27 +310,51 @@ it('settles replaced confirmations and cancels navigation without adopting a lat expect(await second).toBe('cancelled'); expect(view.el.querySelector('[data-action=discardChanges]')).toBeNull(); expect(text.value).toBe('draft'); - // Return to the list explicitly before testing a delayed successful write. - view.el.querySelector('[data-action=back]')!.click(); - view.el.querySelector('[data-action=discardChanges]')!.click(); - await tick(); + let releaseList!: () => void; + const listGate = new Promise((resolve) => { + releaseList = resolve; + }); + const list = store.list.bind(store); + vi.spyOn(store, 'list').mockImplementationOnce(async () => { + await listGate; + return list(); + }); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); - const create = store.create.bind(store); - vi.spyOn(store, 'create').mockImplementation(async (input) => { - const a = await create(input); - await gate; - return a; - }); - const pending = view.save('late result'); - view.cancelPending(); - expect(await pending).toBe('cancelled'); - release(); - await tick(); - expect(view.el.querySelector('textarea')).toBeNull(); - view.dispose(); + try { + view.el.querySelector('[data-action=back]')!.click(); + view.el.querySelector('[data-action=discardChanges]')!.click(); + // A slow list read still owns the old draft until navigation settles. + expect(view.el.querySelector('textarea')).toBe(text); + expect(text.value).toBe('draft'); + releaseList(); + await vi.waitFor(() => expect(view.el.querySelector('[data-artifact]')).not.toBeNull()); + const row = view.el.querySelector('[data-artifact]')!; + expect(view.el.querySelector('textarea')).toBeNull(); + const create = store.create.bind(store); + vi.spyOn(store, 'create').mockImplementation(async (input) => { + const a = await create(input); + await gate; + return a; + }); + const pending = view.save('late result'); + expect(row.disabled).toBe(true); + view.cancelPending(); + expect(await pending).toBe('cancelled'); + expect(view.el.querySelector('textarea')).toBeNull(); + release(); + // Re-enabling this same row proves the cancelled write's perform() has settled. + await vi.waitFor(() => expect(row.disabled).toBe(false)); + expect(view.el.querySelector('[data-artifact]')).toBe(row); + expect(view.el.querySelector('textarea')).toBeNull(); + expect(view.current).toBeUndefined(); + } finally { + releaseList(); + release(); + view.dispose(); + } }); it('accepts a range proposal into an unchanged draft only, without saving automatically', async () => { let request: import('../../lib/agent-plugin/ui/artifact-workflow').ArtifactRevisionRequest | undefined; @@ -351,6 +387,7 @@ it('accepts a range proposal into an unchanged draft only, without saving automa }); document.body.append(view.el); await view.save('前😀selected后'); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.setSelectionRange(3, 11); (view.el.querySelector('[data-action=refine]') as HTMLButtonElement).click(); @@ -397,6 +434,7 @@ it('invalidates late proposals after manual edits and offers direct keep-as-new (view.el.querySelector('[data-action=refine]') as HTMLButtonElement).click(); (view.el.querySelector('form input') as HTMLInputElement).value = 'Revise'; view.el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'My new edit'; text.dispatchEvent(new Event('input')); @@ -518,6 +556,7 @@ it.each(['draft', 'context'] as const)( held = true; (view.el.querySelector('[data-action=accept]') as HTMLButtonElement).click(); await vi.waitFor(() => expect(finish).toBeDefined()); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; if (change === 'draft') { text.value = 'My newer draft'; @@ -571,6 +610,7 @@ it('restores selected instructions without old offsets and requires an explicit start: 0, end: 8, }); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'My newer draft'; text.dispatchEvent(new Event('input')); @@ -650,8 +690,10 @@ it('imports a validated structured result, paginates preview, preserves JSON edi document.body.append(view.el); await view.refresh(); (view.el.querySelector('[data-action=import]') as HTMLButtonElement).click(); + view.el.querySelector('[data-action=edit]')?.click(); const title = view.el.querySelector('input')!; title.value = 'Table'; + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = JSON.stringify({ schemaVersion: 1, @@ -669,6 +711,7 @@ it('imports a validated structured result, paginates preview, preserves JSON edi ).toBeTruthy(); expect((view.el.querySelector('[data-action=apply]') as HTMLButtonElement).hidden).toBe(true); expect(apply).not.toHaveBeenCalled(); + view.el.querySelector('[data-action=edit]')!.click(); const editor = view.el.querySelector('textarea[data-artifact-source]')!; editor.value = 'invalid JSON'; editor.dispatchEvent(new Event('input')); @@ -687,6 +730,7 @@ it('keeps a structured cell focused while typing, persists edits and can restore await view.refresh(); (view.el.querySelector('[data-artifact]') as HTMLButtonElement).click(); await tick(); + view.el.querySelector('[data-action=edit]')!.click(); const cell = view.el.querySelector('td textarea')!; cell.focus(); cell.value = 'two'; @@ -694,13 +738,15 @@ it('keeps a structured cell focused while typing, persists edits and can restore cell.dispatchEvent(new Event('input')); expect(document.activeElement).toBe(cell); expect(cell.selectionStart).toBe(2); - expect(view.el.querySelector('.agent-artifact-editor > details')?.open).toBe(false); + expect(view.el.querySelector('.agent-artifact-source')?.open).toBe(false); (view.el.querySelector('[data-action=saveVersion]') as HTMLButtonElement).click(); await tick(); expect((await store.get(a.id))?.versions[1].payload).toEqual({ ...payload, rows: [['two']] }); const select = view.el.querySelector('select')!; select.value = '1'; select.dispatchEvent(new Event('change')); + expect(view.el.querySelector('td')?.textContent).toBe('one'); + view.el.querySelector('[data-action=edit]')!.click(); expect(view.el.querySelector('td textarea')?.value).toBe('one'); view.dispose(); }); @@ -713,6 +759,7 @@ it('retains imported text payload envelopes when editing a version', async () => await view.refresh(); (view.el.querySelector('[data-artifact]') as HTMLButtonElement).click(); await tick(); + view.el.querySelector('[data-action=edit]')?.click(); const text = view.el.querySelector('textarea')!; text.value = 'two'; text.dispatchEvent(new Event('input')); @@ -740,13 +787,12 @@ it('keeps destination-aware header Back in the busy registry and guards imported document.body.append(view.el); await view.refresh(); const back = () => view.el.querySelector('.agent-view-header [data-action=back]')!; - expect(back().getAttribute('aria-label')).toBe('Back to conversation'); - expect(view.el.querySelectorAll('[data-action=back]')).toHaveLength(1); + expect(back()).toBeNull(); + expect(view.el.querySelectorAll('[data-action=back]')).toHaveLength(0); let resolve!: (value: typeof a) => void; vi.spyOn(store, 'get').mockImplementationOnce(() => new Promise((r) => (resolve = r))); view.el.querySelector('[data-artifact]')!.click(); - expect(back().disabled).toBe(true); - back().click(); + expect(view.el.querySelector('[data-artifact]')!.disabled).toBe(true); expect(close).not.toHaveBeenCalled(); resolve(a); await tick(); @@ -770,6 +816,217 @@ it('keeps destination-aware header Back in the busy registry and guards imported view.el.querySelector('[data-action=discardChanges]')!.click(); await tick(); expect(view.el.querySelector('textarea')).toBeNull(); - expect(view.el.querySelectorAll('[data-action=back]')).toHaveLength(1); + expect(view.el.querySelectorAll('[data-action=back]')).toHaveLength(0); + view.dispose(); +}); + +it('preserves a busy import when workspace navigation requests a list refresh', async () => { + const store = createArtifactStore(); + const view = createArtifactsView({ store, labels: { ...labels, import: 'Import' }, onApply: vi.fn() }); + document.body.append(view.el); + await view.refresh(); + view.el.querySelector('[data-action=import]')!.click(); + const input = view.el.querySelector('textarea')!; + view.el.querySelector('input')!.value = 'Imported'; + input.value = JSON.stringify({ schemaVersion: 1, payload: { kind: 'text', text: 'imported' } }); + let release!: () => void; + const create = store.create.bind(store); + vi.spyOn(store, 'create').mockImplementation(async (...args) => { + await new Promise((resolve) => (release = resolve)); + return create(...args); + }); + view.el.querySelector('[data-action=saveNew]')!.click(); + expect(input.disabled).toBe(true); + view.cancelPending(); + await view.refresh(); + expect(view.el.querySelector('textarea')).toBe(input); + expect(input.disabled).toBe(true); + release(); + await vi.waitFor(() => expect(view.el.querySelector('textarea')?.value).toBe('imported')); + view.dispose(); +}); + +it('focuses the safe deletion choice and returns to Delete when cancelled', async () => { + const store = createArtifactStore(); + const view = createArtifactsView({ store, labels, onApply: vi.fn() }); + document.body.append(view.el); + await view.save('Keep this draft'); + const remove = view.el.querySelector('[data-action=delete]')!; + remove.focus(); + remove.click(); + const cancel = view.el.querySelector('[data-action=cancel]')!; + expect(view.el.querySelector('.agent-artifact-delete-prompt')?.textContent).toContain('confirmDelete'); + expect(view.el.querySelector('[data-action=confirmDelete]')?.textContent).toBe('delete'); + expect(view.el.querySelector('[role=status]')?.textContent).toBe('saved'); + expect(document.activeElement).toBe(cancel); + cancel.click(); + expect(document.activeElement).toBe(remove); + expect(view.el.querySelector('textarea')?.value).toBe('Keep this draft'); + expect(await store.list()).toHaveLength(1); + view.dispose(); +}); + +it('opens saved text as safe readable preview and keeps the canonical draft across modes', async () => { + const store = createArtifactStore(); + const view = createArtifactsView({ store, labels, onApply: vi.fn() }); + document.body.append(view.el); + await view.save('# Heading\n\n**Readable** '); + const text = view.el.querySelector('[data-artifact-source]')!; + const title = view.el.querySelector('input')!; + const reader = view.el.querySelector('[data-artifact-reader]')!; + expect(reader?.querySelector('h2')?.textContent).toBe('Heading'); + expect(reader?.querySelector('img')).toBeNull(); + expect(reader?.textContent).toContain('('[data-action=edit]')!.click(); + expect(text.hidden).toBe(false); + expect(title.readOnly).toBe(false); + text.value = '**Unsaved**'; + text.dispatchEvent(new Event('input')); + view.el.querySelector('[data-action=preview]')!.click(); + expect(text.hidden).toBe(true); + expect(reader.querySelector('strong')?.textContent).toBe('Unsaved'); + expect((await store.get(view.current!.id))!.versions).toHaveLength(1); + view.el.querySelector('[data-action=edit]')!.click(); + expect(text.value).toBe('**Unsaved**'); + view.el.querySelector('[data-action=cancelEditing]')!.click(); + expect(text.value).toBe('**Unsaved**'); + view.el.querySelector('[data-action=cancel]')!.click(); + expect(text.hidden).toBe(false); + view.el.querySelector('[data-action=cancelEditing]')!.click(); + view.el.querySelector('[data-action=discardChanges]')!.click(); + expect(text.value).toContain('# Heading'); + expect(text.hidden).toBe(true); + view.dispose(); +}); + +it('refines the whole preview despite stale hidden selection and enters Edit on acceptance', async () => { + let request!: import('../../lib/agent-plugin/ui/artifact-workflow').ArtifactRevisionRequest; + const store = createArtifactStore(); + const view = createArtifactsView({ + store, + labels, + onApply: vi.fn(), + refinement: { + labels: { + refine: 'Refine', + instruction: 'Instruction', + submit: 'Suggest', + stop: 'Stop', + accept: 'Accept', + reject: 'Reject', + waiting: 'Waiting', + ready: 'Ready', + stale: 'Stale', + failed: 'Failed', + stopped: 'Stopped', + accepted: 'Accepted', + }, + submit: (r) => { + request = r; + }, + stop: vi.fn(), + }, + }); + document.body.append(view.el); + await view.save('whole artifact'); + const text = view.el.querySelector('[data-artifact-source]')!; + text.setSelectionRange(0, 5); + view.el.querySelector('[data-action=refine]')!.click(); + view.el.querySelector('form input')!.value = 'Revise'; + view.el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + expect(request.draft.scope).toBe('artifact'); + expect(request.draft.selectedText).toBe('whole artifact'); + request.complete('accepted draft'); + view.el.querySelector('[data-action=accept]')!.click(); + await vi.waitFor(() => expect(text.value).toBe('accepted draft')); + expect(view.el.querySelector('.agent-artifact-status')?.textContent).toBe(''); + expect(view.el.querySelector('.agent-artifact-unsaved')!.hidden).toBe(false); + expect(view.el.querySelector('[data-action=saveVersion]')!.disabled).toBe(false); + expect(text.hidden).toBe(false); + expect(document.activeElement).toBe(text); + expect((await store.get(view.current!.id))!.versions).toHaveLength(1); + view.dispose(); +}); + +it('keeps one export toolbar bound to the selected version', async () => { + const store = createArtifactStore(); + const a = await store.create({ title: 'Versions', text: 'one', provenance: {} }); + await store.update(a.id, 1, { title: 'Versions', text: 'two' }); + const view = createArtifactsView({ store, labels, onApply: vi.fn() }); + document.body.append(view.el); + await view.refresh(); + view.el.querySelector('[data-artifact]')!.click(); + await tick(); + const version = view.el.querySelector('select')!; + version.value = '1'; + version.dispatchEvent(new Event('change')); + expect(view.el.querySelectorAll('.agent-artifact-export')).toHaveLength(1); + view.dispose(); +}); + +it('localizes the mode group along with its Preview and Edit controls', async () => { + const view = createArtifactsView({ store: createArtifactStore(), labels, onApply: vi.fn() }); + await view.save('Saved'); + view.setLabels({ ...labels, preview: '预览', edit: '编辑' }); + expect(view.el.querySelector('.agent-artifact-modes')?.getAttribute('aria-label')).toBe('预览'); + expect(view.el.querySelector('[data-action=preview]')?.textContent).toBe('预览'); + expect(view.el.querySelector('[data-action=edit]')?.textContent).toBe('编辑'); + view.dispose(); +}); + +it.each(['input', 'textarea'] as const)( + 'clears obsolete save feedback when %s changes or an older version is selected', + async (selector) => { + const store = createArtifactStore(); + const view = createArtifactsView({ store, labels, onApply: vi.fn() }); + await view.save('Saved body'); + const status = view.el.querySelector('[role=status]')!; + expect(status.textContent).toBe('saved'); + view.el.querySelector('[data-action=edit]')!.click(); + const field = view.el.querySelector(selector)!; + field.value = 'Updated draft'; + field.dispatchEvent(new Event('input')); + expect(status.textContent).toBe(''); + view.el.querySelector('[data-action=saveVersion]')!.click(); + await vi.waitFor(() => expect(status.textContent).toBe('saved')); + const version = view.el.querySelector('select')!; + version.value = '1'; + version.dispatchEvent(new Event('change')); + expect(status.textContent).toBe(''); + expect(view.el.querySelector('[data-action=saveVersion]')!.disabled).toBe(false); + expect((await store.get(view.current!.id))!.versions).toHaveLength(2); + view.dispose(); + }, +); + +it('keeps discard prompts usable while typing for Cancel editing and pending result saves', async () => { + const store = createArtifactStore(); + const view = createArtifactsView({ store, labels, onApply: vi.fn() }); + await view.save('Saved body'); + view.el.querySelector('[data-action=edit]')!.click(); + const text = view.el.querySelector('[data-artifact-source]')!; + text.value = 'Unsaved draft'; + text.dispatchEvent(new Event('input')); + view.el.querySelector('[data-action=cancelEditing]')!.click(); + const status = view.el.querySelector('[role=status]')!; + const discard = status.querySelector('[data-action=discardChanges]')!; + text.value = 'Continue typing'; + text.dispatchEvent(new Event('input')); + expect(status.querySelector('[data-action=discardChanges]')).toBe(discard); + expect(status.textContent).toContain('discardChanges'); + status.querySelector('[data-action=cancel]')!.click(); + expect(text.value).toBe('Continue typing'); + expect(text.hidden).toBe(false); + const pending = view.save('New result'); + const pendingDiscard = status.querySelector('[data-action=discardChanges]'); + text.value = 'Keep typing in pending confirmation'; + text.dispatchEvent(new Event('input')); + expect(status.querySelector('[data-action=discardChanges]')).toBe(pendingDiscard); + status.querySelector('[data-action=cancel]')!.click(); + expect(await pending).toBe('cancelled'); + expect(text.value).toBe('Keep typing in pending confirmation'); + expect(await store.list()).toHaveLength(1); view.dispose(); }); diff --git a/test/unit/agent-panel-loading.test.ts b/test/unit/agent-panel-loading.test.ts index 91eb94251..db0d4f7fe 100644 --- a/test/unit/agent-panel-loading.test.ts +++ b/test/unit/agent-panel-loading.test.ts @@ -491,9 +491,11 @@ it('opening the default model leaves preparation to the user', async () => { panel.querySelector('.agent-panel-load')!.click(); await vi.waitFor(() => expect(state.preload).toHaveBeenCalledOnce()); }); -it('opens first-use assistant settings without preparing a model', async () => { +it('starts in chat and opens first-use settings only on request without preparing a model', async () => { const panel = createAgentPanel({ externalEntry: true }); - await vi.waitFor(() => expect(panel.dataset.view).toBe('settings')); + expect(panel.dataset.view).toBe('chat'); + panel.querySelector('.agent-panel-settings-toggle')!.click(); + expect(panel.dataset.view).toBe('settings'); expect(state.preload).not.toHaveBeenCalled(); }); it('opens chat for a remembered model even when its runtime is not loaded', () => { @@ -529,7 +531,9 @@ it('opens chat for a configured service without connecting on its own', () => { it('keeps CPU-only setup free of WebGPU cache inspection until inventory is opened', async () => { localStorage.setItem('agent-panel-provider', 'wllama'); const panel = createAgentPanel({ externalEntry: true }); - await vi.waitFor(() => expect(panel.dataset.view).toBe('settings')); + expect(panel.dataset.view).toBe('chat'); + panel.querySelector('.agent-panel-settings-toggle')!.click(); + expect(panel.dataset.view).toBe('settings'); expect(state.cacheChecks).not.toHaveBeenCalled(); const inventory = panel.querySelector('.agent-model-cache')!; inventory.open = true; @@ -581,6 +585,8 @@ it('does not inspect browser runtime caches for service setup until the local so ); const panel = createAgentPanel({ externalEntry: true }); await new Promise((resolve) => setTimeout(resolve, 0)); + expect(panel.dataset.view).toBe('chat'); + panel.querySelector('.agent-panel-settings-toggle')!.click(); expect(panel.dataset.view).toBe('settings'); expect(state.cacheChecks).not.toHaveBeenCalled(); expect(state.preload).not.toHaveBeenCalled(); @@ -596,20 +602,53 @@ it('keeps setup reachable for an incomplete cloud service', async () => { JSON.stringify({ version: 1, kind: 'openai-compatible', baseUrl: 'https://missing-key.example', model: 'model' }), ); const panel = createAgentPanel({ externalEntry: true }); - await vi.waitFor(() => expect(panel.dataset.view).toBe('settings')); + expect(panel.dataset.view).toBe('chat'); + panel.querySelector('.agent-panel-settings-toggle')!.click(); + expect(panel.dataset.view).toBe('settings'); }); it('keeps setup reachable when the remembered preset is no longer supported', async () => { localStorage.setItem('agent-local-preset', 'Qwen3-1.7B-q4f16_1-MLC'); const panel = createAgentPanel({ externalEntry: true }); - await vi.waitFor(() => expect(panel.dataset.view).toBe('settings')); + expect(panel.dataset.view).toBe('chat'); + panel.querySelector('.agent-panel-settings-toggle')!.click(); + expect(panel.dataset.view).toBe('settings'); }); it('does not override an explicit view change while checking cached configuration', async () => { const panel = createAgentPanel({ externalEntry: true }); panel.querySelector('.agent-panel-settings-toggle')!.click(); - panel.querySelector('.agent-panel-settings-toggle')!.click(); + panel.querySelector('.agent-chat-toggle')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(panel.dataset.view).toBe('chat'); }); +it.each(['.agent-chat-toggle', '.agent-panel-title', '.agent-panel-clear'])( + 'honors explicit %s navigation when a pending initial cache check finds no model', + async (selector) => { + const webllm = await import('@ranuts/agent-core/llm/webllm'); + let finishCheck!: (cached: boolean) => void; + const checking = new Promise((resolve) => (finishCheck = resolve)); + const cache = vi.spyOn(webllm, 'isModelCached').mockReturnValue(checking); + try { + const panel = createAgentPanel({ externalEntry: true }); + expect(cache).toHaveBeenCalled(); + expect(panel.dataset.view).toBe('chat'); + const input = panel.querySelector('.cui-input')!; + const focus = vi.spyOn(input, 'focus'); + const previousConversation = activeSession(); + panel.querySelector(selector)!.click(); + if (selector === '.agent-panel-clear') expect(activeSession()).not.toBe(previousConversation); + else { + expect(activeSession()).toBe(previousConversation); + expect(focus).not.toHaveBeenCalled(); + } + finishCheck(false); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(panel.dataset.view).toBe('chat'); + expect(state.preload).not.toHaveBeenCalled(); + } finally { + cache.mockRestore(); + } + }, +); it.each(['starting', 'listening'])( 'does not cancel %s dictation when the initial cache check finishes', async (phase) => { @@ -814,7 +853,7 @@ it('keeps loading feedback beside the chosen model across settings and chat', as expect(panel.querySelector('.agent-device-section .agent-runtime-row')).not.toBeNull(); expect(load.hidden).toBe(true); expect(panel.querySelector('.agent-model-cache .agent-runtime-row')).toBeNull(); - toggle.click(); + panel.querySelector('.agent-chat-toggle')!.click(); expect(stop.closest('.agent-panel-settings')).toBeNull(); expect(stop.hidden).toBe(false); stop.click(); @@ -2987,8 +3026,10 @@ it.each(['', 'Hello there'])( expect(state.conversations!.history().load()).toEqual([]); expect(state.writing).not.toHaveBeenCalled(); const setup = panel.querySelector('.agent-prepare-action')!; - expect(setup.hidden).toBe(Boolean(draft)); - if (!draft) setup.click(); + expect(setup.hidden).toBe(true); + panel.querySelector('.agent-chat-toggle')!.click(); + expect(input.value).toBe(draft); + panel.querySelector('.agent-model-shortcut')!.click(); expect(panel.getAttribute('data-view')).toBe('settings'); }, ); @@ -3338,9 +3379,12 @@ it('opens the latest meaningful chat from the assistant title without loading op expect(title).not.toBeNull(); expect(title!.type).toBe('button'); expect(title!.textContent).toBe(t('agentTitle')); + const displayed = store.activeId; panel.querySelector('.agent-panel-settings-toggle')!.click(); title!.click(); expect(panel.dataset.view).toBe('chat'); + expect(store.activeId).toBe(displayed); + title!.click(); expect(store.activeId).toBe(latest); expect(panel.querySelector('.cui-msg-user')?.textContent).toBe('Latest chat'); expect(restore).not.toHaveBeenCalled(); diff --git a/test/unit/agent-panel-owners.test.ts b/test/unit/agent-panel-owners.test.ts index 40b0e5175..1b24828c1 100644 --- a/test/unit/agent-panel-owners.test.ts +++ b/test/unit/agent-panel-owners.test.ts @@ -189,3 +189,64 @@ it('coordinates workspace visibility, capture, focus, runtime placement and Esca expect(beforeChange).toHaveBeenCalledWith('chat'); expect(changed).toHaveBeenCalledWith('chat'); }); + +it('keeps destination selection idempotent and exposes the selected navigation state', () => { + const panel = document.createElement('div'); + panel.dataset.view = 'chat'; + const chat = { el: document.createElement('div'), focus: vi.fn() }; + const row = document.createElement('div'); + const settings = document.createElement('div'); + const modelRow = document.createElement('div'); + const modelActions = document.createElement('div'); + modelRow.append(modelActions); + settings.append(modelRow); + panel.append(row, settings, chat.el); + const chatTrigger = document.createElement('button'); + chatTrigger.scrollIntoView = vi.fn(); + const surface = (el = document.createElement('div')) => ({ + el, + trigger: document.createElement('button'), + focus: vi.fn(), + }); + const surfaces = { + settings: surface(settings), + history: surface(), + sources: surface(), + artifacts: surface(), + }; + const navigation = { capture: vi.fn(), restore: vi.fn(() => false) }; + const changed = vi.fn(); + const beforeChange = vi.fn(); + const workspace = createPanelWorkspace({ + panel, + chat, + chatTrigger, + navigation, + conversation: () => 'current draft', + isOpen: () => true, + runtime: { row, settings, modelRow, modelActions }, + views: surfaces, + beforeChange, + changed, + }); + workspace.show('chat'); + chatTrigger.click(); + expect(beforeChange).toHaveBeenCalledTimes(1); + expect(chat.focus).toHaveBeenCalledTimes(1); + expect(chatTrigger.getAttribute('aria-current')).toBe('page'); + expect(chatTrigger.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' }); + vi.mocked(chatTrigger.scrollIntoView).mockClear(); + chatTrigger.dispatchEvent(new FocusEvent('focus')); + expect(chatTrigger.scrollIntoView).toHaveBeenCalledOnce(); + surfaces.artifacts.trigger.click(); + expect(surfaces.artifacts.trigger.getAttribute('aria-current')).toBe('page'); + expect(chatTrigger.hasAttribute('aria-current')).toBe(false); + const effects = [beforeChange.mock.calls.length, changed.mock.calls.length]; + surfaces.artifacts.trigger.click(); + expect(panel.dataset.view).toBe('artifacts'); + expect([beforeChange.mock.calls.length, changed.mock.calls.length]).toEqual(effects); + expect(surfaces.artifacts.focus).toHaveBeenCalledTimes(1); + chatTrigger.click(); + expect(navigation.restore).toHaveBeenLastCalledWith('current draft', true); + expect(chatTrigger.getAttribute('aria-current')).toBe('page'); +}); diff --git a/test/unit/agent-panel.test.ts b/test/unit/agent-panel.test.ts index 7547f4380..160a1b4a1 100644 --- a/test/unit/agent-panel.test.ts +++ b/test/unit/agent-panel.test.ts @@ -1,5 +1,6 @@ import { t, getLanguage, setLanguage, LanguageCode } from '@ranuts/shared/i18n'; import * as historyList from '../../lib/agent-plugin/ui/history-list'; +import * as sessionOwner from '../../lib/agent-plugin/ui/sessions'; import { ChatView } from '@ranuts/chat-ui'; import type { EditorApi } from '../../lib/agent-plugin/editor-bridge'; import { afterEach, expect, it, vi } from 'vitest'; @@ -707,3 +708,79 @@ it('preserves an expanded selected-text disclosure and its scroller while contex expect(trigger.querySelector('svg')).not.toBeNull(); expect(trigger.getAttribute('aria-label')).toContain(String(selection.length)); }); + +it.each(['meaningful', 'blank'])( + 'returns to the displayed %s conversation instead of a newer conversation', + async (kind) => { + const store = sessionOwner.createConversationStore(); + const selected = store.create().id; + if (kind === 'meaningful') store.history().save([{ role: 'user', content: 'Older selected conversation' }]); + await new Promise((resolve) => setTimeout(resolve, 5)); + store.create(); + store.history().save([{ role: 'user', content: 'Newer conversation' }]); + store.select(selected); + const factory = vi.spyOn(sessionOwner, 'createConversationStore').mockReturnValue(store); + try { + const panel = createAgentPanel(); + const input = panel.querySelector('.cui-input')!; + input.value = 'A draft belonging to the selected conversation'; + input.dispatchEvent(new Event('input', { bubbles: true })); + for (const route of [ + '.agent-sources-nav', + '.agent-panel-settings-toggle', + '.agent-workspace-toggle', + '.agent-history-toggle', + ]) { + panel.querySelector(route)!.click(); + panel.querySelector('.agent-chat-toggle')!.click(); + expect(store.activeId).toBe(selected); + expect(input.value).toBe('A draft belonging to the selected conversation'); + } + } finally { + factory.mockRestore(); + } + }, +); + +it('keeps primary header routes and contextual composer entries while preserving the current draft', async () => { + const panel = createAgentPanel(); + const nav = panel.querySelector('.agent-workspace-nav')!; + expect(nav).not.toBeNull(); + expect(nav.closest('.agent-panel-header')).not.toBeNull(); + expect(nav.querySelectorAll('button')).toHaveLength(3); + const destinations = [ + '.agent-chat-toggle', + '.agent-workspace-toggle', + '.agent-history-toggle', + '.agent-sources-nav', + '.agent-panel-settings-toggle', + ]; + for (const selector of destinations) expect(panel.querySelector(selector)?.getAttribute('aria-label')).toBeTruthy(); + expect(panel.querySelector('.agent-sources-nav')?.closest('.cui-composer')).not.toBeNull(); + expect(panel.querySelector('.agent-panel-settings-toggle')?.closest('.cui-footer')).not.toBeNull(); + expect(panel.querySelector('.agent-panel-header .agent-panel-settings-toggle')).toBeNull(); + const input = panel.querySelector('.cui-input')!; + input.value = 'Keep this exact conversation draft'; + input.dispatchEvent(new Event('input', { bubbles: true })); + for (const selector of destinations.slice(1)) { + nav.querySelector('.agent-chat-toggle')!.click(); + panel.querySelector(selector)!.click(); + expect(panel.querySelector('.agent-runtime-row')?.hasAttribute('hidden')).toBe(true); + } + expect(panel.querySelector('.agent-panel-settings .agent-view-header button')).toBeNull(); + expect(panel.querySelector('.agent-history-view .agent-view-header button')).toBeNull(); + expect(panel.querySelector('.agent-sources-view .agent-view-header button')).toBeNull(); + expect(panel.querySelector('.agent-artifacts .agent-view-header button')).toBeNull(); + nav.querySelector('.agent-chat-toggle')!.click(); + expect(input.value).toBe('Keep this exact conversation draft'); + expect(panel.dataset.view).toBe('chat'); + expect(nav.querySelector('[aria-current=page]')?.classList.contains('agent-chat-toggle')).toBe(true); + panel.querySelector('.agent-sources-toggle')!.click(); + expect(panel.dataset.view).toBe('sources'); + expect(panel.querySelector('.agent-sources-toggle')?.getAttribute('aria-expanded')).toBe('true'); + panel + .querySelector('.agent-sources-view')! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(panel.dataset.view).toBe('chat'); + expect(panel.querySelector('.agent-sources-toggle')?.getAttribute('aria-expanded')).toBe('false'); +}); diff --git a/test/unit/agent-presentation.test.ts b/test/unit/agent-presentation.test.ts index e58d0e184..0b220b68f 100644 --- a/test/unit/agent-presentation.test.ts +++ b/test/unit/agent-presentation.test.ts @@ -232,7 +232,12 @@ it.each(SHELL_LOCALES)( (language) => { try { setLanguage(language); - for (const key of ['agentNoCompletedAnswer', 'agentParagraphSelectionConflict'] as const) { + for (const key of [ + 'agentNoCompletedAnswer', + 'agentParagraphSelectionConflict', + 'agentWordFormatUnsupported', + 'agentWordFormattingReviewUnsupported', + ] as const) { const localized = t(key); expect(displayError(new Error(key))).toBe(localized); expect(displayError(displayError(key))).toBe(localized); diff --git a/test/unit/agent-public-reasoning.test.ts b/test/unit/agent-public-reasoning.test.ts new file mode 100644 index 000000000..c733d3fc1 --- /dev/null +++ b/test/unit/agent-public-reasoning.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it, vi } from 'vitest'; +import { OpenAIProvider } from '../../packages/agent-core/src/llm/openai'; +import { OllamaProvider } from '../../packages/agent-core/src/llm/ollama'; +import { GeminiProvider } from '../../packages/agent-core/src/llm/gemini'; +import { LoopbackProvider } from '../../packages/agent-core/src/llm/loopback'; +import { AnthropicProvider, type AnthropicStream } from '../../packages/agent-core/src/llm/anthropic'; +import { runAgent, type AgentEvent } from '../../packages/agent-core/src/runtime'; +import type { LLMProvider, LLMResponse } from '../../packages/agent-core/src/llm/types'; +import { AgentChatController } from '../../lib/agent-plugin/ui/controller'; +import { generateReadAnswer } from '../../lib/agent-plugin/document-read-answer'; + +const answer = (text = 'answer'): LLMResponse => ({ + text, + toolCalls: [], + stopReason: 'stop', + assistant: { role: 'assistant', content: text }, +}); +const sse = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`; +const thought = 'Public summary: \u516c\u5f00\u6458\u8981 🧭'; +const formats = [ + { + name: 'OpenAI', + create: (fetchImpl: typeof fetch) => new OpenAIProvider({ apiKey: 'test', fetchImpl }), + type: 'text/event-stream', + stream: + sse({ choices: [{ delta: { reasoning_content: thought, reasoning: 'duplicate alias' } }] }) + + sse({ choices: [{ delta: { content: 'answer' }, finish_reason: 'stop' }] }), + json: { choices: [{ message: { content: 'answer', reasoning: thought }, finish_reason: 'stop' }] }, + }, + { + name: 'Ollama', + create: (fetchImpl: typeof fetch) => new OllamaProvider({ fetchImpl }), + type: 'text/event-stream', + stream: + sse({ choices: [{ delta: { reasoning: thought } }] }) + + sse({ choices: [{ delta: { content: 'answer' }, finish_reason: 'stop' }] }), + json: { choices: [{ message: { content: 'answer', reasoning_content: thought }, finish_reason: 'stop' }] }, + }, + { + name: 'Gemini', + create: (fetchImpl: typeof fetch) => new GeminiProvider({ apiKey: 'test', fetchImpl }), + type: 'text/event-stream', + stream: sse({ + candidates: [ + { + content: { parts: [{ thought: true, text: thought, thoughtSignature: 'secret' }, { text: 'answer' }] }, + finishReason: 'STOP', + }, + ], + }), + json: { + candidates: [ + { content: { parts: [{ thought: true, text: thought }, { text: 'answer' }] }, finishReason: 'STOP' }, + ], + }, + }, + { + name: 'Loopback', + create: (fetchImpl: typeof fetch) => new LoopbackProvider({ model: 'local', fetchImpl }), + type: 'application/x-ndjson', + stream: JSON.stringify({ done: true, message: { thinking: thought, content: 'answer' } }) + '\n', + json: { done: true, message: { thinking: thought, content: 'answer' } }, + }, +]; + +describe.each(formats)('$name public reasoning', (format) => { + it.each(['stream', 'json'] as const)( + 'separates %s returned reasoning from answer/history with no thinking request', + async (mode) => { + const bodies: unknown[] = []; + const provider = format.create((async (url, init) => { + if (String(url).endsWith('/tags')) return Response.json({ models: [{ name: 'local' }] }); + bodies.push(JSON.parse(init!.body as string)); + return mode === 'json' + ? Response.json(format.json) + : new Response(format.stream, { headers: { 'Content-Type': format.type } }); + }) as typeof fetch); + if (provider instanceof LoopbackProvider) await provider.preload(); + const reasoning = vi.fn(); + const delta = vi.fn(); + const response = await provider.chatStream!([], [], delta, undefined, reasoning); + expect(reasoning.mock.calls).toEqual([[thought]]); + expect(delta.mock.calls).toEqual([['answer']]); + expect(response.text).toBe('answer'); + expect(JSON.stringify(response)).not.toContain(thought); + expect(JSON.stringify(bodies)).not.toMatch(/think|includeThoughts|reasoning_effort|thinkingConfig/); + await provider.chatStream!([], [], () => {}); + expect(bodies[1]).toEqual(bodies[0]); + }, + ); + it.each(['stream', 'json'] as const)( + 'stops answer emission when reasoning consumer cancels a %s response', + async (mode) => { + const provider = format.create((async (url) => { + if (String(url).endsWith('/tags')) return Response.json({ models: [{ name: 'local' }] }); + return mode === 'json' + ? Response.json(format.json) + : new Response(format.stream, { headers: { 'Content-Type': format.type } }); + }) as typeof fetch); + if (provider instanceof LoopbackProvider) await provider.preload(); + const abort = new AbortController(); + const delta = vi.fn(); + await expect(provider.chatStream!([], [], delta, abort.signal, () => abort.abort())).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(delta).not.toHaveBeenCalled(); + }, + ); + if (format.name !== 'Loopback') + it('extracts reasoning on the existing complete JSON tool-bearing route', async () => { + const provider = format.create((async () => Response.json(format.json)) as typeof fetch); + const reasoning = vi.fn(); + const response = await provider.chatStream!( + [], + [{ name: 'read', description: 'read', inputSchema: {} }], + () => {}, + undefined, + reasoning, + ); + expect(reasoning.mock.calls).toEqual([[thought]]); + expect(JSON.stringify(response)).not.toContain(thought); + }); +}); + +it('forwards SDK thinking once, skips signature/redacted blocks, and detaches listeners', async () => { + const listeners = new Map void>(); + let finish!: (value: Awaited>) => void; + const off = vi.fn(); + const stream = { + on: (event: string, cb: (text: string) => void) => listeners.set(event, cb), + off, + finalMessage: () => + new Promise((resolve) => { + finish = resolve; + }), + } as AnthropicStream; + const create = vi.fn(); + const provider = new AnthropicProvider({ client: { messages: { create, stream: () => stream } } }); + const reasoning = vi.fn(); + const pending = provider.chatStream([], [], () => {}, undefined, reasoning); + listeners.get('thinking')?.(thought); + finish({ + content: [ + { type: 'thinking', thinking: thought, signature: 'secret' }, + { type: 'redacted_thinking', data: 'secret' }, + { type: 'text', text: 'answer' }, + ], + stop_reason: 'end_turn', + }); + expect((await pending).text).toBe('answer'); + listeners.get('thinking')?.('late'); + expect(reasoning.mock.calls).toEqual([[thought]]); + expect(off).toHaveBeenCalledWith('thinking', expect.any(Function)); +}); + +it('extracts Anthropic complete response thinking when SDK streaming is unavailable', async () => { + const create = vi.fn(async (_body: Record) => ({ + content: [ + { type: 'thinking', thinking: thought, signature: 'secret' }, + { type: 'text', text: 'answer' }, + ], + stop_reason: 'end_turn', + })); + const reasoning = vi.fn(); + const provider = new AnthropicProvider({ client: { messages: { create } } }); + const response = await provider.chatStream([], [], () => {}, undefined, reasoning); + expect(reasoning.mock.calls).toEqual([[thought]]); + expect(JSON.stringify(response)).not.toContain(thought); + expect(create.mock.calls[0][0]).not.toHaveProperty('thinking'); +}); + +it('ends reasoning before answers/tools, once per inference, and ignores settled inference callbacks', async () => { + const events: AgentEvent[] = []; + let priorReasoning!: (text: string) => void; + let priorAnswer!: (text: string) => void; + let calls = 0; + const provider: LLMProvider = { + name: 'test', + isReady: () => true, + chat: async () => answer(), + chatStream: async (_messages, _tools, delta, _signal, reasoning) => { + calls++; + if (calls === 1) { + priorReasoning = reasoning!; + priorAnswer = delta; + reasoning!(thought); + return { + ...answer(''), + toolCalls: [{ id: '1', name: 'read', input: {} }], + assistant: { role: 'assistant', content: [{ type: 'tool_use', id: '1', name: 'read', input: {} }] }, + }; + } + priorReasoning('stale'); + priorAnswer('stale'); + reasoning!('second'); + delta('answer'); + reasoning!('after answer'); + return answer(); + }, + }; + const result = await runAgent(provider, 'question', { + tools: { + read: { name: 'read', description: '', inputSchema: {}, readOnlyHint: true, execute: async () => 'source' }, + }, + onEvent: (e) => events.push(e), + }); + expect(events.filter((e) => e.type.startsWith('reasoning'))).toEqual([ + { type: 'reasoning_delta', text: thought }, + { type: 'reasoning_end' }, + { type: 'reasoning_delta', text: 'second' }, + { type: 'reasoning_end' }, + ]); + expect(events.findIndex((e) => e.type === 'reasoning_end')).toBeLessThan( + events.findIndex((e) => e.type === 'tool_call'), + ); + expect(events.findLastIndex((e) => e.type === 'reasoning_end')).toBeLessThan( + events.findIndex((e) => e.type === 'assistant_delta'), + ); + expect(JSON.stringify(result)).not.toContain(thought); + expect(JSON.stringify(result)).not.toMatch(/second|stale/); + expect(JSON.stringify(events)).not.toMatch(/stale|after answer/); +}); + +it.each(['stop', 'dispose', 'supersede'] as const)( + 'controller keeps reasoning ephemeral and rejects callbacks after %s', + async (action) => { + let emit!: (text: string) => void; + let resolve!: (value: LLMResponse) => void; + const reasoning = vi.fn(); + const end = vi.fn(); + const save = vi.fn(); + const turns = vi.fn(); + const provider: LLMProvider = { + name: 'test', + isReady: () => true, + chat: async () => answer(), + chatStream: async (_m, _t, _d, _s, cb) => { + emit = cb!; + return new Promise((r) => { + resolve = r; + }); + }, + }; + let valid = true; + const controller = new AgentChatController(provider, turns, { + onReasoningDelta: reasoning, + onReasoningEnd: end, + assertCurrent: () => { + if (!valid) throw new Error('superseded'); + }, + storage: { load: () => [], save, clear: () => {} }, + }); + const pending = controller.send('question'); + await vi.waitFor(() => expect(emit).toBeTypeOf('function')); + emit(thought); + if (action === 'supersede') valid = false; + else controller[action](); + try { + emit('late'); + } catch {} + resolve(answer()); + await pending; + try { + emit('settled'); + } catch {} + expect(reasoning.mock.calls).toEqual([[thought]]); + expect(JSON.stringify(save.mock.calls)).not.toContain(thought); + expect(JSON.stringify(turns.mock.calls)).not.toContain(thought); + }, +); + +it('controller forwards a successful reasoning end before answer delivery', async () => { + const visible: string[] = []; + const provider: LLMProvider = { + name: 'test', + isReady: () => true, + chat: async () => answer(), + chatStream: async (_m, _t, delta, _s, cb) => { + cb!(thought); + delta('answer'); + return answer(); + }, + }; + await new AgentChatController(provider, () => {}, { + onReasoningDelta: (t) => visible.push(t), + onReasoningEnd: () => visible.push('end'), + onAgentDelta: (t) => visible.push(t), + }).send('question'); + expect(visible).toEqual([thought, 'end', 'answer']); +}); + +it('document answer forwards reasoning separately and ignores callbacks after settlement', async () => { + let emit!: (text: string) => void; + const provider: LLMProvider = { + name: 'test', + isReady: () => true, + chat: async () => answer(), + chatStream: async (_m, _t, delta, _s, cb) => { + emit = cb!; + cb!(thought); + delta('answer'); + return answer(); + }, + }; + const reasoning = vi.fn(); + const delta = vi.fn(); + const result = await generateReadAnswer( + provider, + 'question', + {} as never, + [], + new AbortController().signal, + delta, + undefined, + { onReasoningDelta: reasoning }, + ); + emit('late'); + expect(result).toBe('answer'); + expect(reasoning.mock.calls).toEqual([[thought]]); + expect(delta.mock.calls).toEqual([['answer']]); +}); + +it('ends a reasoning-only failed inference before rejecting and ignores later callbacks', async () => { + const events: AgentEvent[] = []; + let emit!: (text: string) => void; + const provider: LLMProvider = { + name: 'test', + isReady: () => true, + chat: async () => answer(), + chatStream: async (_m, _t, _d, _s, cb) => { + emit = cb!; + cb!(thought); + throw new Error('failed'); + }, + }; + await expect(runAgent(provider, 'question', { onEvent: (e) => events.push(e) })).rejects.toThrow('failed'); + emit('late'); + expect(events).toEqual([{ type: 'reasoning_delta', text: thought }, { type: 'reasoning_end' }]); +}); + +it('does not invent reasoning or an end event for answer-only providers', async () => { + const events: AgentEvent[] = []; + await runAgent({ name: 'test', isReady: () => true, chat: async () => answer() }, 'question', { + onEvent: (e) => events.push(e), + }); + expect(events.some((e) => e.type.startsWith('reasoning'))).toBe(false); +}); + +it.each(['fallback', 'tools'] as const)( + 'stops Gemini reasoning callbacks between thought parts on the JSON %s route', + async (route) => { + const provider = new GeminiProvider({ + apiKey: 'test', + fetchImpl: async () => + Response.json({ + candidates: [ + { + content: { + parts: [{ thought: true, text: thought }, { thought: true, text: 'late thought' }, { text: 'answer' }], + }, + finishReason: 'STOP', + }, + ], + }), + }); + const abort = new AbortController(); + const reasoning = vi.fn(() => abort.abort()); + const delta = vi.fn(); + await expect( + provider.chatStream( + [], + route === 'tools' ? [{ name: 'read', description: 'read', inputSchema: {} }] : [], + delta, + abort.signal, + reasoning, + ), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(reasoning.mock.calls).toEqual([[thought]]); + expect(delta).not.toHaveBeenCalled(); + }, +); diff --git a/test/unit/agent-sources-view.test.ts b/test/unit/agent-sources-view.test.ts index 6618bc9bf..dca5ad169 100644 --- a/test/unit/agent-sources-view.test.ts +++ b/test/unit/agent-sources-view.test.ts @@ -20,6 +20,25 @@ afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals(); }); +it('opens an explicitly chosen composer source even when the source list is filtered', () => { + const sources = new ReferenceSources(); + sources.add({ id: 'one', connector: 'local', title: 'Notes', text: 'Visible only on request' }); + const view = createSourcesView({ sources, labels, changed: vi.fn(), close: vi.fn() }); + document.body.append(view.el); + const search = view.el.querySelector('[type=search]')!; + search.value = 'no match'; + search.dispatchEvent(new Event('input')); + expect(view.el.querySelector('.agent-source-row')).toBeNull(); + expect(view.openSource('missing')).toBe(false); + expect(search.value).toBe('no match'); + expect(view.openSource('one')).toBe(true); + const detail = view.el.querySelector('details')!; + expect(detail.open).toBe(true); + expect(document.activeElement).toBe(detail.querySelector('summary')); + expect(detail.querySelector('pre')?.textContent).toBe('Visible only on request'); + view.dispose(); + expect(view.openSource('one')).toBe(false); +}); it('offers local references without remote account or server controls', () => { const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher); @@ -27,6 +46,7 @@ it('offers local references without remote account or server controls', () => { const view = createSourcesView({ sources: new ReferenceSources(), labels, changed: vi.fn(), close: vi.fn() }); document.body.append(view.el); expect(view.el.querySelector('input[type=file]')).toBeTruthy(); + expect(view.el.querySelector('.agent-view-header button')).toBeNull(); expect(view.el.querySelectorAll('input[type=url], input[type=password]')).toHaveLength(0); expect( [...view.el.querySelectorAll('button')].some((button) => @@ -235,3 +255,27 @@ it('retains local attachments across offline and online transitions without a ne view.dispose(); network.mockRestore(); }); + +it('keeps removal focus in the filtered source list and falls back to Attach when empty', () => { + const sources = new ReferenceSources(); + for (const id of ['hidden', 'one', 'two', 'three']) + sources.add({ id, connector: 'local', title: id === 'hidden' ? 'Other' : `Notes ${id}`, text: id }); + const view = createSourcesView({ sources, labels, changed: vi.fn(), close: vi.fn() }); + document.body.append(view.el); + const search = view.el.querySelector('[type=search]')!; + search.value = 'Notes'; + search.dispatchEvent(new Event('input')); + const remove = (name: string) => { + const control = view.el.querySelector(`[aria-label="Remove: Notes ${name}"]`)!; + control.focus(); + control.click(); + }; + remove('two'); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Remove: Notes three'); + remove('three'); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Remove: Notes one'); + remove('one'); + expect(document.activeElement).toBe(view.el.querySelector('.agent-source-attach')); + expect(sources.list().map((source) => source.id)).toEqual(['hidden']); + view.dispose(); +}); diff --git a/test/unit/agent-view-header.test.ts b/test/unit/agent-view-header.test.ts index 9196a8804..412bbd081 100644 --- a/test/unit/agent-view-header.test.ts +++ b/test/unit/agent-view-header.test.ts @@ -12,10 +12,12 @@ it('composes the owned heading and action without replacing their identity or ca expect(header.querySelector('button')).toBe(back); expect(back.getAttribute('aria-label')).toBe('Back to conversation'); expect(back.title).toBe('Back to conversation'); + expect(back.querySelector('span')?.textContent).toBe('Back to conversation'); expect(back.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); labelViewBack(back, '返回成果'); expect(back.querySelectorAll('svg')).toHaveLength(1); expect(back.getAttribute('aria-label')).toBe('返回成果'); + expect(back.querySelector('span')?.textContent).toBe('返回成果'); back.click(); expect(close).toHaveBeenCalledTimes(1); }); diff --git a/test/unit/chat-view-composer-craft.test.ts b/test/unit/chat-view-composer-craft.test.ts new file mode 100644 index 000000000..715073488 --- /dev/null +++ b/test/unit/chat-view-composer-craft.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ChatView } from '../../packages/chat-ui/src/chat-view'; + +let view: ChatView; +beforeEach(() => { + document.body.replaceChildren(); + HTMLElement.prototype.scrollTo = vi.fn(); +}); +afterEach(() => view.dispose()); + +it('keeps a named composer boundary and stable action row across draft, IME, queue and Stop states', () => { + const send = vi.fn(); + const queue = vi.fn(); + const stop = vi.fn(); + view = new ChatView({ onSend: send, onQueue: queue, onStop: stop }); + document.body.append(view.el); + const composer = view.el.querySelector('[role="group"][aria-label="Message composer"]'); + expect(composer).not.toBeNull(); + const toolbar = composer!.querySelector('[role="group"][aria-label="Message actions"]')!; + const input = composer!.querySelector('textarea')!; + const sendButton = toolbar.querySelector('.cui-send')!; + const stopButton = toolbar.querySelector('.cui-stop')!; + const sources = document.createElement('button'); + sources.textContent = 'Sources'; + view.actionsEl.append(sources); + expect([...toolbar.children]).toEqual(expect.arrayContaining([view.actionsEl, stopButton, sendButton])); + expect(view.pendingEl.closest('[role="group"]')).toBeNull(); + expect(input.getAttribute('wrap')).toBe('soft'); + expect(input.getAttribute('enterkeyhint')).toBe('send'); + view.setRunning(true); + view.setInput('下一条问题'); + input.focus(); + input.dispatchEvent(new CompositionEvent('compositionstart')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(queue).not.toHaveBeenCalled(); + expect(view.getInput()).toBe('下一条问题'); + input.dispatchEvent(new CompositionEvent('compositionend')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })); + expect(queue).not.toHaveBeenCalled(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(queue).toHaveBeenCalledWith('下一条问题'); + expect(document.activeElement).toBe(input); + expect(send).not.toHaveBeenCalled(); + stopButton.click(); + expect(stop).toHaveBeenCalledOnce(); + expect(stopButton.disabled).toBe(true); + expect(composer!.querySelector('.cui-composer-bar')).toBe(toolbar); + expect(toolbar.contains(sources)).toBe(true); + expect(toolbar.querySelector('.cui-send')).toBe(sendButton); + view.setLabels({ composer: 'Compor mensagem', actions: 'Ações da mensagem' }); + expect(composer!.getAttribute('aria-label')).toBe('Compor mensagem'); + expect(toolbar.getAttribute('aria-label')).toBe('Ações da mensagem'); +}); diff --git a/test/unit/chat-view-reasoning.test.ts b/test/unit/chat-view-reasoning.test.ts new file mode 100644 index 000000000..923877796 --- /dev/null +++ b/test/unit/chat-view-reasoning.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { Reasoning } from 'ranui/reasoning'; +import type { DisclosureRow } from 'ranui/disclosure-row'; +import { ChatView } from '../../packages/chat-ui/src/chat-view'; + +let view: ChatView; +let frames: Map; +let nextFrame: number; +const flush = () => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(16); +}; +const rows = () => [...view.el.querySelectorAll('r-reasoning')]; +// ranui documents _shadowDom as its closed-root testing seam (ranui/testing). +// Production code uses only public Reasoning properties and composed events. +const toggle = (reasoning: Reasoning) => + reasoning._shadowDom + .querySelector('r-disclosure-row')! + ._shadowDom.querySelector('[role="button"]')!; + +beforeEach(() => { + frames = new Map(); + nextFrame = 0; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + HTMLElement.prototype.scrollTo = vi.fn(); + document.body.replaceChildren(); + view = new ChatView({ onSend: vi.fn(), streamingMarkdown: true }); + document.body.append(view.el); +}); +afterEach(() => { + view.dispose(); + vi.unstubAllGlobals(); +}); + +it('creates native reasoning only for real running output and batches content without model timing', () => { + view.appendReasoningDelta('ignored while idle'); + view.setRunning(true); + view.appendReasoningDelta(''); + expect(rows()).toHaveLength(0); + for (let index = 0; index < 1000; index++) view.appendReasoningDelta('word '); + expect(rows()).toHaveLength(1); + const reasoning = rows()[0]; + expect(reasoning.streaming).toBe(true); + expect(reasoning.open).toBe(true); + expect(reasoning.content).toBe(''); + expect(frames.size).toBe(1); + const write = vi.spyOn(reasoning, 'content', 'set'); + flush(); + expect(write).toHaveBeenCalledOnce(); + expect(reasoning.content).toBe('word '.repeat(1000)); + expect(reasoning.duration).toBeNull(); + expect(view.el.querySelector('.cui-progress')!.hidden).toBe(true); + view.appendDelta('Actual answer'); + expect(reasoning.streaming).toBe(false); + expect(reasoning.open).toBe(false); + view.endStream(); + expect(view.getLastAnswer()).toBe('Actual answer'); +}); + +it('preserves reader-owned native disclosure state across updates, completion and relabeling', () => { + view.setRunning(true); + view.appendReasoningDelta('Public reasoning'); + const reasoning = rows()[0]; + toggle(reasoning).click(); + expect(reasoning.open).toBe(false); + view.appendReasoningDelta(' keeps coming'); + flush(); + expect(reasoning.open).toBe(false); + view.endReasoning(); + toggle(reasoning).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(reasoning.open).toBe(true); + view.setLabels({ reasoning: 'Reasoning translated' }); + view.endReasoning(); + expect(reasoning.open).toBe(true); + expect(reasoning.label).toBe('Reasoning translated'); +}); + +it('shares a 64 KiB UTF-8 budget across phases, keeps emoji whole and emits one localized notice', () => { + view.setLabels({ reasoningTruncated: 'Output shortened' }); + view.setRunning(true); + view.appendReasoningDelta('a'.repeat(65530)); + view.endReasoning(); + view.append({ role: 'tool', text: 'Tool receipt' }); + view.appendReasoningDelta('😀😀after'); + flush(); + expect(rows().map((row) => row.content)).toEqual(['a'.repeat(65530), '😀']); + expect( + new TextEncoder().encode( + rows() + .map((row) => row.content) + .join(''), + ).byteLength, + ).toBe(65534); + expect(view.el.querySelectorAll('.cui-reasoning-notice')).toHaveLength(1); + expect(view.el.querySelector('.cui-reasoning-notice')!.textContent).toBe('Output shortened'); + view.endReasoning(); + view.appendReasoningDelta('late'); + view.setRunning(true); + view.appendReasoningDelta('still the same run'); + expect(rows()).toHaveLength(2); + view.setLabels({ reasoningTruncated: 'Notice translated' }); + expect(view.el.querySelector('.cui-reasoning-notice')!.textContent).toBe('Notice translated'); + view.setRunning(false); + view.setRunning(true); + view.appendReasoningDelta('New run'); + flush(); + expect(rows()).toHaveLength(3); + expect(rows()[2].content).toBe('New run'); +}); + +it.each(['answer', 'tool', 'review', 'phase', 'end'] as const)('ends an active phase when %s arrives', (event) => { + view.setRunning(true); + view.appendReasoningDelta('A public phase'); + const reasoning = rows()[0]; + if (event === 'answer') view.appendDelta('Answer'); + else if (event === 'tool') view.append({ role: 'tool', text: 'Reading file' }); + else if (event === 'review') view.appendContent(document.createElement('section')); + else if (event === 'phase') view.setActivity('reading'); + else view.endStream(); + expect(reasoning.streaming).toBe(false); + expect(reasoning.content).toBe('A public phase'); + expect(reasoning.label).toBe('Reasoning'); +}); + +it.each(['stop', 'error', 'settle'] as const)('freezes interruption on %s and ignores late callbacks', (event) => { + view.setRunning(true); + view.appendReasoningDelta('Retained public output'); + const reasoning = rows()[0]; + if (event === 'stop') view.requestStop(); + else if (event === 'error') view.append({ role: 'error', text: 'Service unavailable' }); + else view.setRunning(false); + expect(reasoning.streaming).toBe(false); + expect(reasoning.content).toBe('Retained public output'); + expect(reasoning.label).toBe('Reasoning interrupted'); + view.appendReasoningDelta('Late output'); + view.endReasoning(); + expect(reasoning.label).toBe('Reasoning interrupted'); + expect(reasoning.content).toBe('Retained public output'); +}); + +it.each(['clear', 'restore', 'dispose'] as const)( + 'discards ephemeral output and rejects late work after %s', + (event) => { + view.setRunning(true); + view.appendReasoningDelta('Never persist this'); + const reasoning = rows()[0]; + if (event === 'clear') view.clear(); + else if (event === 'restore') view.restoreHistory([{ role: 'agent', text: 'Stored answer' }]); + else view.dispose(); + view.appendReasoningDelta('Late output'); + view.endReasoning(); + flush(); + expect(rows()).toHaveLength(0); + expect(reasoning.content).toBe(''); + reasoning.dispatchEvent(new CustomEvent('disclosuretoggle')); + expect(frames.size).toBe(0); + }, +); + +it('keeps reasoning out of answer/source/copy/save/apply and message hooks', async () => { + view.dispose(); + const copy = vi.fn().mockResolvedValue(undefined); + const save = vi.fn(); + const apply = vi.fn().mockResolvedValue('verified'); + const present = vi.fn(); + const actions = vi.fn().mockReturnValue([]); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copy } }); + view = new ChatView({ + onSend: vi.fn(), + onSaveMessage: save, + onApplyMessage: apply, + presentMessage: present, + getMessageActions: actions, + }); + document.body.append(view.el); + view.setRunning(true); + view.appendReasoningDelta('Ephemeral public reasoning'); + view.appendDelta('**Answer source**', { documentArtifact: true }); + view.endStream(); + view.setRunning(false); + expect(view.getLastAnswer()).toBe('**Answer source**'); + expect(view.getLastDocumentBody()).toBe('**Answer source**'); + expect(view.el.querySelectorAll('[data-source]')).toHaveLength(1); + view.el.querySelector('.cui-copy')!.click(); + view.el.querySelector('.cui-save')!.click(); + view.el.querySelector('.cui-apply')!.click(); + await Promise.resolve(); + expect(copy).toHaveBeenCalledWith('**Answer source**'); + expect(save).toHaveBeenCalledWith('**Answer source**', undefined); + expect(apply).toHaveBeenCalledWith('**Answer source**'); + expect(present).toHaveBeenCalled(); + for (const [bubble] of present.mock.calls) expect(bubble.closest('r-reasoning')).toBeNull(); + expect(actions).toHaveBeenCalledOnce(); +}); + +it.each([true, false])('reasoning expansion respects current scroll ownership: following=%s', (following) => { + const messages = view.el.querySelector('.cui-messages')!; + view.append({ role: 'agent', text: 'Earlier answer' }); + Object.defineProperties(messages, { + scrollHeight: { configurable: true, value: 1000 }, + clientHeight: { configurable: true, value: 200 }, + }); + messages.scrollTop = following ? 800 : 100; + messages.dispatchEvent(new Event('scroll')); + view.setRunning(true); + view.appendReasoningDelta('Public output'); + flush(); + view.endReasoning(); + flush(); + vi.mocked(messages.scrollTo).mockClear(); + toggle(rows()[0]).click(); + flush(); + if (following) expect(messages.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' }); + else { + expect(messages.scrollTo).not.toHaveBeenCalled(); + expect(messages.scrollTop).toBe(100); + } +}); + +it('buffers surrogate pairs split between deltas before enforcing the UTF-8 cap', () => { + view.setRunning(true); + view.appendReasoningDelta('a'.repeat(65533) + '\uD83D'); + flush(); + expect(rows()[0].content).toBe('a'.repeat(65533)); + view.appendReasoningDelta('\uDE00'); + flush(); + expect(rows()[0].content).toBe('a'.repeat(65533)); + expect(view.el.querySelectorAll('.cui-reasoning-notice')).toHaveLength(1); + expect(new TextEncoder().encode(rows()[0].content).byteLength).toBe(65533); +}); + +it('buffers a first high surrogate without an empty row and completes the emoji on the next delta', () => { + view.setRunning(true); + view.appendReasoningDelta('\uD83D'); + flush(); + expect(rows()).toHaveLength(0); + view.appendReasoningDelta('\uDE00'); + flush(); + expect(rows()[0].content).toBe('😀'); +}); + +it.each([false, true])('normalizes an orphaned high surrogate at phase end; interrupted=%s', (interrupted) => { + view.setRunning(true); + view.appendReasoningDelta('\uD83D'); + if (interrupted) view.requestStop(); + else view.endReasoning(); + expect(rows()[0].content).toBe('\uFFFD'); + expect(rows()[0].streaming).toBe(false); + expect(rows()[0].label).toBe(interrupted ? 'Reasoning interrupted' : 'Reasoning'); +}); + +it('normalizes orphaned low surrogates and does not join a pair across inference phases', () => { + view.setRunning(true); + view.appendReasoningDelta('\uD83D'); + view.endReasoning(); + view.appendReasoningDelta('\uDE00'); + view.endReasoning(); + expect(rows().map((row) => row.content)).toEqual(['\uFFFD', '\uFFFD']); +});