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/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/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/styles/assistant.css b/styles/assistant.css index b5e85b54e..c3bb8589d 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); @@ -1276,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; @@ -1290,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; @@ -1308,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); @@ -1482,3 +1489,56 @@ 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); +} + +/* 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/e2e/agent-sidebar-layout.spec.ts b/test/e2e/agent-sidebar-layout.spec.ts new file mode 100644 index 000000000..432ad7d2d --- /dev/null +++ b/test/e2e/agent-sidebar-layout.spec.ts @@ -0,0 +1,30 @@ +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.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-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; + 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); + }); +} 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'); +}); 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'); +});