From 443afab2dbf58251d22cc2a0d6578ea755459c74 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 11 Oct 2026 14:47:40 +0800 Subject: [PATCH 1/6] fix(assistant): refine sidebar resizing and copy feedback --- CHANGELOG.md | 2 + docs/assistant-format-regression.md | 55 +++++++++++ lib/agent-plugin/ui/panel.ts | 3 + packages/chat-ui/CLAUDE.md | 3 + packages/chat-ui/src/chat-view.ts | 146 +++++++++++++++++++++++++--- packages/chat-ui/src/markdown.ts | 3 + packages/chat-ui/src/styles.ts | 17 +++- test/e2e/agent-composer-ux.spec.ts | 64 +++++++++++- test/unit/chat-view-layout.test.ts | 134 +++++++++++++++++++++++++ test/unit/chat-view.test.ts | 19 +++- 10 files changed, 424 insertions(+), 22 deletions(-) create mode 100644 test/unit/chat-view-layout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d398408..e48aa9be9 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 b7fd53565..e6db3c1f3 100644 --- a/docs/assistant-format-regression.md +++ b/docs/assistant-format-regression.md @@ -158,3 +158,58 @@ 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. diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 0432f95fa..fa576924e 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 b272dca5f..33c97d57b 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,8 @@ 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. +- 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 3169bf67b..282080e91 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,16 @@ 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; + 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 +78,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 +155,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(); } @@ -164,6 +216,7 @@ export class ChatView { } if (message.role === 'user') this.lastUserText = message.text; const stick = this.nearBottom(); + this.following = stick; this.emptyEl.remove(); if (message.role === 'tool') { const last = this.messagesEl.lastElementChild; @@ -180,6 +233,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 +246,7 @@ export class ChatView { } if (stick) this.scrollToEnd(); else this.updateScrollBtn(); + this.resizeObserver?.observe(group); return item; } @@ -202,19 +257,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 +338,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(); @@ -281,6 +352,7 @@ export class ChatView { appendDelta(delta: string): void { if (!delta) return; const stick = this.nearBottom(); + this.following = stick; if (!this.liveMsg) { this.liveMsg = this.append({ role: 'agent', text: '' }).parentElement as HTMLDivElement; this.liveMsg.classList.add('cui-streaming'); @@ -295,6 +367,7 @@ export class ChatView { /** Finalise the current streaming bubble (removes the caret). */ endStream(): void { const stick = this.nearBottom(); + this.following = stick; const bubble = this.liveMsg?.querySelector('.cui-bubble'); if (bubble) renderMarkdown(bubble, this.liveText); this.liveMsg?.classList.remove('cui-streaming'); @@ -307,7 +380,9 @@ export class ChatView { appendContent(content: HTMLElement): void { this.emptyEl.remove(); const stick = this.nearBottom(); + this.following = stick; this.messagesEl.append(content); + this.resizeObserver?.observe(content); if (stick) this.scrollToEnd(); else this.updateScrollBtn(); } @@ -342,11 +417,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 +458,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 +511,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 d2cbb3fda..893bee334 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 369daf777..00cfa5789 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 f32b5f8e3..a7a2e5ce8 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 000000000..6aa67243d --- /dev/null +++ b/test/unit/chat-view-layout.test.ts @@ -0,0 +1,134 @@ +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('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 d38510923..5a4334af1 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(); From 5c6f6be05e096760cbd6b015c1b71fbe9323fd4e Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 11 Oct 2026 15:01:30 +0800 Subject: [PATCH 2/6] fix(chat-ui): preserve reader attachment through reflow --- docs/assistant-format-regression.md | 18 ++++++++++++ packages/chat-ui/CLAUDE.md | 1 + packages/chat-ui/src/chat-view.ts | 14 ++++----- test/unit/chat-view-layout.test.ts | 45 +++++++++++++++++++++++++++++ test/unit/chat-view.test.ts | 2 ++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/docs/assistant-format-regression.md b/docs/assistant-format-regression.md index e6db3c1f3..0ad51717e 100644 --- a/docs/assistant-format-regression.md +++ b/docs/assistant-format-regression.md @@ -213,3 +213,21 @@ 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. + +## 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. diff --git a/packages/chat-ui/CLAUDE.md b/packages/chat-ui/CLAUDE.md index 33c97d57b..95659ce21 100644 --- a/packages/chat-ui/CLAUDE.md +++ b/packages/chat-ui/CLAUDE.md @@ -80,6 +80,7 @@ onSend: async (text) => { - 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 282080e91..903be92b1 100644 --- a/packages/chat-ui/src/chat-view.ts +++ b/packages/chat-ui/src/chat-view.ts @@ -59,6 +59,8 @@ export class ChatView { 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; @@ -215,8 +217,7 @@ export class ChatView { this.endStream(); } if (message.role === 'user') this.lastUserText = message.text; - const stick = this.nearBottom(); - this.following = stick; + const stick = this.following; this.emptyEl.remove(); if (message.role === 'tool') { const last = this.messagesEl.lastElementChild; @@ -351,8 +352,7 @@ export class ChatView { */ appendDelta(delta: string): void { if (!delta) return; - const stick = this.nearBottom(); - this.following = stick; + const stick = this.following; if (!this.liveMsg) { this.liveMsg = this.append({ role: 'agent', text: '' }).parentElement as HTMLDivElement; this.liveMsg.classList.add('cui-streaming'); @@ -366,8 +366,7 @@ export class ChatView { /** Finalise the current streaming bubble (removes the caret). */ endStream(): void { - const stick = this.nearBottom(); - this.following = stick; + const stick = this.following; const bubble = this.liveMsg?.querySelector('.cui-bubble'); if (bubble) renderMarkdown(bubble, this.liveText); this.liveMsg?.classList.remove('cui-streaming'); @@ -379,8 +378,7 @@ export class ChatView { /** Mount a host-owned review card in chronological conversation order. */ appendContent(content: HTMLElement): void { this.emptyEl.remove(); - const stick = this.nearBottom(); - this.following = stick; + const stick = this.following; this.messagesEl.append(content); this.resizeObserver?.observe(content); if (stick) this.scrollToEnd(); diff --git a/test/unit/chat-view-layout.test.ts b/test/unit/chat-view-layout.test.ts index 6aa67243d..3c4cffde7 100644 --- a/test/unit/chat-view-layout.test.ts +++ b/test/unit/chat-view-layout.test.ts @@ -90,6 +90,51 @@ it.each([true, false])('resize follows only a reader already at the end: %s', (a 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); diff --git a/test/unit/chat-view.test.ts b/test/unit/chat-view.test.ts index 5a4334af1..cb647e44f 100644 --- a/test/unit/chat-view.test.ts +++ b/test/unit/chat-view.test.ts @@ -300,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'); @@ -418,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'; From fa231476a93cccf867e16240ecb5d40102b0e9be Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 11 Oct 2026 15:11:57 +0800 Subject: [PATCH 3/6] docs(assistant): record equal-count multilingual reading probe --- docs/assistant-format-regression.md | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/assistant-format-regression.md b/docs/assistant-format-regression.md index 0ad51717e..48c265786 100644 --- a/docs/assistant-format-regression.md +++ b/docs/assistant-format-regression.md @@ -214,6 +214,48 @@ being promoted as a fix. The local model's multilingual task selection and answe 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 From 08aa61315ff93e1d1455233a5691533bfd68fff9 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 11 Oct 2026 15:28:38 +0800 Subject: [PATCH 4/6] fix(assistant): clarify document reading tool purpose --- docs/assistant-format-regression.md | 43 +++++++++++++++++++++++++++++ lib/agent-plugin/tools.ts | 6 +++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/assistant-format-regression.md b/docs/assistant-format-regression.md index 48c265786..110890904 100644 --- a/docs/assistant-format-regression.md +++ b/docs/assistant-format-regression.md @@ -273,3 +273,46 @@ 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. diff --git a/lib/agent-plugin/tools.ts b/lib/agent-plugin/tools.ts index d864bd94c..ebc7e3137 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: { From 4b722e27f07efb298595c86e347ee0638bc69565 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 11 Oct 2026 15:35:45 +0800 Subject: [PATCH 5/6] docs(assistant): record current cross-format acceptance --- docs/assistant-format-regression.md | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/assistant-format-regression.md b/docs/assistant-format-regression.md index 110890904..f859b0099 100644 --- a/docs/assistant-format-regression.md +++ b/docs/assistant-format-regression.md @@ -316,3 +316,46 @@ For the generic description change, the full unit suite passed 4,961 tests 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. From 2404961de04ebe7940d358537899e1225dd9f252 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 11 Oct 2026 15:55:23 +0800 Subject: [PATCH 6/6] docs(assistant): record native model task acceptance --- docs/assistant-format-regression.md | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/docs/assistant-format-regression.md b/docs/assistant-format-regression.md index f859b0099..9f6453890 100644 --- a/docs/assistant-format-regression.md +++ b/docs/assistant-format-regression.md @@ -359,3 +359,104 @@ 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.