diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d39840..e48aa9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ notes. Entries describe what users experience, not internal refactors. - **Keep writing while the assistant responds.** Prepare the next request without sending it automatically. Reading older messages keeps your place, and activity hints distinguish reading, responding and waiting for your review. + The draft adapts when you resize the sidebar, and long drafts scroll inside the + input. Copy feedback stays compact; code and tables can be navigated by keyboard. - **Ask the assistant about the current PDF page and review notes before adding them.** It can read selectable page text and add page notes that you can undo. Scanned diff --git a/docs/assistant-format-regression.md b/docs/assistant-format-regression.md index b7fd5356..9f645389 100644 --- a/docs/assistant-format-regression.md +++ b/docs/assistant-format-regression.md @@ -158,3 +158,305 @@ Final full unit regression: 166 files, 4,949 tests passed. Formatting, strict li These interaction checks use controlled provider responses. They do not establish seven-language model quality or actual screen-reader acceptance. + +## Sidebar interaction details + +The draft reflows when the sidebar width changes; long drafts scroll within the +existing input. Layout changes keep the latest answer visible only when the reader +was following the end. Reading history does not resume following implicitly. +Copy uses fixed-size success/failure icons, a separate polite announcement and an +available retry action. Feedback does not replace running activity. Message block +edges, nested lists and table text use the existing theme and type scale. Code +and overflowing tables are keyboard-reachable and retain their own scroll areas. + +The first browser run found a real viewport-ordering defect: a scroll event from +composer growth was mistaken for intentional scrolling. A new regression models +that event arriving before the resize notification. A later run rejected resize +observer delivery errors through the existing L0 check. Layout writes now run in +the next animation frame and only changed widths trigger input reflow, following +the [browser guidance on resize observation errors](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors). +The L0 check and its error allowlist were not changed. A separate test-locator +failure was corrected to select the runtime sidebar rather than the hidden +onboarding panel; no product assertion was removed. + +Final current-code verification: 167 unit files and 4,956 tests passed. The +production-build browser run passed all 24 cases, including sidebar resizing, +long drafts, history position, clipboard feedback and keyboard scrolling in +DOCX/XLSX/PPTX/PDF, eight imported formats, native writes, Undo and scoped PDF +behavior. These interaction cases use controlled provider responses; actual +screen-reader acceptance and repeated multilingual model qualification remain open. + +## Exploratory repeated model reading + +A separate on-device Qwen3 4B session used a synthetic DOCX containing exactly +`Project budget: 120.Due date: 2026-11-01.` plus the native final newline. The +question asked for the budget alone, with expected visible answer `120`. The UI +language stayed English; question languages varied. Each request started a new +conversation. Native reads before and after the session confirmed the source +was unchanged. + +| Question language | Trials | Exact expected answers | Observed failures | +| ----------------- | -----: | ---------------------: | ---------------------------------------------------------------------------- | +| English | 3 | 3 | None in these trials | +| Chinese | 2 | 2 | None in these trials | +| Japanese | 1 | 1 | None in this trial | +| Korean | 1 | 1 | None in this trial | +| German | 1 | 1 | None in this trial | +| Spanish | 1 | 1 | None in this trial | +| Portuguese | 3 | 0 | One operation-selection refusal; two answers included the unrelated due date | + +These are exploratory observations, not a balanced benchmark or final-build +qualification. Repeating the question after a temporary prompt-order experiment +still produced a selection refusal and an answer with the extra date. Delivery +of that experimental prompt was not independently attested in the live session, +so this is not a controlled A/B result. The experiment was removed instead of +being promoted as a fix. The local model's multilingual task selection and answer +formatting remain unqualified; neither a green CI run nor passing editor adapter +tests establish those capabilities. + +### Equal-count follow-up on 2026-10-11 + +A fresh workspace development session loaded the cached +`Qwen3-4B-q4f16_1-MLC` model and used the same synthetic DOCX text above. The UI +remained English. Each request started a new conversation; the seven questions +were repeated in the same order for three rounds. There was no prompt experiment, +provider substitution or answer postprocessing. The current copy-feedback DOM +marker was present. Native reads before and after all 21 requests returned the +exact same source text and final newline. + +| Question language | Trials | Exact `120` answers | Failures | +| ----------------- | -----: | ------------------: | ------------------------------------------------ | +| English | 3 | 3 | None | +| Chinese | 3 | 3 | None | +| Japanese | 3 | 3 | None | +| Korean | 3 | 3 | None | +| German | 3 | 3 | None | +| Spanish | 3 | 1 | Rounds 1 and 2: no executable operation selected | +| Portuguese | 3 | 3 | None | + +Questions, unchanged between rounds: + +- English: What is the budget in the current document? Answer with the amount only. +- Chinese: 当前文档里的预算是多少?请只回答金额。 +- Japanese: 現在の文書の予算はいくらですか?金額だけを答えてください。 +- Korean: 현재 문서의 예산은 얼마인가요? 금액만 답해주세요. +- German: Wie hoch ist das Budget im aktuellen Dokument? Antworte nur mit dem Betrag. +- Spanish: ¿Cuál es el presupuesto del documento actual? Responde solo con el importe. +- Portuguese: Qual é o orçamento no documento atual? Responda apenas com o valor. + +The visible Spanish error was "No executable operation was selected. Clarify the +request or try another model." Raw provider selection output was not captured, +so this does not identify a malformed selection, unsupported operation or model +reasoning error as the specific cause. The condition that only Portuguese fails +is contradicted by this session; language support remains unchanged. Earlier +Portuguese failures remain recorded rather than replaced by these successes. + +This is an equal-count reading probe, not full language acceptance. It covers one +short source, one question per language, an English UI, three samples and a +development server. It does not qualify production delivery, matching-language +UIs, writing fidelity, complex operations, other formats or long contexts. + +## Reflow and content-arrival interleaving + +Layout tests reproduced a loss of bottom following when a streamed delta, a +finished message, Markdown completion or a review arrived after viewport reflow +but before its scheduled layout frame. Content arrivals were overwriting reader +attachment using the temporarily changed geometry. They now consume the existing +following state; scroll handling updates attachment, and jumping to the end or +clearing the conversation resets it. + +Four regression cases failed before the repair and pass afterwards. A fifth case +checks that scrolling into history before the pending frame still prevents the +next delta from pulling the reader back. The full unit suite passed 4,961 tests; +two asynchronous Promise warnings were emitted during that run. The production +composer UX suite passed all four DOCX, XLSX, PPTX and PDF cases, covering draft +reflow, bottom following, historical position, focus and keyboard interaction. +Those browser cases use controlled provider responses and do not qualify actual +model accuracy or independently reproduce the exact frame interleaving. + +### Tool-purpose clarification follow-up on 2026-10-11 + +Temporary diagnostics on the same synthetic source captured two fresh Spanish +requests. Both selected the `tools` task and Spanish response language, then +returned the valid `unsupported` operation. This locates those failures in +operation choice rather than malformed JSON or initial task routing. Restricting +the available operations to selection and document reading still returned +`unsupported` in one trial; that experiment was removed. + +A separate description-only experiment retained the eight available Word tools +and clarified that `get_document_text` supplies source text for answering factual +questions, including when nothing is selected. Three fresh requests selected +`get_document_text` with `maxChars: 8000` and answered exactly `120`. Temporary +request matching and diagnostic logging were removed. The retained change is a +generic tool description; schemas, permissions, execution and refusal handling +are unchanged. This follows the tool-description guidance in +[OpenAI function calling](https://developers.openai.com/api/docs/guides/function-calling) +and [Anthropic tool design](https://www.anthropic.com/engineering/writing-tools-for-agents). +Those guides do not establish this local model's reliability. + +The final development code was then tested in a fresh browser session with the +same cached model, English UI and source. Every request used a new conversation. +Native reads before and after returned the unchanged source and final newline. + +| Spanish request | Trials | Visible results | Strict requested-format passes | +| ------------------------------------------------------------------------------------- | -----: | -------------------------------------------------------------- | -----------------------------: | +| Original budget question above | 3 | `120` each time | 3 | +| Indica únicamente la cantidad presupuestada que figura en este documento. | 1 | La cantidad presupuestada que figura en este documento es 120. | 0 | +| ¿Qué fecha de entrega aparece en este documento? Responde solo en formato AAAA-MM-DD. | 1 | `2026-11-01` | 1 | + +All five final-code requests produced the correct source fact without an +operation-selection error. The paraphrase failed the requested answer format and +remains a failure. Final-code trials captured visible results, not raw provider +plans. These small, fixed-order experiments on one source are exploratory; they +are not randomized comparisons, production qualification or full multilingual +acceptance. Language support remains unchanged. + +For the generic description change, the full unit suite passed 4,961 tests +(with the same two asynchronous Promise warnings), strict lint and TypeScript +checks passed, and all ten production browser tests in the native editor write +suite passed. The write suite uses controlled providers and protects execution +behavior; it does not measure local model accuracy. + +## Current-code cross-format acceptance on 2026-10-11 + +At application commit `08aa6131`, a production-build run passed all 14 cases in +`agent-composer-ux`, `agent-imported-formats` and `pdf-agent-contract` with one +worker. The explicit public legacy corpus was supplied; all three file hashes +matched the pinned fixture definitions and no legacy cases were skipped. + +- DOCX, XLSX, PPTX and PDF: editable next drafts, sidebar reflow, bottom following, + historical focus, stable copy feedback and keyboard scrolling of code/tables. +- ODT, ODS, ODP, TXT, RTF, DOC and XLS: imported context reaches the service, + reviewed native modification is independently read back and one Undo restores + the original content. +- PPT: a crowded insertion fails and rolls back, then reviewed selected-title + replacement succeeds and one Undo restores the original. +- PDF: virtual text reads preserve the real selection; scoped reading and reviewed + page notes exercise cancellation, rollback, Undo, and saving/reopening; a + body-rewrite request produces an answer without offering a document write. + +These browser tests use controlled providers. They establish the tested editor +and interaction contracts, not local model quality or universal document-layout +compatibility. CSV native writes passed in the preceding ten-case write suite; +CSV is not included in this 14-case run. + +### Remaining interaction acceptance + +Source review still finds a two-step speech-language interaction: a button opens +a wrapper containing an `r-select`, then a second interaction opens its options. +The service, download and advanced-settings disclosures also have different +structures. These remain redesign work, rather than completed UX acceptance. + +A temporary layout unit probe additionally reproduced a boundary absent from the +passing composer browser cases: upward wheel input, viewport-height change and a +scroll event in the same interval retain bottom attachment. The next streamed +update and pending layout frame both call scroll-to-end. Eleven existing layout +cases passed while that new probe failed. The probe was removed after diagnosis; +production code is unchanged. A real-browser reproduction and a repair covering +wheel, touch, keyboard and scrollbar navigation are still required. This is not +claimed as a browser-proven production regression. + +Actual screen-reader acceptance, the proposed layout responsibility split, +unified settings/language interaction and broader multilingual model acceptance +remain open. Passing the above cases does not close these requirements. + +### Actual local-model Excel probe on 2026-10-11 + +At application commit `4b722e27`, a fresh Chinese-UI development session loaded +cached `Qwen3-4B-q4f16_1-MLC` into an independently created empty XLSX. No provider +substitution, prompt experiment or response postprocessing was used. + +1. `在当前工作表的 A1:A100 填入从 1 到 100 的连续整数。` produced a reviewed + A1:A100 proposal. CSV export before confirmation remained the original empty + file. After confirmation, export contained exactly 100 rows with every value + equal to its one-based row number, and their sum was 5050. +2. `当前工作表 A100 中的数字是多少?请只回答数字。` answered exactly `100`. +3. In a fresh conversation, `当前工作表 A1:A100 的合计是多少?请只回答数字,不修改表格。` + answered exactly `5050`. Export remained byte-identical to the filled CSV. +4. One native Undo restored the original empty CSV. In a fresh conversation, + `你好` answered `你好!有什么可以帮助你的吗?` without the reported spreadsheet + operation refusal. +5. After native Redo, the visible name box was used to select only A1. The same + sum question in another fresh conversation still answered exactly `5050`. + Export was unchanged; a final native Undo restored the empty CSV again. + +This records five actual-model requests, one confirmed write, independent CSV +readback and native Undo/Redo. Initial sum reading had A1:A100 selected; the final +sum reading had only A1 selected. Raw provider plans were not captured, so these +results do not attest which read operation the model selected. This small +Chinese-language development probe does not qualify all spreadsheet tasks, +other languages, production delivery, formula generation or other document +formats. The temporary browser tab was closed after restoring the test file. + +### Actual local-model presentation probe on 2026-10-11 + +The same application commit and cached 4B model were used in a fresh Chinese-UI +PPTX session starting with one empty slide. Native PPTX exports were inspected +independently of the model response: slide XML parts were extracted from the ZIP +central directory, decompressed and parsed for DrawingML text. + +- `在演示文稿末尾新增一页幻灯片,标题为“项目预算”,正文为“预算:120”。` + produced only a new-slide proposal. Before confirmation, exported slide parts + matched the baseline. Confirming added a second slide and preserved slide 1, + but slide 2 contained no text: both requested title and body were missing. + **The compound request failed acceptance.** A verified primitive operation and + the UI completion receipt do not establish completion of the user's request. +- In a new conversation, `请在当前幻灯片中添加文字“预算:120”。` produced a + correct text-addition proposal. After confirmation, exported slide 2 contained + exactly one `预算:120` text value and three shapes. +- In another new conversation, `当前幻灯片里的预算是多少?请只回答金额。` + answered exactly `120`; exported slide parts were unchanged by reading. +- One native Undo restored the pre-text slide parts. A second native Undo removed + the added slide and restored all original slide XML parts. The temporary page + was closed after restoring the baseline. + +Code inspection explains the capacity boundary: `slide_action` accepts only an +add/duplicate/navigate action and optional page; it cannot supply title/body. +The schema planner explicitly chooses one operation and is instructed to refuse +multiple-operation requests, yet this model selected a partial operation. The +sequence wrapper provides a closed spreadsheet phrase sequence rather than a +general multi-operation path. These are separate model-choice and orchestration +limitations; neither language deletion nor a sentence-specific match repairs the +requested presentation workflow. + +The provider-neutral runtime already has bounded tool-call/result round trips. +Any general repair must retain scope checks, reviewed writes, cancellation, +readback and Undo while distinguishing primitive success from whole-task +completion. The documented +[function-call loop](https://developers.openai.com/api/docs/guides/function-calling) +and [composable agent workflows](https://www.anthropic.com/engineering/building-effective-agents) +provide reference patterns; they do not prove that a 4B model can plan arbitrary +multi-step edits. No multi-operation repair is claimed in this probe. + +### Actual local-model PDF probe on 2026-10-11 + +At application commit `4b722e27`, the cached 4B model was used in a Chinese-UI PDF +session. The fixture was generated locally from the synthetic Word source +`Project budget: 120.Due date: 2026-11-01.`; the native editor confirmed that +source before conversion. No private document or provider substitution was used. + +- `当前 PDF 第 1 页的预算是多少?请只回答金额。` answered exactly `120`. +- In a fresh conversation, `在第 1 页添加批注:请核对预算。` proposed the + correct note and page and waited for review. Export before confirmation had no + text annotations. After confirmation, the exported PDF contained one `/Text` + annotation; its decoded UTF-16 contents were exactly `请核对预算。`, and its + page reference matched the single page in the PDF page tree. The page content + stream remained identical to the pre-confirmation export. +- One native Undo removed the annotation. The exported page dictionary and body + content stream matched the pre-confirmation export. Whole-file bytes differed, + so byte-identical PDF restoration is not claimed. +- In another fresh conversation, `把当前 PDF 第 1 页的预算改为 200。` produced + a pending **add-comment** proposal containing the entire request. The + application does not provide PDF body replacement, and a note is not the + requested modification. **This capability-boundary request failed acceptance.** + The proposal was cancelled. Export had no text annotations and its body + content stream remained unchanged. The temporary tab was then closed. + +This records three actual-model requests, one reviewed annotation write, +independent inspection of the exported PDF and native Undo. It is one selectable +page, not a test of scanned PDFs, multiple pages, all languages or arbitrary PDF +editing. The raw provider plans were not captured. The wrong pending proposal +shows that schema-valid operation selection is not sufficient evidence that the +chosen operation satisfies the requested task. Review prevented execution in +this trial; it does not resolve the routing limitation or justify accepting a +different operation in place of an unavailable capability. diff --git a/lib/agent-plugin/tools.ts b/lib/agent-plugin/tools.ts index d864bd94..ebc7e313 100644 --- a/lib/agent-plugin/tools.ts +++ b/lib/agent-plugin/tools.ts @@ -166,7 +166,11 @@ const DEFAULT_MAX_CHARS = 8000; export const getDocumentTextTool: AgentTool = { name: 'get_document_text', description: - 'Word only. Read current main-body text without moving the caret or selection. Long text is truncated; unsupported structures are reported as unavailable.', + 'Read source text from the current Word document so the application can answer factual questions about it. ' + + 'Use this when a question refers to the open document, including when no text is selected. ' + + 'This tool returns text, not a final answer; the application answers from the result. ' + + 'Reading does not modify the document or move the caret. Main body only; long text is truncated ' + + 'and unsupported structures are reported as unavailable.', inputSchema: { type: 'object', properties: { diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 0432f95f..fa576924 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -591,6 +591,9 @@ export function createAgentPanel( labels: chatLabels(), }); const readiness = document.createElement('p'); + window.addEventListener('pagehide', (event) => { + if (!event.persisted) chat.dispose(); + }); readiness.className = 'agent-readiness'; readiness.setAttribute('role', 'status'); chat.el.append(readiness); diff --git a/packages/chat-ui/CLAUDE.md b/packages/chat-ui/CLAUDE.md index b272dca5..95659ce2 100644 --- a/packages/chat-ui/CLAUDE.md +++ b/packages/chat-ui/CLAUDE.md @@ -51,6 +51,7 @@ container.appendChild(chat.el); // mount the root element | `clear()` | Remove all messages, restore empty state. | | `getInput()` / `setInput(text)` / `focus()` | Input helpers (e.g. prepend a quote). | | `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. @@ -78,6 +79,9 @@ onSend: async (text) => { - Streaming: call `appendDelta` repeatedly, then exactly one `endStream`. Don't `append` an agent message during a stream. - The send button is an **icon** (up-arrow ⇄ stop square); `labels.send`/`labels.stop` become its `title`/`aria-label`. It auto-disables when the input is empty (and idle). - Modern look: assistant messages are bubble-less/full-width, the user gets an accent bubble, tool/error render as subtle chips; a jump-to-latest button appears when scrolled up and new content only auto-scrolls when already near the bottom. +- Width changes reflow the draft. Layout changes keep following the latest reply only while the reader is attached to the end; browsing history preserves its position. +- Message arrivals consume the current following state. They must not recompute it from post-reflow geometry: scroll handling owns reader attachment, while jumping to the end and clearing reset it. +- Copy feedback uses fixed-size icons and a separate polite announcement. Clipboard failure leaves the action available to retry; feedback never replaces running activity. - Restyle via CSS custom properties on `.cui-root` (`--cui-accent`, `--cui-user-bg`, …) — don't depend on internal `cui-*` class names. - DOM is built with the ranui `builder` (View/Div/Span/ButtonBuilder) and the scroll handler is throttled via ranuts; the package depends on `ranui` + `ranuts`. diff --git a/packages/chat-ui/src/chat-view.ts b/packages/chat-ui/src/chat-view.ts index 3169bf67..903be92b 100644 --- a/packages/chat-ui/src/chat-view.ts +++ b/packages/chat-ui/src/chat-view.ts @@ -12,6 +12,10 @@ const ICON_DOWN = ''; const ICON_COPY = ''; +const ICON_COPIED = + ''; +const ICON_COPY_ERROR = + ''; /** Distance (px) from the bottom within which we keep auto-scrolling on new content. */ const STICK_THRESHOLD = 60; @@ -50,6 +54,18 @@ export class ChatView { private composing = false; private lastUserText = ''; private readonly statusEl = Div().class('cui-status').attr('role', 'status').build(); + private readonly feedbackEl = Div().class('cui-feedback').attr('role', 'status').attr('aria-atomic', 'true').build(); + private readonly resizeObserver?: ResizeObserver; + private inputWidth = 0; + private inputNeedsResize = false; + private layoutFrame?: number; + // Content mutations consume this state; only scrolling, jumping to the end, + // or clearing a conversation may change it. Reflow is not reader intent. + private following = true; + private scrollHeight = 0; + private viewportHeight = 0; + private disposed = false; + private readonly copyTimers = new Map>(); /** The agent bubble currently receiving streamed deltas, if any. */ private liveMsg: HTMLDivElement | null = null; private liveText = ''; @@ -64,15 +80,21 @@ export class ChatView { this.emptyActionsEl = Div().class('cui-empty-actions').build(); this.emptyEl = Div().class('cui-empty').children(this.emptyTitle, this.emptyActionsEl).build(); + const updateScroll = throttle(() => this.updateScrollBtn(), 100); this.messagesEl = Div() .class('cui-messages') .attr('role', 'log') .attr('aria-busy', 'false') .attr('tabindex', '-1') - .on( - 'scroll', - throttle(() => this.updateScrollBtn(), 100), - ) + .on('scroll', () => { + // Reflow can emit scroll before ResizeObserver. Changed geometry does + // not establish that the reader intentionally scrolled away. + const unchanged = + this.scrollHeight === this.messagesEl.scrollHeight && this.viewportHeight === this.messagesEl.clientHeight; + if (!this.scrollHeight || unchanged || this.nearBottom()) this.following = this.nearBottom(); + this.recordScrollGeometry(); + updateScroll(); + }) .children(this.emptyEl) .build(); @@ -135,7 +157,39 @@ export class ChatView { // slot, and the composer. const footer = Div().class('cui-footer').children(this.scrollBtn, composer).build(); - this.el = Div().class('cui-root').children(this.messagesEl, this.statusEl, footer).build(); + this.el = Div().class('cui-root').children(this.messagesEl, this.statusEl, footer, this.feedbackEl).build(); + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver((entries) => { + if (this.disposed) return; + for (const entry of entries) { + if ( + entry.target === this.input && + entry.contentRect.width > 0 && + entry.contentRect.width !== this.inputWidth + ) { + this.inputWidth = entry.contentRect.width; + this.inputNeedsResize = true; + } + } + // Write in the next frame, outside observer delivery. Resizing a sibling + // viewport here can otherwise produce undelivered-notification errors. + if (this.layoutFrame !== undefined) return; + this.layoutFrame = requestAnimationFrame(() => { + this.layoutFrame = undefined; + if (this.disposed) return; + if (this.inputNeedsResize) { + this.inputNeedsResize = false; + this.autoGrow(); + } + if (this.following) this.scrollToEnd(); + else { + this.recordScrollGeometry(); + this.updateScrollBtn(); + } + }); + }); + this.observeLayout(); + } this.setLabels(this.labels); this.updateSendState(); } @@ -163,7 +217,7 @@ export class ChatView { this.endStream(); } if (message.role === 'user') this.lastUserText = message.text; - const stick = this.nearBottom(); + const stick = this.following; this.emptyEl.remove(); if (message.role === 'tool') { const last = this.messagesEl.lastElementChild; @@ -180,6 +234,7 @@ export class ChatView { const details = document.createElement('details'); details.className = 'cui-activity'; details.append(document.createElement('summary'), document.createElement('ul')); + this.resizeObserver?.unobserve(group); group.replaceWith(details); group = details; } else { @@ -192,6 +247,7 @@ export class ChatView { } if (stick) this.scrollToEnd(); else this.updateScrollBtn(); + this.resizeObserver?.observe(group); return item; } @@ -202,19 +258,34 @@ export class ChatView { const actions = Div().class('cui-message-actions').build(); if (message.role === 'agent') { const copy = ButtonBuilder().class('cui-copy').attr('type', 'button').build(); - copy.innerHTML = ICON_COPY; - copy.title = this.labels.copy ?? 'Copy'; - copy.setAttribute('aria-label', copy.title); + this.updateCopyButton(copy); copy.addEventListener('click', async () => { + if (this.disposed || copy.disabled) return; + clearTimeout(this.copyTimers.get(copy)); + this.copyTimers.delete(copy); + this.feedbackEl.textContent = ''; copy.disabled = true; + let state: 'copied' | 'failed'; try { await navigator.clipboard.writeText(row.dataset.source ?? message.text); - copy.textContent = this.labels.copied ?? 'Copied'; + state = 'copied'; } catch { - copy.textContent = this.labels.copyFailed ?? 'Could not copy'; + state = 'failed'; } finally { copy.disabled = false; } + if (this.disposed || !this.messagesEl.contains(row)) return; + copy.dataset.state = state; + this.updateCopyButton(copy); + this.feedbackEl.textContent = copy.title; + this.copyTimers.set( + copy, + setTimeout(() => { + this.copyTimers.delete(copy); + delete copy.dataset.state; + this.updateCopyButton(copy); + }, 1800), + ); }); actions.append(copy); if ( @@ -268,6 +339,7 @@ export class ChatView { if (message.interrupted) row.dataset.interrupted = 'true'; this.messagesEl.appendChild(row); + this.resizeObserver?.observe(row); this.updateRestoreState(); if (stick) this.scrollToEnd(); else this.updateScrollBtn(); @@ -280,7 +352,7 @@ export class ChatView { */ appendDelta(delta: string): void { if (!delta) return; - const stick = this.nearBottom(); + const stick = this.following; if (!this.liveMsg) { this.liveMsg = this.append({ role: 'agent', text: '' }).parentElement as HTMLDivElement; this.liveMsg.classList.add('cui-streaming'); @@ -294,7 +366,7 @@ export class ChatView { /** Finalise the current streaming bubble (removes the caret). */ endStream(): void { - const stick = this.nearBottom(); + const stick = this.following; const bubble = this.liveMsg?.querySelector('.cui-bubble'); if (bubble) renderMarkdown(bubble, this.liveText); this.liveMsg?.classList.remove('cui-streaming'); @@ -306,8 +378,9 @@ export class ChatView { /** Mount a host-owned review card in chronological conversation order. */ appendContent(content: HTMLElement): void { this.emptyEl.remove(); - const stick = this.nearBottom(); + const stick = this.following; this.messagesEl.append(content); + this.resizeObserver?.observe(content); if (stick) this.scrollToEnd(); else this.updateScrollBtn(); } @@ -342,11 +415,15 @@ export class ChatView { /** Remove all messages and restore the empty state. */ clear(): void { + this.clearCopyFeedback(); + this.resizeObserver?.disconnect(); this.messagesEl.replaceChildren(this.emptyEl); this.emptyTitle.textContent = this.labels.empty ?? ''; this.liveMsg = null; this.liveText = ''; this.lastUserText = ''; + this.following = true; + this.observeLayout(); this.updateScrollBtn(); } @@ -379,10 +456,8 @@ export class ChatView { this.input.setAttribute('aria-label', labels.placeholder ?? 'Message'); this.scrollBtn.setAttribute('aria-label', labels.scrollLatest ?? 'Scroll to latest'); this.updateActivity(); - for (const button of this.messagesEl.querySelectorAll('.cui-copy')) { - button.title = labels.copy ?? 'Copy'; - button.setAttribute('aria-label', button.title); - } + for (const button of this.messagesEl.querySelectorAll('.cui-copy')) + this.updateCopyButton(button); for (const button of this.messagesEl.querySelectorAll('.cui-apply')) { const state = button.dataset.state; button.title = labels.applyTip ?? 'Insert at the cursor or replace the selected text'; @@ -434,21 +509,62 @@ export class ChatView { this.input.style.height = `${Math.min(this.input.scrollHeight, 160)}px`; } + /** Release layout observers and transient feedback when the host unmounts. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.layoutFrame !== undefined) cancelAnimationFrame(this.layoutFrame); + this.layoutFrame = undefined; + this.resizeObserver?.disconnect(); + this.clearCopyFeedback(); + } + + private observeLayout(): void { + if (this.disposed) return; + for (const element of [this.messagesEl, this.input, this.emptyEl]) this.resizeObserver?.observe(element); + } + + private clearCopyFeedback(): void { + for (const timer of this.copyTimers.values()) clearTimeout(timer); + this.copyTimers.clear(); + this.feedbackEl.textContent = ''; + } + + private updateCopyButton(button: HTMLButtonElement): void { + const state = button.dataset.state; + button.innerHTML = state === 'copied' ? ICON_COPIED : state === 'failed' ? ICON_COPY_ERROR : ICON_COPY; + button.title = + state === 'copied' + ? (this.labels.copied ?? 'Copied') + : state === 'failed' + ? (this.labels.copyFailed ?? 'Could not copy') + : (this.labels.copy ?? 'Copy'); + button.setAttribute('aria-label', button.title); + } + private nearBottom(): boolean { const el = this.messagesEl; return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD; } + private recordScrollGeometry(): void { + this.scrollHeight = this.messagesEl.scrollHeight; + this.viewportHeight = this.messagesEl.clientHeight; + } + private scrollToEnd(smooth = false): void { + this.following = true; const reduceMotion = smooth && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; this.messagesEl.scrollTo({ top: this.messagesEl.scrollHeight, behavior: smooth && !reduceMotion ? 'smooth' : 'auto', }); + this.recordScrollGeometry(); this.updateScrollBtn(); } private updateScrollBtn(): void { + if (this.disposed) return; const hidden = this.emptyEl.parentElement === this.messagesEl || this.nearBottom(); // Keep focus in the conversation when its jump control disappears. if (hidden && document.activeElement === this.scrollBtn) this.messagesEl.focus({ preventScroll: true }); diff --git a/packages/chat-ui/src/markdown.ts b/packages/chat-ui/src/markdown.ts index d2cbb3fd..893bee33 100644 --- a/packages/chat-ui/src/markdown.ts +++ b/packages/chat-ui/src/markdown.ts @@ -30,10 +30,12 @@ function appendTokens(parent: Node, tokens: Token[]): void { const table = token as Tokens.Table; const wrapper = document.createElement('div'); wrapper.className = 'cui-table'; + wrapper.tabIndex = 0; const el = document.createElement('table'); const head = el.createTHead().insertRow(); for (const cell of table.header) { const th = document.createElement('th'); + th.scope = 'col'; appendTokens(th, cell.tokens); head.append(th); } @@ -48,6 +50,7 @@ function appendTokens(parent: Node, tokens: Token[]): void { } if (token.type === 'code') { const pre = document.createElement('pre'); + pre.tabIndex = 0; const code = document.createElement('code'); code.textContent = (token as Tokens.Code).text; pre.append(code); diff --git a/packages/chat-ui/src/styles.ts b/packages/chat-ui/src/styles.ts index 369daf77..00cfa578 100644 --- a/packages/chat-ui/src/styles.ts +++ b/packages/chat-ui/src/styles.ts @@ -31,15 +31,24 @@ export const CHAT_UI_CSS = ` .cui-bubble code { font-family:ui-monospace,monospace; font-size:.88em; background:var(--cui-user-bg); padding:2px 4px; border-radius:4px; } .cui-bubble pre { margin:12px 0; background:var(--cui-user-bg); padding:12px; border-radius:10px; overflow:auto; white-space:pre; } .cui-bubble pre code { padding:0; background:none; } +.cui-bubble > :first-child { margin-top:0; } +.cui-bubble > :last-child { margin-bottom:0; } +.cui-bubble li > p { margin:0 0 6px; } +.cui-bubble li > :last-child { margin-bottom:0; } +.cui-bubble li > :is(ul,ol) { margin-bottom:0; } +.cui-bubble pre,.cui-table { max-width:100%; box-sizing:border-box; overscroll-behavior-x:contain; } .cui-bubble a { color:var(--cui-text); text-underline-offset:3px; } -.cui-table { overflow:auto; } -.cui-table table { border-collapse:collapse; font-size:13px; } +.cui-table { overflow:auto; margin:12px 0; } +.cui-table table { border-collapse:collapse; font-size:inherit; min-width:100%; overflow-wrap:normal; } .cui-table th,.cui-table td { border-bottom:1px solid var(--cui-border); padding:8px; text-align:left; } .cui-message-actions { display:flex; gap:8px; } .cui-message-actions button { border:0; background:transparent; color:var(--cui-muted); cursor:pointer; font:inherit; font-size:11px; min-height:28px; padding:4px; border-radius:6px; } .cui-message-actions button:hover { color:var(--cui-text); background:var(--cui-user-bg); } .cui-message-actions button:disabled { opacity:.5; cursor:default; } -.cui-root button:focus-visible { outline:var(--ran-focus-ring, 2px solid var(--ran-color-link, #0070f3)); outline-offset:3px; } +.cui-message-actions .cui-copy { flex:0 0 28px; width:28px; display:inline-flex; align-items:center; justify-content:center; } +.cui-message-actions .cui-copy svg { flex-shrink:0; } +.cui-feedback { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; } +.cui-root :is(button,pre,.cui-table):focus-visible { outline:var(--ran-focus-ring, 2px solid var(--ran-color-link, #0070f3)); outline-offset:3px; } .cui-streaming .cui-message-actions { display:none; } .cui-streaming .cui-bubble { white-space:pre-wrap; } .cui-activity { color:var(--cui-muted); font-size:12px; margin:-10px 0; white-space:pre-wrap; overflow-wrap:anywhere; } @@ -58,7 +67,7 @@ export const CHAT_UI_CSS = ` .cui-composer-bar { display:flex; align-items:center; gap:8px; margin-top:12px; } .cui-actions { display:flex; flex:1; min-width:0; align-items:center; gap:6px; font-size:12px; flex-wrap:wrap; } .cui-context:empty { display:none; } -.cui-input { min-width:0; width:100%; box-sizing:border-box; resize:none; border:0; outline:none; background:transparent; padding:0; font:inherit; line-height:1.5; color:inherit; max-height:160px; min-height:40px; } +.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:40px; } .cui-input::placeholder { color:var(--cui-muted); } .cui-send { flex:0 0 auto; width:30px; height:30px; box-sizing:border-box; padding:0; display:flex; align-items:center; justify-content:center; border:0; border-radius:8px; cursor:pointer; background:var(--cui-accent); color:var(--cui-accent-contrast); } .cui-send:disabled { opacity:.35; cursor:default; } diff --git a/test/e2e/agent-composer-ux.spec.ts b/test/e2e/agent-composer-ux.spec.ts index f32b5f8e..a7a2e5ce 100644 --- a/test/e2e/agent-composer-ux.spec.ts +++ b/test/e2e/agent-composer-ux.spec.ts @@ -4,7 +4,9 @@ import { buildDocx, toBase64 } from './lib/ooxml'; // Controlled latency verifies editing and focus during a real panel request, // independently of model quality. No request is queued or sent automatically. for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { - test(`${ext}: a pending response preserves an editable next draft and historical focus`, async ({ page }) => { + test(`${ext}: a pending response preserves an editable next draft and historical focus`, async ({ + page, + }, testInfo) => { test.setTimeout(120_000); let pdf: number[] | undefined; if (ext === 'pdf') { @@ -56,7 +58,19 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { else { answers++; if (answers === 1) await hold; - content = 'Completed response'; + content = + answers === 1 + ? 'Completed response' + : [ + '# Result', + ...Array.from( + { length: 24 }, + (_, index) => `Paragraph ${index + 1}: A readable answer in a narrow document sidebar.`, + ), + '- First point\n- Second point', + '```txt\n' + 'long_code_value_'.repeat(30) + '\n```', + '| First | Second | Third | Fourth | Fifth | Sixth | Seventh | Eighth |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 |', + ].join('\n\n'); } await route.fulfill({ json: { done: true, message: { role: 'assistant', content } } }); }); @@ -95,6 +109,52 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { await input.press('Enter'); await expect.poll(() => answers).toBe(2); await expect(page.locator('.cui-msg-user')).toHaveCount(2); + await expect(page.locator('.cui-send-stop')).toHaveCount(0); + await expect(page.locator('.cui-msg-agent')).toHaveCount(2); + const resize = page.getByRole('separator', { name: 'Resize AI panel' }); + const draft = 'Keep this next draft editable while I resize the sidebar. '.repeat(8); + await resize.press('Home'); + await input.fill(draft); + await expect.poll(() => input.evaluate((el) => el.clientHeight)).toBe(160); + expect(await input.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true); + await expect.poll(() => log.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(3); + const narrowHeight = await input.evaluate((el) => el.clientHeight); + await resize.press('End'); + await expect.poll(() => input.evaluate((el) => el.clientHeight)).toBeLessThan(narrowHeight); + await expect(input).toHaveValue(draft); + await expect.poll(() => log.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(3); + await log.evaluate((el) => { + el.scrollTop = 0; + }); + await resize.press('Home'); + await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(0); + await input.fill(draft + draft); + await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(0); + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + const copy = page.locator('.cui-copy').first(); + const width = (await copy.boundingBox())!.width; + await copy.click(); + await expect(copy).toHaveAccessibleName('Copied'); + await expect(page.locator('.cui-feedback')).toHaveText('Copied'); + expect((await copy.boundingBox())!.width).toBe(width); + await expect(copy).toHaveAccessibleName('Copy'); + await expect(page.locator('.cui-msg-agent pre')).toHaveAttribute('tabindex', '0'); + await expect(page.locator('.cui-msg-agent .cui-table')).toHaveAttribute('tabindex', '0'); + await expect + .poll(() => page.locator('.agent-runtime-panel').evaluate((el) => el.scrollWidth - el.clientWidth)) + .toBeLessThan(2); + expect(answers).toBe(2); + await page.getByRole('button', { name: 'Scroll to latest' }).click(); + await expect.poll(() => log.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(3); + const code = page.locator('.cui-msg-agent pre'); + await code.focus(); + await code.press('ArrowRight'); + await expect.poll(() => code.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); + const table = page.locator('.cui-msg-agent .cui-table'); + await table.focus(); + await table.press('ArrowRight'); + await expect.poll(() => table.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); + await page.screenshot({ path: testInfo.outputPath('chat-details.png') }); } finally { release(); } diff --git a/test/unit/chat-view-layout.test.ts b/test/unit/chat-view-layout.test.ts new file mode 100644 index 00000000..3c4cffde --- /dev/null +++ b/test/unit/chat-view-layout.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ChatView } from '../../packages/chat-ui/src/chat-view'; + +let resize: ResizeObserverCallback; +let frames: FrameRequestCallback[]; +const disconnect = vi.fn(); +beforeEach(() => { + document.body.replaceChildren(); + HTMLElement.prototype.scrollTo = vi.fn(); + disconnect.mockClear(); + frames = []; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resize = callback; + } + observe() {} + unobserve() {} + disconnect = disconnect; + }, + ); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +function mount() { + const view = new ChatView({ onSend: vi.fn() }); + document.body.append(view.el); + const input = view.el.querySelector('textarea')!; + const messages = view.el.querySelector('.cui-messages')!; + return { view, input, messages }; +} +function resizeEntry(target: Element, width: number): ResizeObserverEntry { + return { + target, + contentRect: new DOMRectReadOnly(0, 0, width, 0), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }; +} +function resized(target: Element, width: number) { + resize([resizeEntry(target, width)], {} as ResizeObserver); + frames.splice(0).forEach((callback) => callback(0)); +} +it('defers dimension writes until after the resize notification to avoid observer loops', () => { + const { view, input } = mount(); + Object.defineProperty(input, 'scrollHeight', { configurable: true, value: 70 }); + view.setInput('Draft'); + Object.defineProperty(input, 'scrollHeight', { configurable: true, value: 300 }); + resize([resizeEntry(input, 280)], {} as ResizeObserver); + expect(input.style.height).toBe('70px'); + frames.splice(0).forEach((callback) => callback(0)); + expect(input.style.height).toBe('160px'); +}); +it('reflows the same draft when the sidebar width changes and caps long input', () => { + const { view, input } = mount(); + Object.defineProperty(input, 'scrollHeight', { configurable: true, value: 70 }); + view.setInput('A draft that wraps at a narrow width.'); + expect(input.style.height).toBe('70px'); + Object.defineProperty(input, 'scrollHeight', { configurable: true, value: 300 }); + resized(input, 280); + expect(input.style.height).toBe('160px'); + expect(input.value).toBe('A draft that wraps at a narrow width.'); + Object.defineProperty(input, 'scrollHeight', { configurable: true, value: 50 }); + resized(input, 480); + expect(input.style.height).toBe('50px'); +}); +it.each([true, false])('resize follows only a reader already at the end: %s', (atEnd) => { + const { view, messages } = mount(); + view.append({ role: 'agent', text: 'A long answer' }); + Object.defineProperties(messages, { + scrollHeight: { configurable: true, value: 1000 }, + clientHeight: { configurable: true, value: 200 }, + }); + messages.scrollTop = atEnd ? 800 : 100; + messages.dispatchEvent(new Event('scroll')); + vi.mocked(messages.scrollTo).mockClear(); + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 100 }); + // Browsers can fire scroll before ResizeObserver when the composer changes the viewport. + messages.dispatchEvent(new Event('scroll')); + resized(messages, 320); + if (atEnd) expect(messages.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' }); + else { + expect(messages.scrollTo).not.toHaveBeenCalled(); + expect(messages.scrollTop).toBe(100); + } +}); +it.each(['delta', 'message', 'completion', 'review'] as const)( + 'preserves following when %s arrives between reflow and its scheduled frame', + (arrival) => { + const { view, messages } = mount(); + view.appendDelta('First'); + Object.defineProperties(messages, { + scrollHeight: { configurable: true, value: 1000 }, + clientHeight: { configurable: true, value: 200 }, + }); + messages.scrollTop = 800; + messages.dispatchEvent(new Event('scroll')); + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 100 }); + messages.dispatchEvent(new Event('scroll')); + resize([resizeEntry(messages, 320)], {} as ResizeObserver); + vi.mocked(messages.scrollTo).mockClear(); + if (arrival === 'delta') view.appendDelta(' streamed'); + else if (arrival === 'message') view.append({ role: 'status', text: 'Done' }); + else if (arrival === 'completion') view.endStream(); + else view.appendContent(document.createElement('section')); + frames.splice(0).forEach((callback) => callback(0)); + expect(messages.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' }); + view.dispose(); + }, +); +it('honors scrolling into history before a pending resize frame and the next streamed delta', () => { + const { view, messages } = mount(); + view.appendDelta('First'); + Object.defineProperties(messages, { + scrollHeight: { configurable: true, value: 1000 }, + clientHeight: { configurable: true, value: 200 }, + }); + messages.scrollTop = 800; + messages.dispatchEvent(new Event('scroll')); + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 100 }); + messages.dispatchEvent(new Event('scroll')); + resize([resizeEntry(messages, 320)], {} as ResizeObserver); + messages.scrollTop = 100; + messages.dispatchEvent(new Event('scroll')); + vi.mocked(messages.scrollTo).mockClear(); + view.appendDelta(' streamed'); + frames.splice(0).forEach((callback) => callback(0)); + expect(messages.scrollTo).not.toHaveBeenCalled(); + expect(messages.scrollTop).toBe(100); + view.dispose(); +}); +it('copy feedback restores the icon label, can retry, and does not replace running activity', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockRejectedValueOnce(new Error('Denied')).mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + const { view } = mount(); + view.append({ role: 'agent', text: '**Source**' }); + view.setRunning(true); + view.setActivity('reading'); + const copy = view.el.querySelector('.cui-copy')!; + copy.click(); + await Promise.resolve(); + expect(copy.getAttribute('aria-label')).toBe('Could not copy'); + expect(copy.disabled).toBe(false); + copy.click(); + await Promise.resolve(); + expect(copy.getAttribute('aria-label')).toBe('Copied'); + expect(writeText).toHaveBeenLastCalledWith('**Source**'); + expect(view.el.querySelector('.cui-status')?.textContent).toBe('Reading content…'); + expect(view.el.querySelector('.cui-feedback')?.getAttribute('role')).toBe('status'); + await vi.advanceTimersByTimeAsync(2000); + expect(copy.getAttribute('aria-label')).toBe('Copy'); + expect(copy.querySelector('svg')).not.toBeNull(); + view.dispose(); +}); +it('disposes observers and discards clipboard completions after unmount', async () => { + let finish!: () => void; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: () => new Promise((resolve) => (finish = resolve)) }, + }); + const { view, messages } = mount(); + view.append({ role: 'agent', text: 'Source' }); + view.el.querySelector('.cui-copy')!.click(); + view.dispose(); + finish(); + await Promise.resolve(); + expect(disconnect).toHaveBeenCalledOnce(); + expect(view.el.querySelector('.cui-feedback')?.textContent).toBe(''); + vi.mocked(messages.scrollTo).mockClear(); + resized(messages, 320); + expect(messages.scrollTo).not.toHaveBeenCalled(); +}); diff --git a/test/unit/chat-view.test.ts b/test/unit/chat-view.test.ts index d3851092..cb647e44 100644 --- a/test/unit/chat-view.test.ts +++ b/test/unit/chat-view.test.ts @@ -61,6 +61,16 @@ it('renders useful Markdown safely and leaves user text literal', () => { expect(view.el.querySelector('.cui-msg-user strong')).toBeNull(); expect(view.el.querySelector('.cui-role')).toBeNull(); }); +it('makes code and table overflow keyboard reachable without losing table semantics', () => { + const { view } = mount(); + view.append({ + role: 'agent', + text: '```txt\nLong code line\n```\n\n| Name | Value |\n| --- | --- |\n| Budget | 120 |', + }); + expect(view.el.querySelector('pre')?.getAttribute('tabindex')).toBe('0'); + expect(view.el.querySelector('.cui-table')?.getAttribute('tabindex')).toBe('0'); + expect(view.el.querySelector('th')?.getAttribute('scope')).toBe('col'); +}); it('shows one execution result directly without a duplicate or empty disclosure', () => { const { view } = mount(); view.append({ role: 'tool', text: 'A1\n12 ' }); @@ -156,11 +166,16 @@ it('copies exact message text and reports clipboard failures without executing a const copy = view.el.querySelector('.cui-copy')!; expect(copy).not.toBeNull(); copy.click(); - await vi.waitFor(() => expect(copy.textContent).toBe('Copied')); + await vi.waitFor(() => expect(copy.getAttribute('aria-label')).toBe('Copied')); + expect(copy.querySelector('svg')).not.toBeNull(); + expect(view.el.querySelector('.cui-feedback')?.textContent).toBe('Copied'); expect(writeText).toHaveBeenCalledWith('plain text'); writeText.mockRejectedValue(new Error('Denied')); copy.click(); - await vi.waitFor(() => expect(copy.textContent).toBe('Could not copy')); + await vi.waitFor(() => expect(copy.getAttribute('aria-label')).toBe('Could not copy')); + expect(copy.querySelector('svg')).not.toBeNull(); + expect(copy.disabled).toBe(false); + expect(view.el.querySelector('.cui-feedback')?.textContent).toBe('Could not copy'); }); it('restores failed requests into the draft without sending and preserves an existing draft', () => { const { view, input, onSend } = mount(); @@ -285,6 +300,7 @@ it.each([true, false])( view.appendDelta('**Final reply**'); Object.defineProperties(messages, { scrollHeight: { value: 1000 }, clientHeight: { value: 200 } }); messages.scrollTop = atBottom ? 800 : 100; + messages.dispatchEvent(new Event('scroll')); vi.mocked(messages.scrollTo).mockClear(); view.endStream(); expect(view.el.querySelector('.cui-bubble strong')?.textContent).toBe('Final reply'); @@ -403,6 +419,7 @@ it('does not interrupt historical reading when a review is appended', () => { const messages = view.el.querySelector('.cui-messages')!; Object.defineProperties(messages, { scrollHeight: { value: 1000 }, clientHeight: { value: 200 } }); messages.scrollTop = 100; + messages.dispatchEvent(new Event('scroll')); vi.mocked(messages.scrollTo).mockClear(); const review = document.createElement('section'); review.textContent = 'Review changes';