From 46b3c770990a9bd4604dc41ea961bb68cdd63a31 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 21:08:25 +0800 Subject: [PATCH 1/3] fix(assistant): allow questions after stopping local generation --- CHANGELOG.md | 2 + .../2026-10-14-assistant-stop-resume.md | 47 +++++++ packages/agent-core/src/llm/webllm.ts | 52 +++++++- test/e2e/agent-stop-resume.spec.ts | 98 ++++++++++++++ test/unit/agent-llm-webllm.test.ts | 121 +++++++++++++++--- test/unit/lib/webllm-fixture.ts | 24 ++++ test/unit/local-generation-settings.test.ts | 7 +- test/unit/webllm-stop-resume.test.ts | 60 +++++++++ 8 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 docs/explorations/2026-10-14-assistant-stop-resume.md create mode 100644 test/e2e/agent-stop-resume.spec.ts create mode 100644 test/unit/lib/webllm-fixture.ts create mode 100644 test/unit/webllm-stop-resume.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f5dfe57..90fdc3609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Stopping a browser-local assistant response no longer prevents the next question + from being answered. - Cancelling a save keeps the unsaved-changes warning and recovery status intact. - Exporting another format, such as PDF, preserves the original document file and its ordinary save target, including when the export is cancelled or fails. diff --git a/docs/explorations/2026-10-14-assistant-stop-resume.md b/docs/explorations/2026-10-14-assistant-stop-resume.md new file mode 100644 index 000000000..d927b3ea5 --- /dev/null +++ b/docs/explorations/2026-10-14-assistant-stop-resume.md @@ -0,0 +1,47 @@ +# Asking again after stopping a local assistant + +## Scope + +Repair the WebLLM cancellation-to-next-request path. Preserve conversation +storage, explicit Send, the model queue, schema constraints, tool exchanges and +usage reporting. History and speech presentation are separate changes. + +## Cause and decision + +The installed WebLLM 0.2.85 engine keeps `interruptSignal` after +`interruptGenerate()`. Its non-streaming `chatCompletion()` checks that flag +before calling `_generate()`, which would normally clear it. Consequently, +stopping an answer or routing request makes subsequent non-streaming JSON +routing return empty content. The panel reports a generic request failure. + +Use the SDK streaming entry for every WebLLM inference, including callers that +only need a complete `chat()` or `generateJSON()` result. That entry clears the +interrupt flag before generation. The provider accumulates the response and +keeps consuming the interrupted stream tail to release the worker model lock; +it does not expose internal JSON deltas to chat callers or replay a cancelled +request. This uses public SDK APIs rather than changing its private flag. + +Hermes tool streams supply `index` without `id`, whereas the non-streaming SDK +supplies numeric string IDs. The WebLLM adapter fills missing IDs from the index +and keeps explicit IDs across later argument fragments. Generic OpenAI stream +conversion is unchanged. + +## Evidence + +- `webllm-stop-resume.test.ts` runs the installed SDK's real `chatCompletion`, + `asyncGenerate` and `interruptGenerate`. Only the GPU pipeline boundary is + synthetic. Stopping answer and routing generation is followed by two complete + JSON-routing/chat turns. +- Reverse verification: restoring the original provider makes both cases fail + with `Unexpected end of JSON input`; restoring the fix passes both. +- SDK-shaped dual-tool regression fails before ID adaptation (`['', '']` + instead of `['0', '1']`) and passes afterwards. Explicit IDs remain intact. +- `agent-stop-resume.spec.ts` runs the real Word and PowerPoint editor panels + with controlled loopback responses. Four cases stop during routing or an + answer, preserve the draft, then receive two fresh replies without reload. + Cancelled late content does not appear and no error message is shown. + +These checks validate SDK lifecycle and application interaction separately. +They do not measure physical WebGPU inference, GPU memory loss, actual browser +worker latency or microphone transcription quality. The CPU provider's existing +runtime retirement and explicit reload policy is unchanged. diff --git a/packages/agent-core/src/llm/webllm.ts b/packages/agent-core/src/llm/webllm.ts index f0a48d05e..4984cd9ec 100644 --- a/packages/agent-core/src/llm/webllm.ts +++ b/packages/agent-core/src/llm/webllm.ts @@ -54,7 +54,11 @@ export const DEFAULT_WEBLLM_MODEL = 'Qwen3-4B-q4f16_1-MLC'; /** The slice of the WebLLM engine this provider uses (eases test mocking). */ export interface WebLLMEngine { - chat: { completions: { create(body: Record): Promise } }; + chat: { + completions: { + create(body: Record): Promise>; + }; + }; /** Stop the in-flight generation (so Stop works mid-stream). */ interruptGenerate?(): void | Promise; unload?(): Promise; @@ -318,16 +322,24 @@ export class WebLLMProvider implements LocalLLMProvider { response_format: { type: 'json_object', schema: JSON.stringify(schema) }, } : {}), - ...(onDelta ? { stream: true, stream_options: { include_usage: true } } : {}), + // SDK 0.2.85 keeps its interrupt flag after Stop. Its non-streaming + // entry skips generation before resetting that flag; the streaming + // entry resets it on every run. Collect internal streams even for + // chat/JSON callers that only need the completed response. + stream: true, + stream_options: { include_usage: true }, }), failureSignals, ); const completion = await waitForSignals( - onDelta - ? accumulateOpenAIStream(response as unknown as AsyncIterable, onDelta, signal, { - drainOnAbort: true, - }) - : Promise.resolve(response), + accumulateOpenAIStream( + webLLMStream(response as AsyncIterable), + onDelta ?? (() => {}), + signal, + { + drainOnAbort: true, + }, + ), failureSignals, ); signal?.throwIfAborted(); @@ -385,6 +397,32 @@ export class WebLLMProvider implements LocalLLMProvider { } } +/** Hermes streams carry an index instead of the IDs used by non-streamed SDK calls. */ +async function* webLLMStream(chunks: AsyncIterable): AsyncIterable { + const ids = new Map(); + for await (const chunk of chunks) { + yield { + ...chunk, + choices: chunk.choices?.map((choice) => ({ + ...choice, + ...(choice.delta?.tool_calls + ? { + delta: { + ...choice.delta, + tool_calls: choice.delta.tool_calls.map((call) => { + const index = call.index ?? 0; + const id = call.id ?? ids.get(index) ?? String(index); + ids.set(index, id); + return { ...call, id }; + }), + }, + } + : {}), + })), + }; + } +} + /** Checks actual adapter availability before attempting a large download. */ export async function checkWebGPUSupport(model: string): Promise { const gpu = ( diff --git a/test/e2e/agent-stop-resume.spec.ts b/test/e2e/agent-stop-resume.spec.ts new file mode 100644 index 000000000..de58743dc --- /dev/null +++ b/test/e2e/agent-stop-resume.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from './lib/l0'; + +for (const ext of ['docx', 'pptx'] as const) { + for (const phase of ['routing', 'answer'] as const) { + test(`${ext}: stopping ${phase} lets the user ask again without reloading`, async ({ page }) => { + test.setTimeout(120_000); + await page.addInitScript(() => + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind: 'loopback', + baseUrl: 'http://localhost:11434', + model: 'stop-test', + preference: 'device-first', + localWritingConsent: false, + }), + ), + ); + let held = false; + let release!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + let answers = 0; + await page.route('http://localhost:11434/api/**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); + return; + } + if (route.request().url().endsWith('/tags')) { + await route.fulfill({ json: { models: [{ name: 'stop-test' }] } }); + return; + } + const body = route.request().postDataJSON(); + const routing = !!body.format?.properties?.task; + if (!held && ((phase === 'routing' && routing) || (phase === 'answer' && !routing))) { + held = true; + await hold; + await route.fulfill({ + json: { + done: true, + message: { + role: 'assistant', + content: routing + ? JSON.stringify({ task: 'chat', language: 'en' }) + : 'Cancelled answer must stay hidden', + }, + }, + }); + return; + } + const content = routing ? JSON.stringify({ task: 'chat', language: 'en' }) : `Fresh answer ${++answers}`; + await route.fulfill({ json: { done: true, message: { role: 'assistant', content } } }); + }); + try { + await page.goto(`/editor?new=${ext}`); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.waitForFunction(() => typeof window.__toggleAgentPanel === 'function'); + await page.evaluate(() => window.__toggleAgentPanel?.()); + await page.locator('.agent-enable-switch').click(); + const input = page.locator('.cui-input'); + const send = page.locator('.cui-send'); + await input.fill('Hello'); + await expect(send).toHaveAccessibleName('Reconnect'); + await send.click(); + await expect(send).toHaveAccessibleName('Send'); + await send.click(); + await expect.poll(() => held).toBe(true); + await expect(send).toHaveAccessibleName('Stop'); + await input.fill('Continue'); + await send.click(); + await expect(send).toHaveAccessibleName('Send'); + await expect(input).toHaveValue('Continue'); + await expect(page.getByRole('log')).toContainText('Stopped.'); + release(); + for (let turn = 1; turn <= 2; turn++) { + await input.fill(`Continue ${turn}`); + await expect(send).toBeEnabled(); + await send.click(); + await expect(page.getByRole('log')).toContainText(`Fresh answer ${turn}`); + await expect(send).toHaveAccessibleName('Send'); + } + await expect(page.getByRole('log')).not.toContainText('Cancelled answer must stay hidden'); + await expect(page.locator('.cui-msg-error')).toHaveCount(0); + expect(answers).toBe(2); + } finally { + release(); + } + }); + } +} diff --git a/test/unit/agent-llm-webllm.test.ts b/test/unit/agent-llm-webllm.test.ts index 1fec3f441..36d725332 100644 --- a/test/unit/agent-llm-webllm.test.ts +++ b/test/unit/agent-llm-webllm.test.ts @@ -1,3 +1,4 @@ +import { completionStream } from './lib/webllm-fixture'; import { describe, expect, it, vi } from 'vitest'; import { checkWebGPUSupport, @@ -5,6 +6,7 @@ import { isWebGPUAvailable, WEBLLM_MODELS, WebLLMProvider, + type WebLLMEngine, } from '@ranuts/agent-core/llm/webllm'; import { createProvider, defaultProviderId } from '@ranuts/agent-core/llm/factory'; import { CHAT_ONLY_SYSTEM_PROMPT, DEFAULT_SYSTEM_PROMPT } from '@ranuts/agent-core/llm/prompt'; @@ -33,7 +35,11 @@ describe('WebLLMProvider', () => { }); it('chat() sends the OpenAI-shaped request and parses the result', async () => { - const create = vi.fn().mockResolvedValue({ choices: [{ message: { content: 'done' }, finish_reason: 'stop' }] }); + const create = vi + .fn() + .mockImplementation(async () => + completionStream({ choices: [{ message: { content: 'done' }, finish_reason: 'stop' }] }), + ); const provider = new WebLLMProvider({ model: 'Hermes-3-Llama-3.1-8B-q4f16_1-MLC', engine: { chat: { completions: { create } } }, @@ -55,7 +61,11 @@ describe('WebLLMProvider', () => { }); it('chatOnly drops tools: no tools/tool_choice sent, and a real system prompt is kept', async () => { - const create = vi.fn().mockResolvedValue({ choices: [{ message: { content: 'hi' }, finish_reason: 'stop' }] }); + const create = vi + .fn() + .mockImplementation(async () => + completionStream({ choices: [{ message: { content: 'hi' }, finish_reason: 'stop' }] }), + ); const provider = new WebLLMProvider({ engine: { chat: { completions: { create } } }, chatOnly: true }); await provider.chat( [{ role: 'user', content: 'go' }], @@ -76,7 +86,11 @@ describe('WebLLMProvider', () => { expect(new WebLLMProvider({ chatOnly: true, systemPrompt: 'custom' })['systemPrompt']).toContain('custom'); }); it('adapts legacy tool history for text-only models without mutating saved history', async () => { - const create = vi.fn().mockResolvedValue({ choices: [{ message: { content: 'hi' }, finish_reason: 'stop' }] }); + const create = vi + .fn() + .mockImplementation(async () => + completionStream({ choices: [{ message: { content: 'hi' }, finish_reason: 'stop' }] }), + ); const provider = new WebLLMProvider({ engine: { chat: { completions: { create } } }, chatOnly: true }); const history = [ { role: 'user' as const, content: 'Read selection' }, @@ -104,16 +118,22 @@ describe('WebLLMProvider', () => { }); it.each(WEBLLM_MODELS.map(({ id }) => id))('disables thinking for writing with %s', async (model) => { - const create = vi.fn().mockResolvedValue({ choices: [{ message: { content: 'text' }, finish_reason: 'stop' }] }); + const create = vi + .fn() + .mockImplementation(async () => + completionStream({ choices: [{ message: { content: 'text' }, finish_reason: 'stop' }] }), + ); const provider = new WebLLMProvider({ model, engine: { chat: { completions: { create } } }, chatOnly: true }); await provider.chat([{ role: 'user', content: 'Rewrite this text.' }], []); expect(create.mock.calls[0][0].extra_body).toEqual({ enable_thinking: false }); }); it('uses schema-constrained tool-free generation without the chat advisor prompt', async () => { - const create = vi.fn().mockResolvedValue({ - choices: [{ message: { content: '{"status":"ready","content":"Hello"}' }, finish_reason: 'stop' }], - }); + const create = vi.fn().mockImplementation(async () => + completionStream({ + choices: [{ message: { content: '{"status":"ready","content":"Hello"}' }, finish_reason: 'stop' }], + }), + ); const provider = new WebLLMProvider({ engine: { chat: { completions: { create } } }, chatOnly: true }); const schema = { type: 'object', required: ['status', 'content'] }; await provider.generateJSON([{ role: 'user', content: 'bounded task' }], schema); @@ -191,7 +211,7 @@ describe('local model lifecycle', () => { const provider = new WebLLMProvider({ engineFactory: async () => { if (++attempts === 1) throw new Error('download failed'); - return { chat: { completions: { create: async () => completion } } }; + return { chat: { completions: { create: async () => completionStream(completion) } } }; }, }); await expect(provider.preload()).rejects.toThrow('download failed'); @@ -210,7 +230,7 @@ describe('local model lifecycle', () => { }); it('does not generate if cancelled during initialization', async () => { - let resolveLoad!: (engine: { chat: { completions: { create: () => Promise } } }) => void; + let resolveLoad!: (engine: WebLLMEngine) => void; let generated = false; const provider = new WebLLMProvider({ engineFactory: () => @@ -228,7 +248,7 @@ describe('local model lifecycle', () => { completions: { create: async () => { generated = true; - return completion; + return completionStream(completion); }, }, }, @@ -249,7 +269,7 @@ describe('local model lifecycle', () => { await new Promise((resolve) => { finishFirst = resolve; }); - return completion; + return completionStream(completion); }, }, }, @@ -268,7 +288,7 @@ describe('local model lifecycle', () => { let unloaded = false; const provider = new WebLLMProvider({ engine: { - chat: { completions: { create: async () => completion } }, + chat: { completions: { create: async () => completionStream(completion) } }, unload: async () => { unloaded = true; }, @@ -288,7 +308,7 @@ describe('local model lifecycle', () => { completions: { create: async (request) => { body = request; - return completion; + return completionStream(completion); }, }, }, @@ -347,7 +367,7 @@ it('drains the interrupted worker stream so the next request can acquire its mod const create = vi.fn().mockImplementation(async () => { if (create.mock.calls.length > 1) { if (!released) throw new Error('Worker model lock still held'); - return { choices: [{ message: { content: 'next reply' }, finish_reason: 'stop' }] }; + return completionStream({ choices: [{ message: { content: 'next reply' }, finish_reason: 'stop' }] }); } return (async function* () { yield { choices: [{ delta: { content: 'visible' } }] }; @@ -395,7 +415,7 @@ it.each(['json', 'stream'] as const)( ).rejects.toThrow('agentContextTooLong'); expect(delta).not.toHaveBeenCalled(); create.mockImplementationOnce( - async () => ({ choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }] }) as never, + async () => completionStream({ choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }] }) as never, ); expect((await provider.chat([{ role: 'user', content: 'Short request' }], [])).text).toBe('hello'); expect(provider.isReady()).toBe(true); @@ -473,7 +493,9 @@ it('reports cancellation immediately while keeping subsequent inference queued u release = resolve; }), ) - .mockResolvedValue({ choices: [{ message: { content: 'next' }, finish_reason: 'stop' }] }); + .mockImplementation(async () => + completionStream({ choices: [{ message: { content: 'next' }, finish_reason: 'stop' }] }), + ); const interruptGenerate = vi.fn(); const provider = new WebLLMProvider({ engine: { chat: { completions: { create } }, interruptGenerate } }); const abort = new AbortController(); @@ -491,7 +513,72 @@ it('reports cancellation immediately while keeping subsequent inference queued u const next = provider.chat([{ role: 'user', content: 'next' }], []); await Promise.resolve(); expect(create).toHaveBeenCalledOnce(); - release({ choices: [{ message: { content: 'discarded' }, finish_reason: 'stop' }] } as never); + release(completionStream({ choices: [{ message: { content: 'discarded' }, finish_reason: 'stop' }] }) as never); await first; expect((await next).text).toBe('next'); }); + +it('preserves distinct Hermes tool IDs when the SDK stream supplies only indices', async () => { + const create = vi.fn(async () => + (async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, function: { name: 'first_tool', arguments: '{}' } }, + { index: 1, function: { name: 'second_tool', arguments: '{}' } }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }; + })(), + ); + const provider = new WebLLMProvider({ + model: 'Hermes-3-Llama-3.1-8B-q4f16_1-MLC', + engine: { chat: { completions: { create } } }, + }); + const result = await provider.chat( + [{ role: 'user', content: 'Use both tools' }], + [ + { name: 'first_tool', description: 'First', inputSchema: {} }, + { name: 'second_tool', description: 'Second', inputSchema: {} }, + ], + ); + expect(result.toolCalls.map((call) => call.id)).toEqual(['0', '1']); + expect(result.assistant.content).toEqual([ + { type: 'tool_use', id: '0', name: 'first_tool', input: {} }, + { type: 'tool_use', id: '1', name: 'second_tool', input: {} }, + ]); +}); + +it('keeps explicit streamed tool IDs across later argument-only fragments', async () => { + const create = vi.fn(async () => + (async function* () { + yield { + choices: [ + { + delta: { tool_calls: [{ index: 0, id: 'provided-id', function: { name: 'first_tool', arguments: '{' } }] }, + }, + ], + }; + yield { + choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '}' } }] }, finish_reason: 'tool_calls' }], + }; + })(), + ); + const provider = new WebLLMProvider({ + model: 'Hermes-3-Llama-3.1-8B-q4f16_1-MLC', + engine: { chat: { completions: { create } } }, + }); + expect( + ( + await provider.chat( + [{ role: 'user', content: 'Use a tool' }], + [{ name: 'first_tool', description: 'First', inputSchema: {} }], + ) + ).toolCalls, + ).toEqual([{ id: 'provided-id', name: 'first_tool', input: {} }]); +}); diff --git a/test/unit/lib/webllm-fixture.ts b/test/unit/lib/webllm-fixture.ts new file mode 100644 index 000000000..98e50e77a --- /dev/null +++ b/test/unit/lib/webllm-fixture.ts @@ -0,0 +1,24 @@ +import type { OpenAICompletion, OpenAIStreamChunk } from '@ranuts/agent-core/llm/openai-format'; + +/** SDK-shaped stream for scripted engine tests, including tool fragments and usage. */ +export async function* completionStream(completion: OpenAICompletion): AsyncIterable { + for (const choice of completion.choices) { + yield { + choices: [ + { + delta: { + content: choice.message.content, + tool_calls: choice.message.tool_calls?.map((call, index) => ({ ...call, index })), + }, + finish_reason: choice.finish_reason, + }, + ], + }; + } + if (completion.usage || completion.timings) + yield { + choices: [], + ...(completion.usage ? { usage: completion.usage } : {}), + ...(completion.timings ? { timings: completion.timings } : {}), + }; +} diff --git a/test/unit/local-generation-settings.test.ts b/test/unit/local-generation-settings.test.ts index 8cddc4331..15d6277b9 100644 --- a/test/unit/local-generation-settings.test.ts +++ b/test/unit/local-generation-settings.test.ts @@ -1,10 +1,11 @@ +import { completionStream } from './lib/webllm-fixture'; import { expect, it, vi } from 'vitest'; import { WebLLMProvider } from '../../packages/agent-core/src/llm/webllm'; import { WllamaProvider } from '../../packages/agent-core/src/llm/wllama'; const reply = { choices: [{ message: { content: 'answer' }, finish_reason: 'stop' }] }; it('applies local generation settings to WebLLM without reloading its engine', async () => { - const create = vi.fn(async (_body: Record) => reply); + const create = vi.fn(async (_body: Record) => completionStream(reply)); const provider = new WebLLMProvider({ engine: { chat: { completions: { create } } } }); provider.setGenerationOptions({ systemPrompt: 'Respond briefly.', temperature: 0.2, topP: 0.9, maxTokens: 128 }); await provider.chat([{ role: 'user', content: 'hello' }], []); @@ -58,7 +59,7 @@ it.each([ }); it('keeps schema-constrained writing deterministic for models without a sampling requirement', async () => { - const create = vi.fn(async (_body: Record) => reply); + const create = vi.fn(async (_body: Record) => completionStream(reply)); const provider = new WebLLMProvider({ model: 'Llama-3.2-1B-Instruct-q4f16_1-MLC', engine: { chat: { completions: { create } } }, @@ -73,7 +74,7 @@ it('keeps schema-constrained writing deterministic for models without a sampling }); it.each([undefined, 0.2])('retains Qwen3 sampling for constrained JSON (%s)', async (temperature) => { - const create = vi.fn(async (_body: Record) => reply); + const create = vi.fn(async (_body: Record) => completionStream(reply)); const provider = new WebLLMProvider({ model: 'Qwen3-1.7B-q4f16_1-MLC', engine: { chat: { completions: { create } } }, diff --git a/test/unit/webllm-stop-resume.test.ts b/test/unit/webllm-stop-resume.test.ts new file mode 100644 index 000000000..1579d09aa --- /dev/null +++ b/test/unit/webllm-stop-resume.test.ts @@ -0,0 +1,60 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { WebLLMProvider, type WebLLMEngine } from '@ranuts/agent-core/llm/webllm'; + +// Exercise the installed SDK's real generation/interrupt lifecycle. Only its +// GPU pipeline is synthetic, so this runs without downloading model weights. +const requireSDK = createRequire(resolve('packages/agent-core/package.json')); +const { MLCEngine } = await import(requireSDK.resolve('@mlc-ai/web-llm')); + +it.each(['answer', 'routing'] as const)('can ask again after stopping SDK %s generation', async (phase) => { + const sdk = new MLCEngine(); + let text = ''; + let stopped = false; + const abort = new AbortController(); + let first = true; + const pipeline = { + getMessage: () => text, + stopped: () => stopped, + triggerStop: () => { + stopped = true; + }, + getFinishReason: () => 'stop', + getCurRoundDecodingTotalTokens: () => 1, + getCurRoundPrefillTotalTokens: () => 1, + getCurRoundPrefillTokensPerSec: () => 1, + getCurRoundDecodingTokensPerSec: () => 1, + getCurRoundGrammarInitTotalTime: () => 0, + getCurRoundPrefillTotalTime: () => 1, + getCurRoundDecodingTotalTime: () => 1, + getCurRoundGrammarPerTokenTotalTime: () => 0, + getCurRoundLatencyBreakdown: () => undefined, + }; + // SDK-private state is substituted only at the hardware boundary, never in + // application code. chatCompletion/asyncGenerate/interruptGenerate stay real. + sdk.loadedModelIdToLock.set('test-model', { acquire: async () => {}, release: async () => {} }); + vi.spyOn(sdk, 'getLLMStates').mockReturnValue(['test-model', pipeline, {}]); + vi.spyOn(sdk, 'prefill').mockImplementation(async (...args: unknown[]) => { + const request = args[0] as { response_format?: unknown }; + stopped = false; + text = request.response_format ? '{"task":"chat","language":"en"}' : 'Answer'; + if (phase === 'routing' && first) abort.abort(); + first = false; + }); + vi.spyOn(sdk, 'decode').mockImplementation(async () => { + stopped = true; + }); + const provider = new WebLLMProvider({ engine: sdk as WebLLMEngine }); + await expect( + phase === 'routing' + ? provider.generateJSON([{ role: 'user', content: 'First question' }], { type: 'object' }, abort.signal) + : provider.chatStream([{ role: 'user', content: 'First question' }], [], () => abort.abort(), abort.signal), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(provider.isReady()).toBe(true); + for (let turn = 0; turn < 2; turn++) { + const route = await provider.generateJSON([{ role: 'user', content: 'Continue' }], { type: 'object' }); + expect(JSON.parse(route.text)).toEqual({ task: 'chat', language: 'en' }); + expect((await provider.chat([{ role: 'user', content: 'Continue' }], [])).text).toBe('Answer'); + } +}); From 641226c89edf73fbf60f2325edb3ecc8163ed4c2 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 21:13:27 +0800 Subject: [PATCH 2/3] feat(assistant): replace conversation picker with searchable history --- CHANGELOG.md | 5 + ...26-10-14-assistant-conversation-browser.md | 80 +++++++ lib/agent-plugin/ui/history-controls.ts | 31 ++- lib/agent-plugin/ui/history-list.ts | 120 ++++++++++ lib/agent-plugin/ui/panel.ts | 41 ++-- packages/shared/src/i18n/messages/de.ts | 7 + packages/shared/src/i18n/messages/en.ts | 7 + packages/shared/src/i18n/messages/es.ts | 7 + packages/shared/src/i18n/messages/ja.ts | 7 + packages/shared/src/i18n/messages/ko.ts | 9 +- packages/shared/src/i18n/messages/pt.ts | 7 + packages/shared/src/i18n/messages/zh-CN.ts | 9 +- packages/shared/src/i18n/types.ts | 7 + styles/assistant.css | 207 ++++++++++++++---- test/e2e/agent-history-rendering.spec.ts | 43 +++- test/unit/agent-history-controls.test.ts | 98 +++++++++ test/unit/agent-history-list.test.ts | 68 ++++++ test/unit/agent-panel-loading.test.ts | 66 +++--- test/unit/agent-panel.test.ts | 5 +- test/unit/agent-write-reply.test.ts | 6 +- 20 files changed, 716 insertions(+), 114 deletions(-) create mode 100644 docs/explorations/2026-10-14-assistant-conversation-browser.md create mode 100644 lib/agent-plugin/ui/history-list.ts create mode 100644 test/unit/agent-history-list.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 90fdc3609..dd1d10b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ notes. Entries describe what users experience, not internal refactors. ### Added +- **Find earlier assistant conversations.** Search titles and messages locally, + browse recent chats by date and see the current conversation at a glance. + Compact row actions confirm which conversation will be deleted; history + saving and data tools stay available in a collapsed section. + - **Keep document structure when writing an assistant draft.** Word headings, lists and emphasis remain formatted; long slide content continues on fitted pages without dropping text. Completed changes keep location and Undo guidance. diff --git a/docs/explorations/2026-10-14-assistant-conversation-browser.md b/docs/explorations/2026-10-14-assistant-conversation-browser.md new file mode 100644 index 000000000..60457adcb --- /dev/null +++ b/docs/explorations/2026-10-14-assistant-conversation-browser.md @@ -0,0 +1,80 @@ +# Assistant conversation browsing + +## Task and direction + +The conversation view used a native select followed by a large fieldset of +storage and deletion buttons. Picking an earlier chat was visually secondary +to data administration. The sidebar now gives its main space to finding and +reopening conversations. + +The design uses a restrained list with one shared text spine, local search, +recent date groups, timestamps and a textual current-conversation marker. +Deletion is a compact trailing action, available without hover. Storage tools +are a native disclosure below the list. The existing header continues to own +new-conversation creation and navigation. + +References checked: + +- [ChatGPT search](https://help.openai.com/en/articles/10056348-finding-your-chats-projects-and-files-in-chatgpt) + documents matching conversation titles and messages and reopening a result. +- [Claude conversation management](https://support.claude.com/en/articles/8230524-delete-or-rename-a-conversation) + documents row-scoped management and a second explicit deletion action. + +This borrows the interaction hierarchy and retains this project's browser-local +storage model. It does not add account synchronization, archiving or another +persistence format. + +## Ownership and safeguards + +`history-list.ts` renders the existing conversation store and emits select or +request-delete events. `history-controls.ts` continues to own opt-in, export, +legacy import, restore and deletion confirmation. The panel owns switching, +controller invalidation and draft preservation. Search reads local message +content only; it performs no network requests. + +A row deletion names its target before confirmation, including a non-current +conversation. Cancel returns focus to that row. After deleting the focused row, +focus returns to search. Escape cancels the topmost deletion decision first; +a later Escape returns to chat. Pending deletion cannot be cancelled after its +storage operation has started. Existing storage failure and revision-conflict +handling remain in place. A storage failure automatically opens the data +disclosure so its error remains visible even when a row deletion closes its +confirmation. Chinese and Korean confirmation copy names this conversation, +not the current conversation. + +## Verification + +- Focused Vitest coverage includes the list, data controls, panel, model loading, + writing replies, sessions and all locale tables. +- Regression proof: the old layout failed assertions for the searchable list + and collapsed management disclosure. Temporarily disabling query filtering + made the title/message search test fail (two results instead of one). +- Collapsed-disclosure row deletion tests reproduce both revision conflicts + and storage failures; they failed before error reporting opened the disclosure. + Focus returns to search when the deleted trigger has been removed. +- The row-target confirmation test failed without the target's title. Escape + cancellation failed before the local confirmation key handler was added. +- The real-editor Playwright path restores 400 messages, searches message text, + verifies an empty result, switches both conversations, cancels and confirms + deletion of the inactive conversation, preserves the draft and verifies focus. +- Geometry assertions check that the sidebar title, history heading and list + title share the same leading edge, and that a 360px viewport has no horizontal + overflow. Light, dark and narrow-view screenshots were inspected. +- Lint and workspace/application TypeScript validation pass. + +## Design and accessibility review + +Task, hierarchy and scope: checked against the rendered editor panel and the +select/delete flows. Geometry, tokens and typography: checked with layout +assertions and screenshots; existing ranui tokens provide theme, sizes, spacing +and color. Keyboard, focus, accessible names, empty state and destructive +feedback: covered by the component and editor tests. The current marker is text +as well as a row fill. Pointer targets remain 32px and metadata uses 12px type. + +Storage busy, failure and opt-in states are covered by existing controls tests. +Seven locale tables remain complete; every language was not separately visually +reviewed. Screen-reader output and high browser zoom were not manually audited. +Motion is not introduced. Offline behavior uses the existing local store and +has no new transport. GPUI-native window, action and component APIs do not apply +to this browser UI; the repository's ranui and web interaction conventions take +precedence. diff --git a/lib/agent-plugin/ui/history-controls.ts b/lib/agent-plugin/ui/history-controls.ts index cfee64aad..c4a12fd5d 100644 --- a/lib/agent-plugin/ui/history-controls.ts +++ b/lib/agent-plugin/ui/history-controls.ts @@ -7,6 +7,7 @@ export interface HistoryControlsOptions { store: ReturnType; onRestore(): void; onBeforeRestore?(): void; + onFocusAfterDeletion?(): void; } /** Local-only download. The export contains conversation data, never provider settings. */ @@ -21,9 +22,11 @@ export function downloadConversations(json: string): void { export function createHistoryControls(options: HistoryControlsOptions) { const { store } = options; - const el = document.createElement('fieldset'); + const el = document.createElement('div'); el.className = 'agent-history-options'; - const legend = document.createElement('legend'); + const details = document.createElement('details'); + details.className = 'agent-history-management'; + const legend = document.createElement('summary'); const label = document.createElement('label'); const toggle = document.createElement('input'); toggle.type = 'checkbox'; @@ -54,7 +57,8 @@ export function createHistoryControls(options: HistoryControlsOptions) { const confirm = button('agent-history-confirm'); const cancel = button('agent-history-cancel'); confirmation.append(question, confirm, cancel); - el.append(legend, label, status, actions, confirmation); + details.append(legend, label, status, actions); + el.append(details, confirmation); let busy = 0; let requestedSaving: boolean | undefined; let failure: keyof I18nMessages | undefined; @@ -67,18 +71,22 @@ export function createHistoryControls(options: HistoryControlsOptions) { for (const item of actions.querySelectorAll('button')) item.disabled = busy > 0; confirm.disabled = busy > 0; cancel.disabled = busy > 0; - legend.textContent = t('agentConversations'); + legend.textContent = t('agentHistoryDataControls'); saveText.textContent = t('agentSaveHistory'); restore.textContent = t('agentRestoreHistory'); legacy.textContent = t('agentImportHistory'); exported.textContent = t('agentExportHistory'); remove.textContent = t('agentDeleteConversation'); clear.textContent = t('agentDeleteHistory'); + const target = store.list().find((item) => item.id === deletion); question.textContent = t(deletion === 'all' ? 'agentDeleteHistoryConfirm' : 'agentDeleteConversationConfirm'); + if (target) question.textContent += ` ${target.title || t('agentNewConversation')}`; confirm.textContent = t('agentHistoryConfirmDelete'); cancel.textContent = t('agentPlanCancel'); }; const showError = (error: unknown) => { + // Row actions live outside the disclosure, so failures must reveal its status. + details.open = true; requestedSaving = undefined; failure = typeof error === 'object' && error !== null && 'name' in error && error.name === 'ConversationConflictError' @@ -150,6 +158,7 @@ export function createHistoryControls(options: HistoryControlsOptions) { confirmation.hidden = false; sync(); cancel.focus(); + confirmation.scrollIntoView?.({ block: 'nearest' }); }; remove.addEventListener('click', () => askDeletion(store.activeId, remove)); clear.addEventListener('click', () => askDeletion('all', clear)); @@ -159,6 +168,11 @@ export function createHistoryControls(options: HistoryControlsOptions) { deletionTrigger?.focus(); deletionTrigger = undefined; }); + confirmation.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || event.isComposing) return; + event.stopPropagation(); + if (!busy) cancel.click(); + }); confirm.addEventListener('click', () => { const target = deletion; if (!target) return; @@ -184,8 +198,10 @@ export function createHistoryControls(options: HistoryControlsOptions) { finishDeletion(); }).then(() => { if (restoreFocus && (document.activeElement === document.body || confirmation.contains(document.activeElement))) { - if (confirmation.hidden) trigger?.focus(); - else confirm.focus(); + if (confirmation.hidden) { + if (trigger?.isConnected) trigger.focus(); + else options.onFocusAfterDeletion?.(); + } else confirm.focus(); } if (confirmation.hidden) deletionTrigger = undefined; }); @@ -195,6 +211,9 @@ export function createHistoryControls(options: HistoryControlsOptions) { el, sync, showError, + requestDelete: (id: string, trigger: HTMLButtonElement) => { + if (!busy && store.list().some((item) => item.id === id)) askDeletion(id, trigger); + }, start: async () => { if (localStorageGetItem(SAVE_PREFERENCE) === 'true') { requestedSaving = true; diff --git a/lib/agent-plugin/ui/history-list.ts b/lib/agent-plugin/ui/history-list.ts new file mode 100644 index 000000000..ec4d68094 --- /dev/null +++ b/lib/agent-plugin/ui/history-list.ts @@ -0,0 +1,120 @@ +import { ButtonBuilder, Div, InputBuilder, Span, View } from 'ranui/builder'; +import { getLanguage, t } from '@ranuts/shared/i18n'; +import type { createConversationStore } from './sessions'; + +interface ConversationListOptions { + store: ReturnType; + onSelect(id: string): void; + onDelete(id: string, trigger: HTMLButtonElement): void; +} + +/** Browse and search the existing local conversations without creating a second store. */ +export function createConversationList({ store, onSelect, onDelete }: ConversationListOptions) { + const heading = View('h2').class('agent-history-heading').build(); + const search = InputBuilder().class('agent-history-search').attr('type', 'search').build(); + const searchIcon = Span().attr('aria-hidden', 'true').build(); + searchIcon.innerHTML = + ''; + const searchRow = Div().class('agent-history-search-row').children(searchIcon, search).build(); + const list = View('ul').class('agent-conversation-list').build(); + const empty = View('p').class('agent-history-empty').attr('role', 'status').build(); + const el = Div().class('agent-conversations').children(heading, searchRow, list, empty).build(); + const sync = () => { + heading.textContent = t('agentConversations'); + search.placeholder = t('agentSearchConversations'); + search.setAttribute('aria-label', t('agentSearchConversations')); + list.setAttribute('aria-label', t('agentConversations')); + const focused = list.contains(document.activeElement) ? (document.activeElement as HTMLButtonElement) : null; + const query = search.value.trim().toLocaleLowerCase(); + const sessions = store + .list() + .filter((session) => { + if (!query || session.title.toLocaleLowerCase().includes(query)) return true; + return store + .history(session.id) + .load() + .some((message) => { + const text = + typeof message.content === 'string' + ? message.content + : message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(' '); + return text.toLocaleLowerCase().includes(query); + }); + }) + .sort((a, b) => b.updatedAt - a.updatedAt); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).getTime(); + let previousGroup = ''; + const items = sessions + .map((session) => { + const group = + session.updatedAt >= today + ? 'agentHistoryToday' + : session.updatedAt >= yesterday + ? 'agentHistoryYesterday' + : 'agentHistoryEarlier'; + const row = View('li').class('agent-conversation-row').build(); + const title = Span() + .class('agent-conversation-title') + .text(session.title || t('agentNewConversation')) + .build(); + const date = View('time') + .class('agent-conversation-date') + .attr('datetime', new Date(session.updatedAt).toISOString()) + .text( + new Intl.DateTimeFormat(getLanguage(), { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(session.updatedAt), + ) + .build(); + const metadata = Div().class('agent-conversation-meta').children(date).build(); + const active = session.id === store.activeId; + if (active) + metadata.append(Span().class('agent-conversation-current').text(t('agentCurrentConversation')).build()); + const open = ButtonBuilder() + .class('agent-conversation-open') + .attr('type', 'button') + .attr('data-id', session.id) + .attr('aria-current', active ? 'true' : 'false') + .children(title, metadata) + .on('click', () => onSelect(session.id)) + .build(); + open.title = title.textContent || ''; + const remove = ButtonBuilder() + .class('agent-conversation-delete') + .attr('type', 'button') + .attr('data-id', session.id) + .attr('aria-label', `${t('agentDeleteConversation')}: ${title.textContent}`) + .on('click', () => onDelete(session.id, remove)) + .build(); + remove.title = t('agentDeleteConversation'); + remove.innerHTML = + ''; + row.append(open, remove); + if (group !== previousGroup) { + const label = View('li').class('agent-conversation-group').text(t(group)).build(); + previousGroup = group; + return [label, row]; + } + return [row]; + }) + .flat(); + list.replaceChildren(...items); + empty.hidden = sessions.length > 0; + empty.textContent = t('agentNoConversationsFound'); + if (focused) + Array.from(list.querySelectorAll('button')) + .find((button) => button.dataset.id === focused.dataset.id && button.className === focused.className) + ?.focus(); + }; + search.addEventListener('input', sync); + sync(); + return { el, sync, focus: () => search.focus() }; +} diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index b89a73d21..3c9a26fbe 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -77,6 +77,7 @@ import { import { historyToTurns } from './storage'; import { createConversationStore } from './sessions'; import { createHistoryControls } from './history-controls'; +import { createConversationList } from './history-list'; import { normalizeGenerationOptions } from '@ranuts/agent-core/llm/generation'; import { createSidebarEntry } from './sidebar-entry'; import { mountPanelResize } from './panel-resize'; @@ -253,7 +254,7 @@ export function createAgentPanel( queueMicrotask(() => { if (open) { if (panel.dataset.view === 'settings') settingsHeading.focus(); - else if (panel.dataset.view === 'history') sessionSelect.focus(); + else if (panel.dataset.view === 'history') conversationList?.focus(); else chat.focus(); } }); @@ -837,11 +838,7 @@ export function createAgentPanel( planning?.abort(); planning = null; }; - // Persist the conversation so a reload keeps it (model context + display). - const sessionSelect = document.createElement('select'); - sessionSelect.className = 'agent-session-select'; - const sessionRow = Div().class('agent-session-row').children(sessionSelect, clearBtn).build(); - const sessionBar = Div().class('agent-session-bar').children(sessionRow).build(); + let conversationList: ReturnType | undefined; const syncContext = () => { if (!open || !enabled || document.visibilityState === 'hidden') return; syncPanelContext({ @@ -863,17 +860,7 @@ export function createAgentPanel( ); let historyStorage = conversations.history(); function renderSessions(): void { - sessionSelect.replaceChildren( - ...conversations.list().map((session) => { - const option = document.createElement('option'); - option.value = session.id; - option.textContent = session.title || t('agentNewConversation'); - return option; - }), - ); - sessionSelect.value = conversations.activeId; - sessionSelect.setAttribute('aria-label', t('agentConversations')); - sessionSelect.title = t('agentConversations'); + conversationList?.sync(); } renderSessions(); @@ -2337,12 +2324,9 @@ export function createAgentPanel( speech?.close(); switchConversation(conversations.create().id); }); - sessionSelect.addEventListener('change', () => { - speech?.close(); - switchConversation(sessionSelect.value); - }); historyControls = createHistoryControls({ store: conversations, + onFocusAfterDeletion: () => conversationList?.focus(), onBeforeRestore: () => { conversationRevision++; invalidatePlans(); @@ -2538,10 +2522,18 @@ export function createAgentPanel( const historyView = document.createElement('section'); historyView.className = 'agent-history-view'; historyView.hidden = true; - historyView.append(sessionBar, historyControls.el); + conversationList = createConversationList({ + store: conversations, + onSelect: (id) => { + speech?.close(); + switchConversation(id); + showView('chat'); + }, + onDelete: (id, trigger) => historyControls?.requestDelete(id, trigger), + }); + historyView.append(conversationList.el, historyControls.el); header.insertBefore(historyBtn, settingsBtn); header.insertBefore(clearBtn, settingsBtn); - sessionRow.replaceChildren(sessionSelect); chat.el.append(writeDestination); function showView(view: 'chat' | 'settings' | 'history'): void { @@ -2558,7 +2550,7 @@ export function createAgentPanel( historyBtn.setAttribute('aria-expanded', String(view === 'history')); if (!open) return; if (view === 'settings') settingsHeading.focus(); - else if (view === 'history') sessionSelect.focus(); + else if (view === 'history') conversationList?.focus(); else chat.focus(); } historyBtn.addEventListener('click', () => showView(panel.dataset.view === 'history' ? 'chat' : 'history')); @@ -2588,7 +2580,6 @@ export function createAgentPanel( syncProductLabels(); window.addEventListener('languagechange', syncProductLabels); clearBtn.addEventListener('click', () => showView('chat')); - sessionSelect.addEventListener('change', () => showView('chat')); clearBtn.title = t('agentNewConversation'); clearBtn.setAttribute('aria-label', t('agentNewConversation')); panel.append(header, runtimeRow, settings, historyView, chat.el); diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index 26f2d2f8f..b32d36208 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -335,6 +335,13 @@ export const de: Partial = { agentDeleteConversationConfirm: 'Diese Unterhaltung löschen? Dies kann nicht rückgängig gemacht werden.', agentHistoryConfirmDelete: 'Löschen', agentConversations: 'Unterhaltungen', + agentSearchConversations: 'Unterhaltungen suchen', + agentCurrentConversation: 'Aktuell', + agentHistoryToday: 'Heute', + agentHistoryYesterday: 'Gestern', + agentHistoryEarlier: 'Früher', + agentNoConversationsFound: 'Keine passenden Unterhaltungen', + agentHistoryDataControls: 'Verlauf und Daten', agentContextTrimmed: 'Ältere Beiträge wurden für diese Anfrage ausgelassen. Der Verlauf bleibt erhalten.', agentContextTooLong: 'Die Anfrage ist zu lang. Kürzen Sie den ausgewählten Abschnitt oder Ihre eigenen Systemanweisungen, oder teilen Sie die Aufgabe auf.', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index 4031ad156..afd610e0f 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -316,6 +316,13 @@ export const en: I18nMessages = { agentDeleteConversationConfirm: 'Delete this conversation? This cannot be undone.', agentHistoryConfirmDelete: 'Delete', agentConversations: 'Conversations', + agentSearchConversations: 'Search conversations', + agentCurrentConversation: 'Current', + agentHistoryToday: 'Today', + agentHistoryYesterday: 'Yesterday', + agentHistoryEarlier: 'Earlier', + agentNoConversationsFound: 'No matching conversations', + agentHistoryDataControls: 'History & data', agentContextTrimmed: 'Earlier turns were omitted from this request. Full history remains available.', agentContextTooLong: 'This request is too long. Shorten the selected passage or your custom system prompt, or split the task.', diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index 72addd458..11d0437bf 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -325,6 +325,13 @@ export const es: Partial = { agentDeleteConversationConfirm: '¿Eliminar esta conversación? Esta acción no se puede deshacer.', agentHistoryConfirmDelete: 'Eliminar', agentConversations: 'Conversaciones', + agentSearchConversations: 'Buscar conversaciones', + agentCurrentConversation: 'Actual', + agentHistoryToday: 'Hoy', + agentHistoryYesterday: 'Ayer', + agentHistoryEarlier: 'Anteriores', + agentNoConversationsFound: 'No se encontraron conversaciones', + agentHistoryDataControls: 'Historial y datos', agentContextTrimmed: 'Se omitieron turnos anteriores en esta solicitud. El historial completo se conserva.', agentContextTooLong: 'La solicitud es demasiado larga. Acorta el fragmento seleccionado o tus instrucciones de sistema personalizadas, o divide la tarea.', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index 946363cc0..f162f7865 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -321,6 +321,13 @@ export const ja: Partial = { agentDeleteConversationConfirm: 'この会話を削除しますか?元に戻せません。', agentHistoryConfirmDelete: '削除', agentConversations: '会話履歴', + agentSearchConversations: '会話を検索', + agentCurrentConversation: '現在の会話', + agentHistoryToday: '今日', + agentHistoryYesterday: '昨日', + agentHistoryEarlier: 'それ以前', + agentNoConversationsFound: '一致する会話がありません', + agentHistoryDataControls: '履歴とデータ', agentContextTrimmed: 'このリクエストでは古い会話を省略しました。履歴は保存されています。', agentContextTooLong: 'リクエストが長すぎます。選択した内容やカスタムシステム指示を短くするか、タスクを分割してください。', diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index 52bc03cdf..ff9cfaf12 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -311,9 +311,16 @@ export const ko: Partial = { agentDeleteConversation: '현재 대화 삭제', agentDeleteHistory: '모든 채팅 기록 삭제', agentDeleteHistoryConfirm: '저장된 기록과 현재 페이지의 모든 채팅 기록을 삭제할까요? 되돌릴 수 없습니다.', - agentDeleteConversationConfirm: '현재 대화를 삭제할까요? 되돌릴 수 없습니다.', + agentDeleteConversationConfirm: '이 대화를 삭제할까요? 되돌릴 수 없습니다.', agentHistoryConfirmDelete: '삭제', agentConversations: '대화 기록', + agentSearchConversations: '대화 검색', + agentCurrentConversation: '현재 대화', + agentHistoryToday: '오늘', + agentHistoryYesterday: '어제', + agentHistoryEarlier: '이전', + agentNoConversationsFound: '일치하는 대화가 없습니다', + agentHistoryDataControls: '기록 및 데이터', agentContextTrimmed: '이번 요청에서 이전 대화를 제외했습니다. 전체 기록은 유지됩니다.', agentContextTooLong: '요청이 너무 깁니다. 선택한 내용이나 사용자 지정 시스템 지침을 줄이거나 작업을 나누세요.', agentClear: '대화 지우기', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index 3965b27c5..15405e81a 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -321,6 +321,13 @@ export const pt: Partial = { agentDeleteConversationConfirm: 'Eliminar esta conversa? Esta ação não pode ser anulada.', agentHistoryConfirmDelete: 'Eliminar', agentConversations: 'Conversas', + agentSearchConversations: 'Buscar conversas', + agentCurrentConversation: 'Atual', + agentHistoryToday: 'Hoje', + agentHistoryYesterday: 'Ontem', + agentHistoryEarlier: 'Anteriores', + agentNoConversationsFound: 'Nenhuma conversa encontrada', + agentHistoryDataControls: 'Histórico e dados', agentContextTrimmed: 'Mensagens anteriores foram omitidas nesta solicitação. O histórico completo foi mantido.', agentContextTooLong: 'A solicitação é longa demais. Reduza o trecho selecionado ou suas instruções de sistema personalizadas, ou divida a tarefa.', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index a838c1841..788fa4328 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -300,9 +300,16 @@ export const zhCN: I18nMessages = { agentDeleteConversation: '删除当前对话', agentDeleteHistory: '删除全部聊天记录', agentDeleteHistoryConfirm: '删除已保存和当前页面的全部聊天记录?此操作无法撤销。', - agentDeleteConversationConfirm: '删除当前对话?此操作无法撤销。', + agentDeleteConversationConfirm: '删除此对话?此操作无法撤销。', agentHistoryConfirmDelete: '删除', agentConversations: '历史会话', + agentSearchConversations: '搜索对话', + agentCurrentConversation: '当前对话', + agentHistoryToday: '今天', + agentHistoryYesterday: '昨天', + agentHistoryEarlier: '更早', + agentNoConversationsFound: '没有找到匹配的对话', + agentHistoryDataControls: '历史与数据', agentContextTrimmed: '本次请求已省略较早的对话,完整记录仍保留。', agentContextTooLong: '当前请求过长,请缩短所选内容或自定义系统指令,也可以拆分任务。', agentClear: '清空对话', diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index 620c09f19..98d4c53ad 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -314,6 +314,13 @@ export interface I18nMessages { agentHistoryConfirmDelete: string; agentConversations: string; + agentSearchConversations: string; + agentCurrentConversation: string; + agentHistoryToday: string; + agentHistoryYesterday: string; + agentHistoryEarlier: string; + agentNoConversationsFound: string; + agentHistoryDataControls: string; agentContextTrimmed: string; agentContextTooLong: string; agentClear: string; diff --git a/styles/assistant.css b/styles/assistant.css index c3bb8589d..a1342bcec 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -48,11 +48,6 @@ body.agent-panel-resizing #app iframe { .agent-panel-hidden { display: none; } -.agent-session-row { - display: flex; - align-items: center; - gap: var(--ran-space-2); -} .agent-document-scope { display: inline-flex; max-width: 100%; @@ -70,7 +65,6 @@ body.agent-panel-resizing #app iframe { .agent-document-scope:empty { display: none; } -.agent-session-select:focus-visible, .agent-panel-clear:focus-visible { outline: 2px solid var(--ran-blue-700); outline-offset: 2px; @@ -111,15 +105,33 @@ body.agent-panel-resizing #app iframe { .agent-history-options { min-width: 0; margin: 0; - padding: var(--ran-space-3); - border: 1px solid var(--ran-color-border); - border-radius: var(--ran-radius-md); + padding-top: var(--ran-space-4); + border-top: 1px solid var(--ran-color-border-secondary); font-size: var(--ran-text-label-2); line-height: 1.5; } -.agent-history-options legend { - padding: 0 var(--ran-space-1); +.agent-history-management > summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--ran-space-2) 0; color: var(--ran-color-text-secondary); + font-weight: var(--ran-text-label-weight); + cursor: pointer; + list-style: none; +} +.agent-history-management > summary::-webkit-details-marker { + display: none; +} +.agent-history-management > summary::after { + content: '+'; + font-size: var(--ran-text-copy-1); +} +.agent-history-management[open] > summary::after { + content: '−'; +} +.agent-history-management label { + margin-top: var(--ran-space-3); } .agent-history-options label { display: flex; @@ -147,10 +159,10 @@ body.agent-panel-resizing #app iframe { flex-basis: 100%; } .agent-history-options button { - min-height: var(--ran-size-7); + min-height: var(--ran-size-6); max-width: 100%; padding: var(--ran-space-1) var(--ran-space-2); - border: 1px solid var(--ran-color-border); + border: 1px solid var(--ran-color-border-secondary); border-radius: var(--ran-radius-sm); background: var(--ran-color-bg-elevated); color: var(--ran-color-text); @@ -520,25 +532,6 @@ r-dropdown[role='listbox'] { border-left: 2px solid var(--ran-color-primary); line-height: 1.55; } -.agent-session-bar { - border-bottom: 1px solid var(--ran-color-border-secondary); - padding: 0 20px 14px; -} -.agent-session-select { - flex: 1 1 auto; - min-width: 0; - width: 100%; - color: inherit; - border: 1px solid var(--ran-color-border); - border-radius: var(--ran-radius-sm); - padding: var(--ran-space-2); - font: inherit; - font-size: var(--ran-text-label-2); - text-overflow: ellipsis; - min-height: 38px; - background: var(--ran-color-bg-muted); - border-color: transparent; -} .agent-panel .cui-messages { padding: 24px 16px; gap: var(--ran-space-4); @@ -637,9 +630,6 @@ r-dropdown[role='listbox'] { .agent-model-status { margin-bottom: 6px; } - .agent-session-bar { - padding-bottom: 6px; - } .agent-panel-settings { box-sizing: border-box; max-height: min(35%, max(32px, calc(100dvh - 280px))); @@ -862,10 +852,7 @@ r-dropdown[role='listbox'] { flex: 1; min-height: 0; overflow: auto; - padding: var(--ran-space-6); -} -.agent-history-view .agent-session-bar { - padding: 12px 0; + padding: var(--ran-space-6) var(--ran-space-4); } .agent-welcome { padding: 40px 28px; @@ -1542,3 +1529,145 @@ r-dropdown[role='listbox'] { animation: none; } } + +/* Conversation browsing shares one text spine; data management stays secondary. */ +.agent-conversations { + min-width: 0; + padding-bottom: var(--ran-space-4); +} +.agent-history-heading { + margin: 0 0 var(--ran-space-4); + font-size: var(--ran-text-heading-4); + font-weight: var(--ran-text-heading-weight); + letter-spacing: var(--ran-text-heading-tracking); +} +.agent-history-search-row { + display: flex; + align-items: center; + gap: var(--ran-space-2); + padding: var(--ran-space-2) var(--ran-space-3); + border: 1px solid var(--ran-color-border); + border-radius: var(--ran-radius-sm); + color: var(--ran-color-text-secondary); + background: var(--ran-color-bg); +} +.agent-history-search-row > span { + display: flex; + flex-shrink: 0; +} +.agent-history-search { + width: 100%; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--ran-color-text); + font: inherit; + font-size: var(--ran-text-label-1); + line-height: var(--ran-line-height); +} +.agent-history-search:focus { + outline: none; +} +.agent-history-search-row:focus-within { + box-shadow: var(--ran-focus-ring); +} +.agent-conversation-list { + margin: var(--ran-space-4) 0 0; + padding: 0; + list-style: none; +} +.agent-conversation-group { + margin: var(--ran-space-4) 0 var(--ran-space-2); + color: var(--ran-color-text-secondary); + font-size: var(--ran-text-label-3); + font-weight: var(--ran-text-label-weight); +} +.agent-conversation-row { + display: flex; + align-items: center; + min-width: 0; + margin-block: var(--ran-space-1); + margin-inline: calc(-1 * var(--ran-space-3)); + border-radius: var(--ran-radius-sm); +} +.agent-conversation-row:has([aria-current='true']) { + background: var(--ran-color-bg-hover); +} +.agent-conversation-row:hover { + background: var(--ran-color-bg-active); +} +.agent-conversation-open { + display: flex; + flex: 1; + flex-direction: column; + align-items: stretch; + gap: var(--ran-space-1); + min-width: 0; + padding: var(--ran-space-3); + border: 0; + border-radius: var(--ran-radius-sm); + color: var(--ran-color-text); + background: transparent; + font: inherit; + text-align: start; + cursor: pointer; +} +.agent-conversation-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--ran-text-label-1); + font-weight: var(--ran-text-label-weight); +} +.agent-conversation-meta { + display: flex; + flex-wrap: wrap; + gap: var(--ran-space-2); + font-size: var(--ran-text-label-3); + color: var(--ran-color-text-secondary); +} +.agent-conversation-current { + font-weight: var(--ran-text-label-weight); +} +.agent-conversation-delete { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--ran-size-7); + height: var(--ran-size-7); + margin-inline-end: var(--ran-space-2); + padding: 0; + border: 0; + border-radius: var(--ran-radius-sm); + color: var(--ran-color-text-secondary); + background: transparent; + cursor: pointer; +} +.agent-conversation-delete:hover { + color: var(--ran-color-danger); + background: var(--ran-color-bg-active); +} +.agent-conversation-open:focus-visible, +.agent-conversation-delete:focus-visible, +.agent-history-management > summary:focus-visible { + outline: 2px solid var(--ran-color-link); + outline-offset: 2px; +} +.agent-history-empty { + margin: var(--ran-space-8) 0; + text-align: center; + color: var(--ran-color-text-secondary); + font-size: var(--ran-text-label-1); +} +.agent-history-empty[hidden] { + display: none; +} +.agent-history-options .agent-history-delete-all, +.agent-history-options .agent-history-confirm { + color: var(--ran-color-danger); +} +.agent-history-options button:hover:not(:disabled) { + background: var(--ran-color-bg-hover); +} diff --git a/test/e2e/agent-history-rendering.spec.ts b/test/e2e/agent-history-rendering.spec.ts index 0f2a67127..1b8468628 100644 --- a/test/e2e/agent-history-rendering.spec.ts +++ b/test/e2e/agent-history-rendering.spec.ts @@ -65,13 +65,52 @@ test('restores a long saved conversation and switches sessions with completed an ) .toBeLessThanOrEqual(2); await page.locator('.agent-history-toggle').click(); - await page.locator('.agent-session-select').selectOption(ids.short); + const search = page.getByRole('searchbox', { name: 'Search conversations' }); + await search.fill('Short answer'); + await expect(page.locator('.agent-conversation-open')).toHaveCount(1); + await search.fill('No matching request'); + await expect(page.locator('.agent-history-empty')).toBeVisible(); + await search.fill(''); + await expect(page.locator('.agent-history-management')).not.toHaveAttribute('open', ''); + await page.locator(`.agent-conversation-open[data-id="${ids.short}"]`).click(); await expect(rows).toHaveCount(2); await expect(rows.last().locator('.cui-bubble')).toHaveText('Short answer'); await page.locator('.agent-history-toggle').click(); - await page.locator('.agent-session-select').selectOption(ids.long); + await page.locator(`.agent-conversation-open[data-id="${ids.long}"]`).click(); await expect(rows).toHaveCount(400); await expect(rows.last()).toHaveAttribute('data-source', '**Answer 199**'); await page.locator('.cui-input').fill('Next request'); await expect(page.locator('.cui-input')).toHaveValue('Next request'); + await page.locator('.agent-history-toggle').click(); + const history = page.locator('.agent-history-view'); + const titleBox = await history.locator('.agent-history-heading').boundingBox(); + const headerBox = await page.locator('.agent-runtime-panel .agent-panel-title').boundingBox(); + expect(Math.abs(titleBox!.x - headerBox!.x)).toBeLessThanOrEqual(1); + const rowBox = await history.locator('.agent-conversation-title').first().boundingBox(); + expect(Math.abs(titleBox!.x - rowBox!.x)).toBeLessThanOrEqual(1); + await page.locator('.agent-runtime-panel').screenshot({ path: test.info().outputPath('history-light.png') }); + await page.emulateMedia({ colorScheme: 'dark' }); + await page.locator('.agent-runtime-panel').screenshot({ path: test.info().outputPath('history-dark.png') }); + await page.emulateMedia({ colorScheme: 'light' }); + await page.setViewportSize({ width: 360, height: 640 }); + await expect.poll(() => history.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.locator('.agent-runtime-panel').screenshot({ path: test.info().outputPath('history-narrow.png') }); + await page.setViewportSize({ width: 1280, height: 900 }); + const inactiveDelete = page.locator(`.agent-conversation-delete[data-id="${ids.short}"]`); + await inactiveDelete.click(); + await expect(page.locator('.agent-history-confirmation')).toBeVisible(); + await expect(page.locator('.agent-history-cancel')).toBeFocused(); + await expect(page.locator('.agent-history-confirmation')).toContainText('Short history'); + await page.keyboard.press('Escape'); + await expect(history).toBeVisible(); + await expect(inactiveDelete).toBeFocused(); + await expect(page.locator('.agent-conversation-open')).toHaveCount(2); + await inactiveDelete.click(); + await page.locator('.agent-history-confirm').click(); + await expect(page.locator('.agent-conversation-open')).toHaveCount(1); + await expect(search).toBeFocused(); + await expect(page.locator('.agent-conversation-open')).toHaveAttribute('data-id', ids.long); + await page.keyboard.press('Escape'); + await expect(rows).toHaveCount(400); + await expect(page.locator('.cui-input')).toHaveValue('Next request'); }); diff --git a/test/unit/agent-history-controls.test.ts b/test/unit/agent-history-controls.test.ts index fd71c1fb3..3d90dabba 100644 --- a/test/unit/agent-history-controls.test.ts +++ b/test/unit/agent-history-controls.test.ts @@ -2,6 +2,7 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { createConversationStore } from '../../lib/agent-plugin/ui/sessions'; import { createHistoryControls } from '../../lib/agent-plugin/ui/history-controls'; +import { ConversationConflictError } from '../../lib/agent-plugin/ui/conversation-db'; import { t } from '@ranuts/shared/i18n'; beforeEach(async () => { @@ -208,3 +209,100 @@ it('keeps keyboard focus on retry when deleting all history fails', async () => expect(document.activeElement).toBe(confirm); expect(store.history().load()[0].content).toBe('preserve on failure'); }); + +it('keeps data management collapsed while leaving conversation browsing prominent', () => { + const controls = createHistoryControls({ store: createConversationStore(), onRestore: vi.fn() }); + const disclosure = controls.el.querySelector('details'); + expect(disclosure).not.toBeNull(); + expect(disclosure!.open).toBe(false); + expect(disclosure!.querySelector('.agent-history-export')).not.toBeNull(); +}); + +it('names the requested row before deleting it and preserves the current conversation', async () => { + const store = createConversationStore(); + const target = store.activeId; + store.history().save([{ role: 'user', content: 'Quarterly review' }]); + store.create(); + store.history().save([{ role: 'user', content: 'Current work' }]); + const current = store.activeId; + const controls = createHistoryControls({ store, onRestore: vi.fn() }); + const trigger = document.createElement('button'); + document.body.append(controls.el, trigger); + controls.requestDelete(target, trigger); + expect(controls.el.querySelector('.agent-history-confirmation')?.textContent).toContain('Quarterly review'); + controls.el.querySelector('.agent-history-confirm')!.click(); + await vi.waitFor(() => expect(store.list().some((item) => item.id === target)).toBe(false)); + expect(store.activeId).toBe(current); + expect(store.history().load()[0].content).toBe('Current work'); +}); + +it('dismisses the deletion decision with Escape before the history view handles it', () => { + const controls = createHistoryControls({ store: createConversationStore(), onRestore: vi.fn() }); + document.body.append(controls.el); + const trigger = controls.el.querySelector('.agent-history-delete-current')!; + trigger.click(); + const bubbled = vi.fn(); + controls.el.addEventListener('keydown', bubbled); + controls.el + .querySelector('.agent-history-cancel')! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(controls.el.querySelector('.agent-history-confirmation')!.hidden).toBe(true); + expect(document.activeElement).toBe(trigger); + expect(bubbled).not.toHaveBeenCalled(); +}); + +it.each([ + [new ConversationConflictError(), 'agentHistoryConflict'], + [new DOMException('Unavailable', 'UnknownError'), 'agentHistorySaveFailed'], +] as const)('exposes %s after deleting a row while data controls are collapsed', async (error, key) => { + const target = crypto.randomUUID(); + const current = crypto.randomUUID(); + const now = Date.now(); + const store = createConversationStore(undefined, { + repository: { + load: async () => ({ + version: 1, + revision: 1, + activeId: current, + sessions: [ + { + id: target, + title: 'Earlier work', + createdAt: now, + updatedAt: now, + messages: [{ role: 'user', content: 'Earlier work' }], + }, + { + id: current, + title: 'Current work', + createdAt: now, + updatedAt: now, + messages: [{ role: 'user', content: 'Current work' }], + }, + ], + }), + save: async () => { + throw error; + }, + clear: async () => 1, + }, + }); + await store.restoreSaved(); + const trigger = document.createElement('button'); + const search = document.createElement('input'); + const controls = createHistoryControls({ + store, + onRestore: () => trigger.remove(), + onFocusAfterDeletion: () => search.focus(), + }); + document.body.append(search, trigger, controls.el); + const details = controls.el.querySelector('details')!; + expect(details.open).toBe(false); + controls.requestDelete(target, trigger); + controls.el.querySelector('.agent-history-confirm')!.click(); + await vi.waitFor(() => expect(controls.el.querySelector('.agent-history-status')?.textContent).toBe(t(key))); + expect(details.open).toBe(true); + expect(controls.el.querySelector('.agent-history-confirmation')!.hidden).toBe(true); + expect(document.activeElement).toBe(search); + expect(store.activeId).toBe(current); +}); diff --git a/test/unit/agent-history-list.test.ts b/test/unit/agent-history-list.test.ts new file mode 100644 index 000000000..27ea3d1cc --- /dev/null +++ b/test/unit/agent-history-list.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { createConversationStore } from '../../lib/agent-plugin/ui/sessions'; +import { createConversationList } from '../../lib/agent-plugin/ui/history-list'; + +const rows = (el: HTMLElement) => Array.from(el.querySelectorAll('.agent-conversation-open')); +afterEach(() => document.body.replaceChildren()); + +it('searches titles and message content locally and opens the matching conversation', () => { + const store = createConversationStore(); + const original = store.activeId; + store.history().save([ + { role: 'user', content: 'Quarterly plan' }, + { role: 'assistant', content: 'Revenue forecast' }, + ]); + store.create(); + store.history().save([{ role: 'user', content: 'Travel notes' }]); + const select = vi.fn(); + const list = createConversationList({ store, onSelect: select, onDelete: vi.fn() }); + document.body.append(list.el); + const search = list.el.querySelector('input')!; + search.value = ' FORECAST '; + search.dispatchEvent(new Event('input')); + expect(rows(list.el)).toHaveLength(1); + expect(rows(list.el)[0].textContent).toContain('Quarterly plan'); + rows(list.el)[0].click(); + expect(select).toHaveBeenCalledWith(original); + search.value = 'missing phrase'; + search.dispatchEvent(new Event('input')); + expect(rows(list.el)).toHaveLength(0); + expect(list.el.querySelector('.agent-history-empty')?.textContent).toBeTruthy(); + search.value = ''; + search.dispatchEvent(new Event('input')); + expect(rows(list.el)).toHaveLength(2); +}); + +it('marks the current conversation and requests deletion of a different row without changing selection', () => { + const store = createConversationStore(); + const original = store.activeId; + store.history().save([{ role: 'user', content: '' }]); + store.create(); + const remove = vi.fn(); + const select = vi.fn(); + const list = createConversationList({ store, onSelect: select, onDelete: remove }); + expect( + rows(list.el) + .find((row) => row.dataset.id === store.activeId) + ?.getAttribute('aria-current'), + ).toBe('true'); + expect(list.el.querySelector('script')).toBeNull(); + const trigger = Array.from(list.el.querySelectorAll('.agent-conversation-delete')).find( + (row) => row.dataset.id === original, + )!; + trigger.click(); + expect(remove).toHaveBeenCalledWith(original, trigger); + expect(select).not.toHaveBeenCalled(); + expect(store.history(original).load()).toHaveLength(1); +}); + +it('keeps keyboard focus on the matching row when titles refresh', () => { + const store = createConversationStore(); + const list = createConversationList({ store, onSelect: vi.fn(), onDelete: vi.fn() }); + document.body.append(list.el); + rows(list.el)[0].focus(); + store.history().save([{ role: 'user', content: 'Updated title' }]); + list.sync(); + expect(document.activeElement).toBe(rows(list.el)[0]); + expect(document.activeElement?.textContent).toContain('Updated title'); +}); diff --git a/test/unit/agent-panel-loading.test.ts b/test/unit/agent-panel-loading.test.ts index c173ebcc6..fdd3f4844 100644 --- a/test/unit/agent-panel-loading.test.ts +++ b/test/unit/agent-panel-loading.test.ts @@ -490,7 +490,7 @@ it('creates a fresh conversation without disposing or reloading the local model' await vi.waitFor(() => expect(state.preload).toHaveBeenCalledTimes(1)); (panel.querySelector('.agent-panel-clear') as HTMLElement).click(); expect(state.dispose).not.toHaveBeenCalled(); - expect(panel.querySelectorAll('.agent-session-select option').length).toBeGreaterThan(1); + expect(panel.querySelectorAll('.agent-conversation-open').length).toBeGreaterThan(1); expect(state.preload).toHaveBeenCalledTimes(1); }); @@ -1065,12 +1065,12 @@ it('records a writing request and verified outcome without copying selected docu { role: 'assistant', content: t('agentPlanReady'), hostGuidance: 'tool' }, { role: 'assistant', content: t('agentPlanVerified'), hostGuidance: 'tool' }, ]); - const sessions = panel.querySelector('.agent-session-select')!; - const originalSession = sessions.value; + const sessions = () => + panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; + const originalSession = sessions(); panel.querySelector('.agent-panel-clear')!.click(); expect(panel.querySelector('.cui-messages')?.textContent ?? '').not.toContain('Rewrite it more clearly'); - sessions.value = originalSession; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${originalSession}"]`)!.click(); expect(panel.textContent).toContain('Rewrite it more clearly'); expect(panel.textContent).toContain('Private rewritten body'); expect(panel.querySelector('.agent-plan-apply')).toBeNull(); @@ -1127,8 +1127,9 @@ it.each(['resolve', 'reject'] as const)( ); allowBrowserLocalWriting(); const panel = createPreparedPanel(); - const sessions = panel.querySelector('.agent-session-select')!; - const originalSession = sessions.value; + const sessions = () => + panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; + const originalSession = sessions(); const task = panel.querySelector('.agent-writing-task')!; task.value = 'rewrite'; task.dispatchEvent(new Event('change')); @@ -1146,8 +1147,7 @@ it.each(['resolve', 'reject'] as const)( expect(panel.querySelector('.cui-messages')?.textContent).not.toContain('Rewrite this old request'); expect(panel.querySelector('.cui-msg-error')).toBeNull(); expect(input.disabled).toBe(false); - sessions.value = originalSession; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${originalSession}"]`)!.click(); expect(panel.textContent).toContain('Rewrite this old request'); expect(panel.querySelector(settlement === 'resolve' ? '.cui-msg-status' : '.cui-msg-error')?.textContent).toContain( t(settlement === 'resolve' ? 'agentStopped' : 'agentWritingUnchanged'), @@ -1168,8 +1168,9 @@ it.each(['rewrite', 'tools'])('retains a late %s failure when returning before i else state.toolPlan.mockReturnValue(pending); allowBrowserLocalWriting(); const panel = createPreparedPanel(); - const sessions = panel.querySelector('.agent-session-select')!; - const original = sessions.value; + const sessions = () => + panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; + const original = sessions(); const task = panel.querySelector('.agent-writing-task')!; task.value = mode; task.dispatchEvent(new Event('change')); @@ -1178,8 +1179,7 @@ it.each(['rewrite', 'tools'])('retains a late %s failure when returning before i input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await vi.waitFor(() => expect(mode === 'rewrite' ? state.writing : state.toolPlan).toHaveBeenCalledOnce()); panel.querySelector('.agent-panel-clear')!.click(); - sessions.value = original; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${original}"]`)!.click(); task.value = 'chat'; task.dispatchEvent(new Event('change')); input.value = 'Subsequent message'; @@ -1191,8 +1191,7 @@ it.each(['rewrite', 'tools'])('retains a late %s failure when returning before i input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await vi.waitFor(() => expect(input.closest('.cui-root')!.querySelector('.cui-send-stop')).toBeNull()); panel.querySelector('.agent-panel-clear')!.click(); - sessions.value = original; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${original}"]`)!.click(); expect(panel.querySelector('.cui-messages')?.textContent).toContain( mode === 'rewrite' ? 'Rewrite pending original request' : 'Create a slide from this request', ); @@ -1205,8 +1204,9 @@ it('restores a failed document operation with its error in the original conversa state.ready = true; state.toolPlan.mockRejectedValue(new Error('agentToolNotChosen')); const panel = createPreparedPanel(); - const sessions = panel.querySelector('.agent-session-select')!; - const original = sessions.value; + const sessions = () => + panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; + const original = sessions(); const mode = panel.querySelector('.agent-writing-task')!; mode.value = 'tools'; mode.dispatchEvent(new Event('change')); @@ -1215,8 +1215,7 @@ it('restores a failed document operation with its error in the original conversa input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await vi.waitFor(() => expect(input.closest('.cui-root')!.querySelector('.cui-send-stop')).toBeNull()); panel.querySelector('.agent-panel-clear')!.click(); - sessions.value = original; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${original}"]`)!.click(); expect(panel.querySelector('.cui-messages')?.textContent).toContain('Read cell A1'); expect(panel.querySelector('.cui-msg-error')?.textContent).toContain(t('agentToolNotChosen')); expect(state.apply).not.toHaveBeenCalled(); @@ -1235,11 +1234,11 @@ it('shows a clear result when the current document has no readable text', async input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await vi.waitFor(() => expect(input.closest('.cui-root')!.querySelector('.cui-send-stop')).toBeNull()); expect(panel.querySelector('.cui-activity')?.textContent).toContain('No readable text was found.'); - const sessions = panel.querySelector('.agent-session-select')!; - const original = sessions.value; + const sessions = () => + panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; + const original = sessions(); panel.querySelector('.agent-panel-clear')!.click(); - sessions.value = original; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${original}"]`)!.click(); expect(panel.querySelector('.cui-activity')?.textContent).toContain('No readable text was found.'); expect(panel.querySelector('.cui-msg-error')).toBeNull(); }); @@ -1558,12 +1557,13 @@ it.each(['stop', 'new conversation', 'switch conversation'] as const)( }), ); const panel = createPreparedPanel(); - const sessions = panel.querySelector('.agent-session-select')!; - const originalSession = sessions.value; + const sessions = () => + panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; + const originalSession = sessions(); if (interruption === 'switch conversation') { panel.querySelector('.agent-panel-clear')!.click(); } - const operationSession = sessions.value; + const operationSession = sessions(); const mode = panel.querySelector('.agent-writing-task')!; mode.value = 'tools'; mode.dispatchEvent(new Event('change')); @@ -1577,8 +1577,7 @@ it.each(['stop', 'new conversation', 'switch conversation'] as const)( if (interruption === 'stop') panel.querySelector('.cui-send-stop')!.click(); else if (interruption === 'new conversation') panel.querySelector('.agent-panel-clear')!.click(); else { - sessions.value = originalSession; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${originalSession}"]`)!.click(); } expect(signal.aborted).toBe(true); // Simulate a read that finishes despite cancellation. The second action must still be suppressed. @@ -1590,8 +1589,7 @@ it.each(['stop', 'new conversation', 'switch conversation'] as const)( expect(panel.textContent).not.toContain(t('agentPlanVerified')); if (interruption !== 'stop') { expect(panel.querySelectorAll('.cui-msg-user, .cui-activity, .cui-msg-status')).toHaveLength(0); - sessions.value = operationSession; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${operationSession}"]`)!.click(); } expect(input.disabled).toBe(false); expect(panel.textContent).toContain(request); @@ -1635,8 +1633,9 @@ it('retains the completed read and verification error without reporting sequence .mockResolvedValueOnce('sent') .mockRejectedValueOnce(new Error('The change could not be verified. Check the document and use Undo if needed.')); const panel = createPreparedPanel(); - const sessions = panel.querySelector('.agent-session-select')!; - const originalSession = sessions.value; + const sessions = () => + panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; + const originalSession = sessions(); const mode = panel.querySelector('.agent-writing-task')!; mode.value = 'tools'; mode.dispatchEvent(new Event('change')); @@ -1658,8 +1657,7 @@ it('retains the completed read and verification error without reporting sequence check(); panel.querySelector('.agent-panel-clear')!.click(); expect(panel.querySelector('.cui-msg-error')).toBeNull(); - sessions.value = originalSession; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${originalSession}"]`)!.click(); check(); }); diff --git a/test/unit/agent-panel.test.ts b/test/unit/agent-panel.test.ts index 0195895f8..a80f84524 100644 --- a/test/unit/agent-panel.test.ts +++ b/test/unit/agent-panel.test.ts @@ -145,8 +145,9 @@ it('offers only browser-local backends and keeps remote credentials out of the p it('groups conversation switching and creation ahead of the transcript', () => { const panel = createAgentPanel(); - const bar = panel.querySelector('.agent-session-bar')!; - expect(bar.querySelector('.agent-session-select')).not.toBeNull(); + const bar = panel.querySelector('.agent-conversations')!; + expect(bar.querySelector('.agent-history-search')).not.toBeNull(); + expect(bar.querySelector('.agent-conversation-open[aria-current="true"]')).not.toBeNull(); expect(bar.closest('.agent-history-view')?.hasAttribute('hidden')).toBe(true); expect(panel.querySelector('.agent-panel-header .agent-panel-clear')).not.toBeNull(); panel.querySelector('.agent-history-toggle')!.click(); diff --git a/test/unit/agent-write-reply.test.ts b/test/unit/agent-write-reply.test.ts index bbad37c6e..8c895e535 100644 --- a/test/unit/agent-write-reply.test.ts +++ b/test/unit/agent-write-reply.test.ts @@ -68,15 +68,13 @@ it('keeps cancellation feedback in the originating conversation after switching input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await vi.waitFor(() => expect(finish).toBeTypeOf('function')); - const original = (panel.querySelector('.agent-session-select') as HTMLSelectElement).value; + const original = panel.querySelector('.agent-conversation-open[aria-current="true"]')!.dataset.id!; (panel.querySelector('.agent-panel-clear') as HTMLElement).click(); finish(); await vi.waitFor(() => expect(state.text).toBe('秋日的天空明净。')); await new Promise((resolve) => setTimeout(resolve, 0)); expect(panel.querySelectorAll('.cui-msg')).toHaveLength(0); - const sessions = panel.querySelector('.agent-session-select')!; - sessions.value = original; - sessions.dispatchEvent(new Event('change')); + panel.querySelector(`.agent-conversation-open[data-id="${original}"]`)!.click(); const exported = await exportHistory(panel); const messages = exported.sessions.find((item: { id: string }) => item.id === original).messages; expect(messages).toHaveLength(3); From 23d999f57b47d10f377837980d5018c03359dcd5 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 21:28:54 +0800 Subject: [PATCH 3/3] feat(assistant): refine voice controls and timed explanations --- CHANGELOG.md | 2 + .../2026-10-14-speech-toolbar-stability.md | 59 +++++++ lib/agent-plugin/ui/speech-input.ts | 148 +++++++++++++++--- styles/assistant.css | 127 ++++++++------- test/e2e/agent-dictation.spec.ts | 121 ++++++++++++-- test/unit/agent-speech-input.test.ts | 103 ++++++++++++ 6 files changed, 473 insertions(+), 87 deletions(-) create mode 100644 docs/explorations/2026-10-14-speech-toolbar-stability.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1d10b12..795b8f10d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,8 @@ notes. Entries describe what users experience, not internal refactors. ### Changed +- Voice controls stay in place while recording or finishing. Explanations appear + without moving the draft, and cancelling preserves the text typed before dictation. - **Configured assistants reopen in chat.** Enable a downloaded model or reconnect your service without losing your draft. Preparation never sends the draft. Speech language choices open directly, and model settings use one consistent diff --git a/docs/explorations/2026-10-14-speech-toolbar-stability.md b/docs/explorations/2026-10-14-speech-toolbar-stability.md new file mode 100644 index 000000000..8de962acb --- /dev/null +++ b/docs/explorations/2026-10-14-speech-toolbar-stability.md @@ -0,0 +1,59 @@ +# Speech toolbar stability + +## Observed failure + +The first microphone click changed the real DOCX composer's height from 134px +(idle) to 180.375px (listening). The privacy paragraph was inserted into the +composer flow, status padding changed between states, and cancellation was +added and removed from the toolbar. The conversation pane consequently shrank, +which explains the large apparent flash. Rendering also replaced the microphone +and cancellation SVG nodes on every state update. + +## Change + +Keep microphone, language, live state, cancellation and privacy in one compact +compose toolbar. Reserve the status and cancellation lanes in idle state, retain +SVG identity, and limit animation to the actual permission/finalization spinner. +Listening has a steady selected stop button. Privacy and full error details use the existing ranui `r-popover` with its +body-level portal, so opening them does not resize the draft. Passive disclosures +close after three seconds; pointer hover or keyboard focus pauses this timer. +Escape dismisses the disclosure before cancelling dictation. Recording and +finalization status remain visible, and errors remain inline after their popover +closes. The info control can reopen the full current error. + +The interaction follows the compact voice button and processing-state treatment +in [AI Elements Speech Input](https://elements.ai-sdk.dev/components/speech-input). +The application retains its editable interim transcript, recognition-language +selector, cancellation policy and existing ranui tokens. + +## Verification + +- Red-before-green unit regression: status lived outside the toolbar and icons + were remounted. It now preserves toolbar containment and both SVG nodes. +- Red-before-green real DOCX regression: composer height differed by 46.375px + when listening. Starting, listening and finishing now preserve its bounds. +- Real DOCX, XLSX, PPTX and PDF editor E2E: unchanged composer/message-pane + geometry, Stop followed by Send waits for the final transcript, a single + request is sent, cancellation restores a 12-line draft, and disclosure + keyboard opening/dismissal preserves geometry and returns focus. +- DOCX at the minimum 320px panel width: no toolbar overflow or state movement; + reduced-motion preference disables spinner animation. A further failing + assertion caught a truncated listening label at this width: active status now + uses the reserved language lane, and the disabled selector returns on idle. + The full listening label fits without moving microphone or trailing controls. + Persistent error feedback uses the same lane so it does not collapse into a + one-letter fragment. +- Speech unit suite: 25 passing; focused formatting/lint and TypeScript checking + passed. Existing bounded two-second finalization, late-event isolation, + manual-edit preservation and recognition-error tests remain covered. +- Real portal hover testing caught a stacking mismatch: the default dropdown + layer was behind the assistant panel. The speech popover now shares the + existing application select-portal layer; no vendor code was modified. +- Real browser privacy tests cover passive expiry, keyboard-focus retention, + portalled-content hover retention, Escape and outside-click dismissal. Error + text remains accessible and can be reopened after passive popover expiry. + +Browser speech events and the AI endpoint are controlled in E2E. Editor frames, +composer components and browser layout are real. Physical microphone recognition, +external browser speech-service latency and operating-system permission prompts +were not verified by these tests. diff --git a/lib/agent-plugin/ui/speech-input.ts b/lib/agent-plugin/ui/speech-input.ts index bc1ff80e3..d3d187bcf 100644 --- a/lib/agent-plugin/ui/speech-input.ts +++ b/lib/agent-plugin/ui/speech-input.ts @@ -1,3 +1,4 @@ +import 'ranui/popover'; import { getLanguage, t } from '@ranuts/shared/i18n'; import type { ChatView } from '@ranuts/chat-ui'; interface Recognition { @@ -28,9 +29,9 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { mic.className = 'agent-speech-mic'; const microphoneIcon = ''; - mic.innerHTML = microphoneIcon; const stopIcon = ''; + mic.innerHTML = microphoneIcon + stopIcon; const language = document.createElement('r-select') as HTMLElement & { value: string; disabled: boolean }; language.className = 'agent-speech-language'; language.setAttribute('placement', 'top'); @@ -52,7 +53,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { const locale = () => languages.find(([code]) => code.startsWith(getLanguage().split('-')[0]))?.[0] ?? 'en-US'; language.value = locale(); const hint = document.createElement('p'); - hint.hidden = true; + hint.className = 'agent-speech-hint'; const status = document.createElement('span'); status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); @@ -63,14 +64,93 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'agent-speech-cancel'; - feedback.append(status, hint); - root.append(mic, language, cancel); + cancel.innerHTML = + ''; + const privacy = document.createElement('r-popover') as HTMLElement & { popoverContent?: HTMLElement }; + privacy.className = 'agent-speech-privacy'; + privacy.setAttribute('trigger', 'click'); + privacy.setAttribute('placement', 'top-end'); + privacy.tabIndex = -1; + const privacyToggle = document.createElement('button'); + privacyToggle.type = 'button'; + const privacyContent = document.createElement('r-content'); + privacyContent.append(hint); + privacyToggle.innerHTML = + ''; + privacy.append(privacyToggle, privacyContent); + feedback.append(status); + // Every state shares one toolbar; disclosures must not resize the draft. + root.append(mic, language, feedback, cancel, privacy); 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'); - if (composer) composer.insertBefore(feedback, composer.querySelector('.cui-composer-bar')); - else root.append(feedback); let disclosed = false; + let notice = ''; + let popupHovered = false; + let triggerHovered = false; + let disclosureTimer: ReturnType | undefined; + let boundPopup: HTMLElement | undefined; + const isDisclosureFocused = () => + privacy.contains(document.activeElement) || Boolean(boundPopup?.contains(document.activeElement)); + const pauseDisclosure = () => clearTimeout(disclosureTimer); + const scheduleDisclosure = () => { + pauseDisclosure(); + if (!privacy.hasAttribute('open') || triggerHovered || popupHovered || isDisclosureFocused()) return; + disclosureTimer = setTimeout(() => privacy.removeAttribute('open'), 3000); + }; + const bindPopup = () => { + const popup = privacy.popoverContent; + if (!popup || popup === boundPopup) return; + boundPopup = popup; + popup.classList.add('agent-speech-popover'); + popup.addEventListener('pointerenter', () => { + popupHovered = true; + pauseDisclosure(); + }); + popup.addEventListener('pointerleave', () => { + popupHovered = false; + scheduleDisclosure(); + }); + popup.addEventListener('focusin', pauseDisclosure); + popup.addEventListener('focusout', () => queueMicrotask(scheduleDisclosure)); + popup.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + privacy.removeAttribute('open'); + privacyToggle.focus({ preventScroll: true }); + }); + }; + const showDisclosure = () => { + privacy.setAttribute('open', ''); + bindPopup(); + scheduleDisclosure(); + }; + privacy.addEventListener('show', () => { + privacyToggle.setAttribute('aria-expanded', 'true'); + bindPopup(); + scheduleDisclosure(); + }); + privacy.addEventListener('hide', () => { + privacyToggle.setAttribute('aria-expanded', 'false'); + pauseDisclosure(); + popupHovered = false; + }); + privacy.addEventListener('pointerenter', () => { + triggerHovered = true; + pauseDisclosure(); + }); + privacy.addEventListener('pointerleave', () => { + triggerHovered = false; + scheduleDisclosure(); + }); + privacy.addEventListener('focusin', pauseDisclosure); + privacy.addEventListener('focusout', () => queueMicrotask(scheduleDisclosure)); + privacyToggle.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + event.stopPropagation(); + if (privacy.hasAttribute('open')) privacy.removeAttribute('open'); + else showDisclosure(); + }); let stopping = false; let listening = false; let recognition: Recognition | undefined; @@ -90,7 +170,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { const render = () => { root.dataset.state = !busy ? 'idle' : stopping ? 'finishing' : listening ? 'listening' : 'starting'; feedback.dataset.state = root.dataset.state; - mic.innerHTML = busy ? stopIcon : microphoneIcon; + root.dataset.feedback = String(Boolean(status.textContent)); mic.classList.toggle('agent-speech-processing', busy && (!listening || stopping)); mic.setAttribute('aria-label', t(busy ? 'agentSpeechStop' : 'agentSpeechInput')); mic.title = mic.getAttribute('aria-label') ?? ''; @@ -98,14 +178,15 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { chat.refreshSendAvailability(); cancel.hidden = !busy; feedback.hidden = !busy && !status.textContent; + cancel.tabIndex = busy ? 0 : -1; mic.disabled = stopping; language.setAttribute('aria-label', t('agentSpeechLanguage')); language.disabled = busy; - hint.textContent = t('agentSpeechPrivacy'); + hint.textContent = notice || t('agentSpeechPrivacy'); + privacyToggle.setAttribute('aria-label', hint.textContent); + privacyToggle.setAttribute('aria-expanded', String(privacy.hasAttribute('open'))); cancel.setAttribute('aria-label', t('agentSpeechCancel')); cancel.title = t('agentSpeechCancel'); - cancel.innerHTML = - ''; }; const close = (discard = false, preserveDraft = false, ready = false) => { clearTimeout(deadline); @@ -120,7 +201,9 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { busy = false; stopping = false; listening = false; - hint.hidden = true; + pauseDisclosure(); + privacy.removeAttribute('open'); + notice = ''; status.textContent = ''; settleFinish(ready); render(); @@ -152,10 +235,11 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { base = original; if (base && !/\s$/.test(base)) base += ' '; transcript = ''; + notice = ''; stopping = false; listening = false; - hint.hidden = disclosed; - disclosed = true; + pauseDisclosure(); + privacy.removeAttribute('open'); const token = ++revision; const instance = new Constructor(); recognition = instance; @@ -163,8 +247,8 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { instance.continuous = true; instance.interimResults = true; busy = true; - render(); status.textContent = t('agentSpeechStarting'); + render(); instance.onstart = () => { if (token !== revision || stopping) return; listening = true; @@ -191,7 +275,9 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { ? 'agentSpeechDenied' : 'agentSpeechFailed', ); + notice = status.textContent ?? ''; render(); + showDisclosure(); }; instance.onend = () => { if (token !== revision) return; @@ -205,37 +291,50 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { ) { status.textContent = transcript ? '' : t('agentSpeechNoResult'); } - hint.hidden = true; stopping = false; busy = false; recognition = undefined; settleFinish(true); + if (status.textContent) notice = status.textContent; render(); + if (notice) showDisclosure(); chat.focus(); }; try { instance.start(); + if (!disclosed) { + disclosed = true; + showDisclosure(); + } deadline = setTimeout(() => { if (token === revision && busy) { close(); status.textContent = t('agentSpeechFailed'); + notice = status.textContent; render(); + showDisclosure(); } }, 60000); } catch { close(); status.textContent = t('agentSpeechFailed'); + notice = status.textContent; render(); + showDisclosure(); } }; - mic.addEventListener('click', () => { + mic.addEventListener('click', (event) => { + // An initial disclosure must survive the same click's document dismissal. + event.stopPropagation(); if (busy) { void finish(); return; } if (!Constructor) { status.textContent = t('agentSpeechUnsupported'); + notice = status.textContent; render(); + showDisclosure(); return; } begin(); @@ -254,6 +353,19 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { mic.focus(); } }); + privacy.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Escape' && privacy.hasAttribute('open')) { + event.preventDefault(); + event.stopPropagation(); + pauseDisclosure(); + privacy.removeAttribute('open'); + privacyToggle.focus({ preventScroll: true }); + } + }, + { capture: true }, + ); feedback.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !event.defaultPrevented && !event.isComposing && busy) { close(true); diff --git a/styles/assistant.css b/styles/assistant.css index a1342bcec..73aac09d1 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1258,47 +1258,49 @@ r-dropdown[role='listbox'] { width: fit-content; } +/* One fixed voice toolbar across idle, permission, listening and finishing. + Status and cancellation own lanes so no state resizes the composer. */ .agent-speech { position: relative; display: flex; + flex: 1; + min-width: 0; align-items: center; - gap: 2px; + gap: var(--ran-space-1); } -.agent-speech-mic { +.agent-speech-mic, +.agent-speech-cancel, +.agent-speech-privacy button { display: grid; + flex: 0 0 auto; place-items: center; width: 32px; height: 32px; + padding: 0; border: 0; - border-radius: 8px; + border-radius: var(--ran-radius-sm); background: transparent; color: var(--ran-color-text-secondary); cursor: pointer; } .agent-speech-mic:hover, -.agent-speech-mic[aria-pressed='true'] { - background: var(--ran-color-bg-container); +.agent-speech-mic[aria-pressed='true'], +.agent-speech-cancel:hover, +.agent-speech-privacy button:hover, +.agent-speech-privacy[open] button { + background: var(--ran-color-bg-muted); color: var(--ran-color-text); } -.agent-speech-cancel { - display: grid; - place-items: center; - width: 32px; - min-height: 32px; - padding: 0; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--ran-color-text-secondary); - font-size: 12px; - cursor: pointer; +.agent-speech-mic svg:last-child, +.agent-speech-mic[aria-pressed='true'] svg:first-child { + display: none; } -.agent-speech-cancel:hover { - background: var(--ran-color-bg-container); +.agent-speech-mic[aria-pressed='true'] svg:last-child { + display: block; } -/* One compact select; its own ranui dropdown owns focus and keyboard behavior. */ .agent-speech-language { - width: 108px; + flex: 0 0 96px; + width: 96px; --ran-select-background-color: transparent; --ran-select-border: 1px solid transparent; --ran-select-hover-border: 1px solid transparent; @@ -1311,32 +1313,61 @@ r-dropdown[role='listbox'] { } .agent-speech-feedback { display: flex; - flex-wrap: wrap; + flex: 1; + min-width: 0; align-items: center; - gap: 4px 8px; - box-sizing: border-box; - padding-block-start: 8px; + height: 32px; color: var(--ran-color-text-secondary); - font-size: 12px; - line-height: 1.6; + font-size: var(--ran-text-label-3); + line-height: 1.5; } -.agent-speech-feedback p { - flex-basis: 100%; - margin: 0; +.agent-speech-feedback > [role='status'] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* While dictating, the fixed language lane displays the live state instead. + Its disabled selector keeps its bounds, and the trailing controls stay put. */ +.agent-speech[data-feedback='true'] .agent-speech-language { + visibility: hidden; +} +.agent-speech[data-feedback='true'] .agent-speech-feedback > [role='status'] { + position: absolute; + inset-inline-start: calc(var(--ran-size-7) + var(--ran-space-1)); + inset-inline-end: calc(var(--ran-size-7) * 2 + var(--ran-space-1) * 2); + height: var(--ran-size-7); + line-height: var(--ran-size-7); } -.agent-speech [hidden], .agent-speech-feedback[hidden], -.agent-speech-feedback [hidden] { - display: none; +.agent-speech-cancel[hidden] { + display: flex; + visibility: hidden; +} +.agent-speech-privacy { + flex: 0 0 auto; +} +.agent-speech-privacy { + --ran-popover-host-display: block; + --ran-popover-content-padding: var(--ran-space-3); +} +/* Match the existing select portal layer above the assistant panel. */ +.agent-speech-popover { + z-index: 100000; +} +.agent-speech-hint { + box-sizing: border-box; + width: min(256px, calc(100vw - 64px)); + margin: 0; + color: var(--ran-color-text-secondary); + font-size: var(--ran-text-label-3); + line-height: 1.6; } .agent-speech button:disabled { cursor: default; opacity: 0.6; } -.agent-speech button:focus-visible, -.agent-speech-feedback button:focus-visible, -.agent-speech r-select:focus-visible { - outline: 2px solid var(--ran-focus-ring, #1677ff); +.agent-speech :is(button, r-select):focus-visible { + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } @@ -1484,15 +1515,13 @@ r-dropdown[role='listbox'] { 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. */ +/* Listening is an actual state, not simulated microphone activity. */ .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; + display: none !important; } .agent-speech-processing::before { content: ''; @@ -1503,28 +1532,12 @@ r-dropdown[role='listbox'] { 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-dictation.spec.ts b/test/e2e/agent-dictation.spec.ts index 6fdaa9024..be9147e96 100644 --- a/test/e2e/agent-dictation.spec.ts +++ b/test/e2e/agent-dictation.spec.ts @@ -4,7 +4,9 @@ import { buildDocx, toBase64 } from './lib/ooxml'; // Native editors and the real composer run. Browser speech events and an AI // endpoint are controlled so permissions, cancellation and latency are repeatable. for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { - test(`${ext}: dictation stays in composer flow and explicit Send finalizes the transcript`, async ({ page }) => { + test(`${ext}: dictation stays in composer flow and explicit Send finalizes the transcript`, async ({ + page, + }, testInfo) => { test.setTimeout(120_000); let pdf: number[] | undefined; if (ext === 'pdf') { @@ -37,19 +39,13 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { state.SpeechRecognition = class { onstart?: () => void; onend?: () => void; + onerror?: (event: { error: string }) => void; onresult?: (event: unknown) => void; constructor() { state.recognition = this; } - start() { - this.onstart?.(); - } - stop() { - setTimeout(() => { - this.onresult?.({ results: [{ isFinal: true, 0: { transcript: 'Hello from dictation' } }] }); - this.onend?.(); - }, 100); - } + start() {} + stop() {} abort() {} }; }); @@ -87,14 +83,33 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { await send.click(); await expect(send).toHaveAccessibleName('Send'); await input.fill(''); + const composer = page.locator('.cui-composer'); + const idle = await composer.boundingBox(); + const messagesBox = await page.locator('.cui-messages').boundingBox(); await page.locator('.agent-speech-mic').click(); + await expect(page.locator('.agent-speech')).toHaveAttribute('data-state', 'starting'); + expect(await composer.boundingBox()).toEqual(idle); + await page.evaluate(() => { + (window as unknown as { recognition: { onstart: () => void } }).recognition.onstart(); + }); await expect(page.locator('.agent-speech-feedback')).toContainText('Listening'); + if (ext === 'docx') { + await expect(page.locator('r-popover.agent-speech-privacy')).toHaveAttribute('open', ''); + await page.waitForTimeout(3200); + await expect(page.locator('r-popover.agent-speech-privacy')).not.toHaveAttribute('open'); + await expect(page.locator('.agent-speech')).toHaveAttribute('data-state', 'listening'); + } + const listening = await composer.boundingBox(); + expect(listening!.height).toBeCloseTo(idle!.height, 0); + expect(listening!.y).toBeCloseTo(idle!.y, 0); + expect((await page.locator('.cui-messages').boundingBox())!.height).toBeCloseTo(messagesBox!.height, 0); await page.evaluate(() => { const recognition = (window as unknown as { recognition: { onresult: (e: unknown) => void } }).recognition; recognition.onresult({ results: [{ isFinal: false, 0: { transcript: 'Hello' } }] }); }); await expect(input).toHaveValue('Hello'); expect(messages).toBe(0); + if (ext === 'docx') await page.screenshot({ path: testInfo.outputPath('speech-listening.png') }); const layout = await page.locator('.agent-speech-feedback').evaluate((el) => { const box = el.getBoundingClientRect(); const input = document.querySelector('.cui-input')!.getBoundingClientRect(); @@ -107,17 +122,99 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { }); expect(layout).toEqual({ position: 'static', afterInput: true, insideComposer: true }); await expect(send).toBeEnabled(); + const beforeFinish = await composer.boundingBox(); + await page.locator('.agent-speech-mic').click(); + await expect(page.locator('.agent-speech')).toHaveAttribute('data-state', 'finishing'); + const finishing = await composer.boundingBox(); + expect(finishing!.height).toBeCloseTo(beforeFinish!.height, 0); + expect(finishing!.y).toBeCloseTo(beforeFinish!.y, 0); await send.click(); + await page.evaluate(() => { + const recognition = (window as unknown as { recognition: { onresult: (e: unknown) => void; onend: () => void } }) + .recognition; + recognition.onresult({ results: [{ isFinal: true, 0: { transcript: 'Hello from dictation' } }] }); + recognition.onend(); + }); await expect(page.locator('.cui-msg-user')).toHaveText('Hello from dictation'); await expect(page.locator('.cui-msg-agent')).toContainText('Hello!'); expect(messages).toBe(1); await expect(input).toHaveValue(''); await expect(page.locator('.agent-speech-feedback')).toBeHidden(); await expect(page.locator('.agent-speech-mic')).toHaveAttribute('aria-pressed', 'false'); - await input.fill('Keep this draft'); + const longDraft = Array.from({ length: 12 }, (_, i) => `Keep draft line ${i + 1}`).join('\n'); + await input.fill(longDraft); + const disclosure = page.locator('.agent-speech-privacy'); + const privacyToggle = disclosure.locator('button'); + const beforePrivacy = await composer.boundingBox(); + await privacyToggle.focus(); + await privacyToggle.press('Enter'); + await expect(disclosure).toHaveAttribute('open', ''); + expect(await composer.boundingBox()).toEqual(beforePrivacy); + if (ext === 'docx') { + await page.waitForTimeout(3200); + await expect(disclosure).toHaveAttribute('open', ''); + } + await privacyToggle.press('Escape'); + await expect(disclosure).not.toHaveAttribute('open'); + await expect(privacyToggle).toBeFocused(); + if (ext === 'docx') { + await privacyToggle.press('Enter'); + await input.focus(); + await page.locator('.agent-speech-hint').hover(); + await page.waitForTimeout(3200); + await expect(disclosure).toHaveAttribute('open', ''); + await page.mouse.move(0, 0); + await page.waitForTimeout(3200); + await expect(disclosure).not.toHaveAttribute('open'); + expect(await composer.boundingBox()).toEqual(beforePrivacy); + } await page.locator('.agent-speech-mic').click(); await page.locator('.agent-speech-cancel').click(); - await expect(input).toHaveValue('Keep this draft'); + await expect(input).toHaveValue(longDraft); + await expect(page.locator('.agent-speech-mic')).toBeFocused(); + expect(await composer.boundingBox()).toEqual(beforePrivacy); + if (ext === 'docx') await page.screenshot({ path: testInfo.outputPath('speech-idle-long-draft.png') }); expect(messages).toBe(1); + if (ext === 'docx') { + await page.locator('.agent-panel-resizer').press('Home'); + await expect(page.locator('.agent-panel-resizer')).toHaveAttribute('aria-valuenow', '320'); + await page.emulateMedia({ reducedMotion: 'reduce' }); + const compact = await composer.boundingBox(); + await page.locator('.agent-speech-mic').click(); + expect(await composer.boundingBox()).toEqual(compact); + const controls = await page.locator('.agent-speech').evaluate((el) => ({ + overflow: el.scrollWidth > el.clientWidth, + animation: getComputedStyle(el.querySelector('.agent-speech-mic')!, '::before').animationName, + })); + expect(controls).toEqual({ overflow: false, animation: 'none' }); + await page.evaluate(() => { + (window as unknown as { recognition: { onstart: () => void } }).recognition.onstart(); + }); + expect(await composer.boundingBox()).toEqual(compact); + const liveStatus = page.locator('.agent-speech-feedback [role="status"]'); + await expect(liveStatus).toHaveText('Listening…'); + expect(await liveStatus.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); + await expect(page.locator('.agent-speech-language')).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('speech-compact.png') }); + await page.evaluate(() => { + (window as unknown as { recognition: { onerror: (event: { error: string }) => void } }).recognition.onerror({ + error: 'not-allowed', + }); + }); + await expect(liveStatus).toContainText('Microphone permission denied'); + const errorWidth = await liveStatus.evaluate((el) => el.clientWidth); + expect(errorWidth).toBeGreaterThan(100); + await expect(disclosure).not.toHaveAttribute('open', { timeout: 4500 }); + await expect(liveStatus).toContainText('Microphone permission denied'); + await privacyToggle.click(); + await expect(page.locator('.agent-speech-hint')).toContainText('Microphone permission denied'); + await input.click(); + await expect(disclosure).not.toHaveAttribute('open'); + await page.locator('.agent-speech-mic').click(); + await page.locator('.agent-speech-cancel').click(); + await expect(input).toHaveValue(longDraft); + await expect(page.locator('.agent-speech-language')).toBeVisible(); + expect(await composer.boundingBox()).toEqual(compact); + } }); } diff --git a/test/unit/agent-speech-input.test.ts b/test/unit/agent-speech-input.test.ts index a569cd744..2e1efe9a2 100644 --- a/test/unit/agent-speech-input.test.ts +++ b/test/unit/agent-speech-input.test.ts @@ -258,3 +258,106 @@ it('shows distinct microphone states and ignores a late start after requesting s recognition.onend?.(); expect(root.dataset.state).toBe('idle'); }); + +it('keeps one toolbar and stable microphone nodes across every recognition state', () => { + const chat = new ComposerView({ onSend: vi.fn() }); + document.body.append(chat.el); + mountSpeechInput(chat, () => true); + const root = chat.el.querySelector('.agent-speech')!; + const feedback = chat.el.querySelector('.agent-speech-feedback')!; + const mic = root.querySelector('.agent-speech-mic')!; + const icons = Array.from(mic.querySelectorAll('svg')); + expect(feedback.parentElement).toBe(root); + expect(icons).toHaveLength(2); + mic.click(); + FakeRecognition.current.onstart?.(); + expect(Array.from(mic.querySelectorAll('svg'))).toEqual(icons); + mic.click(); + expect(Array.from(mic.querySelectorAll('svg'))).toEqual(icons); + FakeRecognition.current.onend?.(); + expect(Array.from(mic.querySelectorAll('svg'))).toEqual(icons); + chat.dispose(); +}); + +it('dismisses speech privacy with Escape before cancelling active dictation', () => { + const { el, recognition, speech } = setup(); + recognition.onstart?.(); + const privacy = el.querySelector('.agent-speech-privacy')!; + const toggle = privacy.querySelector('button')!; + privacy.setAttribute('open', ''); + toggle.focus(); + toggle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(privacy.hasAttribute('open')).toBe(false); + expect(document.activeElement).toBe(toggle); + expect(speech.isBusy()).toBe(true); + expect(recognition.abort).not.toHaveBeenCalled(); + el.querySelector('.agent-speech-mic')!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + expect(speech.isBusy()).toBe(false); +}); + +it('expires the initial speech disclosure without ending listening', () => { + vi.useFakeTimers(); + try { + const { el, recognition, speech } = setup(); + recognition.onstart?.(); + const privacy = el.querySelector('r-popover.agent-speech-privacy')!; + expect(privacy.hasAttribute('open')).toBe(true); + vi.advanceTimersByTime(2999); + expect(privacy.hasAttribute('open')).toBe(true); + vi.advanceTimersByTime(1); + expect(privacy.hasAttribute('open')).toBe(false); + expect(speech.isBusy()).toBe(true); + expect(recognition.stop).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } +}); +it('keeps the speech disclosure while hovered and grants three seconds after leaving', () => { + vi.useFakeTimers(); + try { + const { el } = setup(); + const privacy = el.querySelector('r-popover.agent-speech-privacy')!; + privacy.dispatchEvent(new Event('pointerenter')); + vi.advanceTimersByTime(10000); + expect(privacy.hasAttribute('open')).toBe(true); + privacy.dispatchEvent(new Event('pointerleave')); + vi.advanceTimersByTime(3000); + expect(privacy.hasAttribute('open')).toBe(false); + } finally { + vi.useRealTimers(); + } +}); +it('keeps the speech disclosure while focused and clears its timeout when closing', () => { + vi.useFakeTimers(); + try { + const { el, speech } = setup(); + const privacy = el.querySelector('r-popover.agent-speech-privacy')!; + privacy.querySelector('button')!.focus(); + vi.advanceTimersByTime(10000); + expect(privacy.hasAttribute('open')).toBe(true); + speech.close(); + vi.advanceTimersByTime(10000); + expect(privacy.hasAttribute('open')).toBe(false); + } finally { + vi.useRealTimers(); + } +}); + +it('expires the error popover but retains the error and a readable detail entry', () => { + vi.useFakeTimers(); + try { + const { el, recognition, draft } = setup(); + recognition.onerror?.({ error: 'not-allowed' }); + const privacy = el.querySelector('r-popover.agent-speech-privacy')!; + expect(privacy.hasAttribute('open')).toBe(true); + vi.advanceTimersByTime(3000); + expect(privacy.hasAttribute('open')).toBe(false); + expect(el.querySelector('[role="status"]')!.textContent).toBe('agentSpeechDenied'); + expect(privacy.querySelector('button')!.getAttribute('aria-label')).toBe('agentSpeechDenied'); + expect(draft()).toBe('Original'); + } finally { + vi.useRealTimers(); + } +});