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');
+});