From be190511db8b30423e46c5f8ba979af023fe3041 Mon Sep 17 00:00:00 2001 From: ZhouHang Date: Tue, 13 Oct 2026 10:02:23 +0800 Subject: [PATCH 1/4] fix(assistant): align sidebar content and compact model settings --- lib/agent-plugin/ui/panel.ts | 7 +++-- styles/assistant.css | 45 +++++++++++++++++---------- test/e2e/agent-sidebar-layout.spec.ts | 26 ++++++++++++++++ 3 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 test/e2e/agent-sidebar-layout.spec.ts diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index d3693eb81..4c092ee9c 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -2526,7 +2526,8 @@ export function createAgentPanel( const modelChoice = Div().build(); modelChoice.className = 'agent-model-choice'; modelChoice.append(modelSelect, modelMemory); - modelRow.replaceChildren(modelChoice, loadBtn); + const modelActions = Div().class('agent-model-actions').children(loadBtn).build(); + modelRow.replaceChildren(modelChoice, modelActions); localCard.append(modelRow, ggufRow); const serviceCard = View('section').build(); serviceCard.className = 'agent-use-card agent-service-section'; @@ -2787,7 +2788,7 @@ export function createAgentPanel( } })(); }); - localCard.append(release); + modelActions.append(release); syncSource(); void refreshCache(); settings.replaceChildren(settingsHero, sourcePicker, localCard, serviceCard, cacheList, note, advanced); @@ -2820,7 +2821,7 @@ export function createAgentPanel( viewRevision++; speech?.close(); panel.dataset.view = view; - if (view === 'settings') localCard.insertBefore(runtimeRow, release); + if (view === 'settings') modelRow.insertBefore(runtimeRow, modelActions); else panel.insertBefore(runtimeRow, settings); settings.classList.toggle('agent-panel-settings-hidden', view !== 'settings'); chat.el.hidden = view !== 'chat'; diff --git a/styles/assistant.css b/styles/assistant.css index b5e85b54e..60c8317ec 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -540,13 +540,14 @@ r-dropdown[role='listbox'] { border-color: transparent; } .agent-panel .cui-messages { - padding: 28px 22px; + padding: 24px 16px; gap: var(--ran-space-4); line-height: 1.75; } .agent-panel .cui-empty { width: 100%; - max-width: 320px; + max-width: none; + margin: auto 0; box-sizing: border-box; padding: 20px 0; font-size: 20px; @@ -620,7 +621,7 @@ r-dropdown[role='listbox'] { flex-basis: 44px; } .agent-panel .cui-messages { - padding: 24px 20px; + padding: 24px 16px; } .agent-panel .cui-footer { padding-bottom: max(16px, env(safe-area-inset-bottom)); @@ -651,7 +652,7 @@ r-dropdown[role='listbox'] { padding: 0 16px; } .agent-panel .cui-footer { - padding: 8px 12px; + padding: 8px 16px; padding-bottom: max(8px, env(safe-area-inset-bottom)); } .agent-panel .cui-composer { @@ -705,7 +706,7 @@ r-dropdown[role='listbox'] { .agent-panel .agent-runtime-row r-button::part(button) { box-shadow: none; } -.agent-device-section > .agent-runtime-row { +.agent-device-section .agent-runtime-row { padding: var(--ran-space-3) 0; } @media (max-height: 600px) { @@ -762,7 +763,7 @@ r-dropdown[role='listbox'] { box-sizing: border-box; min-height: 56px; flex: 0 0 auto; - padding: 8px 20px; + padding: 8px 16px; gap: 2px; } .agent-panel-title { @@ -779,7 +780,7 @@ r-dropdown[role='listbox'] { overflow: auto; flex: 1 1 auto; max-height: none; - padding: var(--ran-space-6); + padding: 16px; gap: var(--ran-space-4); border-bottom: 0; scrollbar-width: thin; @@ -945,7 +946,7 @@ r-dropdown[role='listbox'] { } .agent-panel .cui-footer { border-top: 0; - padding: 12px 20px 18px; + padding: 12px 16px 16px; } .agent-panel .cui-composer { border-color: var(--ran-color-border); @@ -980,7 +981,7 @@ r-dropdown[role='listbox'] { } @media (max-width: 480px) { .agent-panel-header { - padding: 8px 14px; + padding: 8px 16px; } .agent-panel-header button { width: 34px; @@ -989,7 +990,7 @@ r-dropdown[role='listbox'] { } .agent-panel-settings, .agent-history-view { - padding: 20px; + padding: 16px; } .agent-panel-title { font-size: 14px; @@ -1070,21 +1071,24 @@ r-dropdown[role='listbox'] { width: 100%; min-width: 0; --ran-input-border-radius: 8px; - --ran-input-min-height: 40px; - --ran-input-content-min-height: 38px; + --ran-input-min-height: 32px; + --ran-input-content-min-height: 30px; --ran-input-content-font-size: 13px; --ran-input-content-padding: 0 12px; --ran-select-border-radius: 8px; - --ran-select-height: 40px; + --ran-select-height: 32px; --ran-select-font-size: 13px; } +.agent-panel-settings r-button { + --ran-btn-padding: 0; +} .agent-panel-settings r-button::part(content) { box-sizing: border-box; - min-height: 40px; + min-height: 32px; display: flex; align-items: center; - font-size: 13px; - padding: 9px 14px; + font-size: 12px; + padding: 5px 10px; } .agent-panel-load::part(content), .agent-panel-endpoint-connect::part(content) { @@ -1190,7 +1194,7 @@ r-dropdown[role='listbox'] { } .agent-release-memory { align-self: flex-start; - margin-top: var(--ran-space-2); + margin-top: 0; } .agent-preferences { color: var(--ran-color-text-secondary); @@ -1482,3 +1486,10 @@ r-dropdown[role='listbox'] { font-weight: 500; margin: 0 0 var(--ran-space-3); } + +.agent-model-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--ran-space-2); +} diff --git a/test/e2e/agent-sidebar-layout.spec.ts b/test/e2e/agent-sidebar-layout.spec.ts new file mode 100644 index 000000000..e38acca7a --- /dev/null +++ b/test/e2e/agent-sidebar-layout.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from './lib/l0'; + +for (const width of [320, 1280]) { + test(`assistant content aligns and model actions stay compact at ${width}px`, async ({ page }) => { + test.setTimeout(90_000); + await page.setViewportSize({ width, height: 900 }); + await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.waitForFunction(() => typeof window.__toggleAgentPanel === 'function'); + await page.evaluate(() => window.__toggleAgentPanel?.()); + await expect(page.locator('.cui-empty')).toBeVisible(); + const alignment = await page.locator('.agent-runtime-panel').evaluate((panel) => { + const left = (selector: string) => panel.querySelector(selector)!.getBoundingClientRect().left; + return [left('.agent-panel-title'), left('.cui-empty'), left('.cui-composer')]; + }); + expect(Math.max(...alignment) - Math.min(...alignment)).toBeLessThanOrEqual(1); + await page.locator('.agent-panel-settings-toggle').click(); + await expect(page.locator('.agent-panel-settings')).toBeVisible(); + const size = await page.locator('.agent-panel-load').evaluate((button) => button.getBoundingClientRect().height); + expect(size).toBeGreaterThanOrEqual(30); + expect(size).toBeLessThanOrEqual(34); + const overflow = await page.locator('.agent-panel-settings').evaluate((el) => el.scrollWidth - el.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + }); +} From 30e03d66469fd06e53edad95fe43d93e82ed6431 Mon Sep 17 00:00:00 2001 From: ZhouHang Date: Tue, 13 Oct 2026 10:02:41 +0800 Subject: [PATCH 2/4] fix(assistant): clarify compact speech input states --- lib/agent-plugin/ui/speech-input.ts | 26 +++++++++--- styles/assistant.css | 59 +++++++++++++++++++++++++--- test/unit/agent-speech-input.test.ts | 15 +++++++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/lib/agent-plugin/ui/speech-input.ts b/lib/agent-plugin/ui/speech-input.ts index d00a56e8a..bc1ff80e3 100644 --- a/lib/agent-plugin/ui/speech-input.ts +++ b/lib/agent-plugin/ui/speech-input.ts @@ -26,8 +26,11 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { const mic = document.createElement('button'); mic.type = 'button'; mic.className = 'agent-speech-mic'; - mic.innerHTML = + const microphoneIcon = ''; + mic.innerHTML = microphoneIcon; + const stopIcon = + ''; const language = document.createElement('r-select') as HTMLElement & { value: string; disabled: boolean }; language.className = 'agent-speech-language'; language.setAttribute('placement', 'top'); @@ -60,8 +63,8 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'agent-speech-cancel'; - feedback.append(status, cancel, hint); - root.append(mic, language); + feedback.append(status, hint); + root.append(mic, language, cancel); chat.actionsEl.append(root); // Keep feedback in normal composer flow; it must never cover the draft or editor. const composer = chat.el.querySelector('.cui-composer'); @@ -69,6 +72,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { else root.append(feedback); let disclosed = false; let stopping = false; + let listening = false; let recognition: Recognition | undefined; let busy = false; let original = ''; @@ -84,6 +88,10 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { finishPromise = undefined; }; const render = () => { + root.dataset.state = !busy ? 'idle' : stopping ? 'finishing' : listening ? 'listening' : 'starting'; + feedback.dataset.state = root.dataset.state; + mic.innerHTML = busy ? stopIcon : microphoneIcon; + mic.classList.toggle('agent-speech-processing', busy && (!listening || stopping)); mic.setAttribute('aria-label', t(busy ? 'agentSpeechStop' : 'agentSpeechInput')); mic.title = mic.getAttribute('aria-label') ?? ''; mic.setAttribute('aria-pressed', String(busy)); @@ -94,7 +102,10 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { language.setAttribute('aria-label', t('agentSpeechLanguage')); language.disabled = busy; hint.textContent = t('agentSpeechPrivacy'); - cancel.textContent = t('agentSpeechCancel'); + cancel.setAttribute('aria-label', t('agentSpeechCancel')); + cancel.title = t('agentSpeechCancel'); + cancel.innerHTML = + ''; }; const close = (discard = false, preserveDraft = false, ready = false) => { clearTimeout(deadline); @@ -108,6 +119,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { if (busy && !preserveDraft) chat.setInput(discard ? original : transcript ? base + transcript : original); busy = false; stopping = false; + listening = false; hint.hidden = true; status.textContent = ''; settleFinish(ready); @@ -141,6 +153,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { if (base && !/\s$/.test(base)) base += ' '; transcript = ''; stopping = false; + listening = false; hint.hidden = disclosed; disclosed = true; const token = ++revision; @@ -153,7 +166,10 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { render(); status.textContent = t('agentSpeechStarting'); instance.onstart = () => { - if (token === revision) status.textContent = t('agentSpeechListening'); + if (token !== revision || stopping) return; + listening = true; + status.textContent = t('agentSpeechListening'); + render(); }; instance.onresult = (event) => { if (token !== revision) return; diff --git a/styles/assistant.css b/styles/assistant.css index 60c8317ec..c3bb8589d 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1280,8 +1280,8 @@ r-dropdown[role='listbox'] { .agent-speech-mic { display: grid; place-items: center; - width: 36px; - height: 36px; + width: 32px; + height: 32px; border: 0; border-radius: 8px; background: transparent; @@ -1294,8 +1294,11 @@ r-dropdown[role='listbox'] { color: var(--ran-color-text); } .agent-speech-cancel { + display: grid; + place-items: center; + width: 32px; min-height: 32px; - padding: 4px 8px; + padding: 0; border: 0; border-radius: 6px; background: transparent; @@ -1312,8 +1315,8 @@ r-dropdown[role='listbox'] { --ran-select-background-color: transparent; --ran-select-border: 1px solid transparent; --ran-select-hover-border: 1px solid transparent; - --ran-select-height: 36px; - --ran-select-min-height: 36px; + --ran-select-height: 32px; + --ran-select-min-height: 32px; --ran-select-font-size: var(--ran-text-label-2); --ran-select-color: var(--ran-color-text-secondary); --ran-select-item-hover-color: var(--ran-color-text); @@ -1493,3 +1496,49 @@ r-dropdown[role='listbox'] { flex-wrap: wrap; gap: var(--ran-space-2); } + +/* Match the voice button's idle, listening and finishing states without + moving the draft or duplicating a Stop action above it. */ +.agent-speech[data-state='listening'] .agent-speech-mic { + color: var(--ran-color-text); + background: var(--ran-color-bg-muted); + animation: agent-speech-pulse 1.4s ease-in-out infinite; +} +.agent-speech-processing svg { + display: none; +} +.agent-speech-processing::before { + content: ''; + width: 14px; + height: 14px; + border: 1.5px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: agent-speech-spin 1s linear infinite; +} +.agent-speech-feedback[data-state='listening'] > [role='status'] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} +.agent-speech-feedback[data-state='listening']:has(p[hidden]) { + padding: 0; +} +@keyframes agent-speech-pulse { + 50% { + opacity: 0.6; + } +} +@keyframes agent-speech-spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .agent-speech[data-state='listening'] .agent-speech-mic, + .agent-speech-processing::before { + animation: none; + } +} diff --git a/test/unit/agent-speech-input.test.ts b/test/unit/agent-speech-input.test.ts index 24097bce8..a569cd744 100644 --- a/test/unit/agent-speech-input.test.ts +++ b/test/unit/agent-speech-input.test.ts @@ -243,3 +243,18 @@ it('editing while an explicit send is finishing cancels that send and preserves expect(chat.getInput()).toBe('User correction'); chat.dispose(); }); + +it('shows distinct microphone states and ignores a late start after requesting stop', () => { + const { el, recognition } = setup(); + const root = el.querySelector('.agent-speech')!; + expect(root.dataset.state).toBe('starting'); + recognition.onstart?.(); + expect(root.dataset.state).toBe('listening'); + root.querySelector('.agent-speech-mic')!.click(); + expect(root.dataset.state).toBe('finishing'); + recognition.onstart?.(); + expect(root.dataset.state).toBe('finishing'); + expect(el.querySelector('[role="status"]')?.textContent).toBe('agentSpeechFinishing'); + recognition.onend?.(); + expect(root.dataset.state).toBe('idle'); +}); From 5475b3720c05e48fb354cf4601b6562621c09f82 Mon Sep 17 00:00:00 2001 From: ZhouHang Date: Tue, 13 Oct 2026 10:03:00 +0800 Subject: [PATCH 3/4] fix(assistant): acknowledge inference cancellation immediately --- packages/agent-core/src/llm/webllm.ts | 14 +++++++----- test/unit/agent-llm-webllm.test.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/agent-core/src/llm/webllm.ts b/packages/agent-core/src/llm/webllm.ts index 4478fbe7d..f0a48d05e 100644 --- a/packages/agent-core/src/llm/webllm.ts +++ b/packages/agent-core/src/llm/webllm.ts @@ -214,10 +214,12 @@ export class WebLLMProvider implements LocalLLMProvider { await engine?.unload?.(); } - private enqueue(operation: () => Promise): Promise { + private enqueue(operation: () => Promise, signal?: AbortSignal): Promise { const request = this.queue.then(operation); this.queue = request.catch(() => undefined); - return request; + // Release the caller on cancellation, but keep the engine queue bound to + // the underlying operation until its interrupt/drain completes. + return waitForSignals(request, [this.lifetime.signal, ...(signal ? [signal] : [])]); } /** @@ -279,7 +281,7 @@ export class WebLLMProvider implements LocalLLMProvider { } async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise { - return this.enqueue(() => this.generate(messages, tools, undefined, signal)); + return this.enqueue(() => this.generate(messages, tools, undefined, signal), signal); } private async generate( @@ -361,7 +363,7 @@ export class WebLLMProvider implements LocalLLMProvider { schema: Record, signal?: AbortSignal, ): Promise { - return this.enqueue(() => this.generate(messages, [], undefined, signal, schema)); + return this.enqueue(() => this.generate(messages, [], undefined, signal, schema), signal); } generateJSONStream( @@ -370,7 +372,7 @@ export class WebLLMProvider implements LocalLLMProvider { onDelta: (text: string) => void, signal?: AbortSignal, ): Promise { - return this.enqueue(() => this.generate(messages, [], onDelta, signal, schema)); + return this.enqueue(() => this.generate(messages, [], onDelta, signal, schema), signal); } async chatStream( @@ -379,7 +381,7 @@ export class WebLLMProvider implements LocalLLMProvider { onDelta: (textDelta: string) => void, signal?: AbortSignal, ): Promise { - return this.enqueue(() => this.generate(messages, tools, onDelta, signal)); + return this.enqueue(() => this.generate(messages, tools, onDelta, signal), signal); } } diff --git a/test/unit/agent-llm-webllm.test.ts b/test/unit/agent-llm-webllm.test.ts index 087e9c712..1fec3f441 100644 --- a/test/unit/agent-llm-webllm.test.ts +++ b/test/unit/agent-llm-webllm.test.ts @@ -462,3 +462,36 @@ it('keeps readiness and does not notify for an ordinary request failure', async expect(provider.isReady()).toBe(true); expect(unavailable).not.toHaveBeenCalled(); }); + +it('reports cancellation immediately while keeping subsequent inference queued until the engine settles', async () => { + let release!: (value: never) => void; + const create = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ) + .mockResolvedValue({ choices: [{ message: { content: 'next' }, finish_reason: 'stop' }] }); + const interruptGenerate = vi.fn(); + const provider = new WebLLMProvider({ engine: { chat: { completions: { create } }, interruptGenerate } }); + const abort = new AbortController(); + let cancelled = false; + const first = provider + .generateJSON([{ role: 'user', content: 'route' }], { type: 'object' }, abort.signal) + .catch((error) => { + expect(error.name).toBe('AbortError'); + cancelled = true; + }); + await vi.waitFor(() => expect(create).toHaveBeenCalledOnce()); + abort.abort(); + await vi.waitFor(() => expect(cancelled).toBe(true), { timeout: 100 }); + expect(interruptGenerate).toHaveBeenCalledOnce(); + const next = provider.chat([{ role: 'user', content: 'next' }], []); + await Promise.resolve(); + expect(create).toHaveBeenCalledOnce(); + release({ choices: [{ message: { content: 'discarded' }, finish_reason: 'stop' }] } as never); + await first; + expect((await next).text).toBe('next'); +}); From 7e912b06b39b92a358ed282b6b557053fd032678 Mon Sep 17 00:00:00 2001 From: ZhouHang Date: Tue, 13 Oct 2026 10:27:20 +0800 Subject: [PATCH 4/4] test(assistant): enter chat before measuring sidebar layout --- test/e2e/agent-sidebar-layout.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/e2e/agent-sidebar-layout.spec.ts b/test/e2e/agent-sidebar-layout.spec.ts index e38acca7a..432ad7d2d 100644 --- a/test/e2e/agent-sidebar-layout.spec.ts +++ b/test/e2e/agent-sidebar-layout.spec.ts @@ -4,11 +4,15 @@ for (const width of [320, 1280]) { test(`assistant content aligns and model actions stay compact at ${width}px`, async ({ page }) => { test.setTimeout(90_000); await page.setViewportSize({ width, height: 900 }); - await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); await page.goto('/editor?new=docx&locale=en'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); - await page.waitForFunction(() => typeof window.__toggleAgentPanel === 'function'); - await page.evaluate(() => window.__toggleAgentPanel?.()); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-onboarding .agent-enable-switch').click(); + const panel = page.locator('.agent-runtime-panel'); + await expect(panel).toHaveAttribute('data-view', 'settings'); + await page.locator('.agent-panel-settings-toggle').click(); + await expect(panel).toHaveAttribute('data-view', 'chat'); + await expect(page.locator('.cui-composer')).toBeVisible(); await expect(page.locator('.cui-empty')).toBeVisible(); const alignment = await page.locator('.agent-runtime-panel').evaluate((panel) => { const left = (selector: string) => panel.querySelector(selector)!.getBoundingClientRect().left;