From 2fdae424828f9c75507e508a062517f62a5360b2 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 26 Oct 2026 17:35:31 +0800 Subject: [PATCH 01/15] docs(plan): define reliability and responsiveness improvements --- ...26-10-26-reliability-and-responsiveness.md | 27 +++++++++++++++++++ ...6-reliability-and-responsiveness-design.md | 19 +++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-26-reliability-and-responsiveness.md create mode 100644 docs/superpowers/specs/2026-10-26-reliability-and-responsiveness-design.md diff --git a/docs/superpowers/plans/2026-10-26-reliability-and-responsiveness.md b/docs/superpowers/plans/2026-10-26-reliability-and-responsiveness.md new file mode 100644 index 00000000..cac0a53d --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-reliability-and-responsiveness.md @@ -0,0 +1,27 @@ +# Reliability and Responsiveness Implementation Plan + +> **For agentic workers:** REQUIRED: Use subagent-driven-development. Keep functional commits separate. + +**Goal:** Repair the five reviewed user-facing defects and improve measured responsiveness in one PR. +**Architecture:** Keep save ownership, view state, conversation storage and conversion execution in their existing owners. Add narrowly scoped worker/view helpers where needed. +**Tech Stack:** TypeScript, ranui, Vitest, Playwright, IndexedDB, browser Workers. + +## Chunk 1: Reliability + +- [ ] Save semantics: `lib/onlyoffice/save-stream.ts`, `test/unit/save-stream-lifecycle.test.ts`. Assert exporting PDF retains dirty state before applying a regular-save-only completion guard. Run targeted tests and commit. +- [ ] Open recovery: `lib/ui.ts`, `styles/base.css`, focused unit/E2E tests. Cover hidden/pointer/timer races. Only activate hero layout when present; cancel stale timers and restore input. Commit. +- [ ] History view: `lib/history-page.ts`, focused tests. Keep toolbar stable, reject stale responses, prune on initialization rather than queries; validate keyboard focus and results. Commit. +- [ ] Chat conflicts: `lib/agent-plugin/ui/sessions.ts`, `history-controls.ts`, locales and tests. Explicit preserve-local recovery avoids repeated conflict and silent data loss. Validate two-store race, persisted restart and current conversation ownership. Commit. +- [ ] Speech duration: `lib/agent-plugin/ui/speech-input.ts`, locales and tests. Test healthy results at 59 seconds followed by duration limit. Graceful stop with bounded final-result drain and accurate notice. Commit. + +## Chunk 2: Responsiveness + +- [ ] Spreadsheet worker: `packages/converter/src/spreadsheet.ts`, `document-converter.ts`, new worker/client helper and tests. Read converter rules first. Preserve CSV/HTML/XLSX conversion APIs, source ownership, CSP/static-host/offline behavior. Measure representative input; browser UI responsiveness must improve. Commit independently. +- [ ] Conversation persistence: inspect `conversation-db.ts` and `sessions.ts`. Benchmark 100/1000 conversations; apply safe incremental-processing optimization with invalidation tests. Avoid speculative storage migration. Commit independently if measured benefit justifies change. + +## Chunk 3: Delivery + +- [ ] Independent spec and quality review; resolve findings. +- [ ] Run focused tests then full coverage, lint/types, format and production build. Only one browser/build runner. +- [ ] Real browser acceptance for opening failure, search focus, conversion and assistant flows. Document physical mic/GPU limits. +- [ ] Update changelog and acceptance record. Push functional commits, create and attach one PR. Inspect exact-head checks and repair failures. diff --git a/docs/superpowers/specs/2026-10-26-reliability-and-responsiveness-design.md b/docs/superpowers/specs/2026-10-26-reliability-and-responsiveness-design.md new file mode 100644 index 00000000..31f0e625 --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-reliability-and-responsiveness-design.md @@ -0,0 +1,19 @@ +# Reliability and responsiveness + +Implement the reviewed main-branch findings in one PR with independent functional commits. + +## Behavior + +1. Exporting another format does not mark the editable source saved. Normal saves retain existing version/ownership protections. +2. Failed editor opens restore a visible, focusable file-opening control surface; stale visibility timers cannot hide it again. +3. Document history keeps its toolbar/input mounted across searches, applies only current async results, and avoids pruning on every keystroke. +4. A conflicting chat can explicitly preserve local work as a separate conversation and restore saved history. Preserve revision checks and active request ownership; never silently discard either copy. +5. Healthy dictation ends gracefully at the duration limit. Startup/service failures remain distinct, and stop still has a bounded final-result wait. +6. Move large spreadsheet conversions off the main thread with a bounded worker lifecycle, current-request ownership and unchanged conversion results. Do not introduce a dependency on remote scripts. +7. Optimize history storage processing where safe without a data-format migration. Preserve cross-tab conflict detection and validate external records. + +CPU model restart and offline caching require benchmark-backed changes: retain cancellation teardown and first-offline-export contracts. Record measured limitations rather than promise unverified speedups. + +## Verification + +Regression tests must fail before fixes for the five confirmed bugs. Run relevant unit tests, full type/lint/format/coverage gates, production build and focused real-browser history/open/conversion/assistant acceptance. Run independent specification and code-quality reviews. Publish English PR text and preserve separate commits; do not merge. From 01205745338b9525db133d8b469e59bf5b10b7a2 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 26 Oct 2026 17:37:35 +0800 Subject: [PATCH 02/15] fix(save): keep source edits unsaved after other-format exports --- lib/onlyoffice/save-stream.ts | 3 ++- test/unit/save-stream-lifecycle.test.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/onlyoffice/save-stream.ts b/lib/onlyoffice/save-stream.ts index fe3d66cd..f7524c9d 100644 --- a/lib/onlyoffice/save-stream.ts +++ b/lib/onlyoffice/save-stream.ts @@ -241,7 +241,8 @@ function routeSavedFile(file: File, state: ReturnType { +it('keeps the editable source unsaved after exporting another format', async () => { await rememberActiveSource('save-document', new File(['editable source'], 'Report.docx')); - setDiskWriter(async () => 'written'); + const writer = vi.fn(async () => 'written' as const); + setDiskWriter(writer); await sendFile(true, 0, 'pdf'); - await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + await vi.waitFor(() => expect(writer).toHaveBeenCalledOnce()); await new Promise((resolve) => setTimeout(resolve, 30)); + expect(hasUnsavedChanges()).toBe(true); + expect(history.Reset_SavedIndex).not.toHaveBeenCalled(); expect(await (await readActiveSource('save-document'))?.text()).toBe('editable source'); expect((await getDoc('save-document'))?.savedToDiskAt).toBeUndefined(); + await sendFile(); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + expect(history.Reset_SavedIndex).toHaveBeenCalledWith(true); }); it('publishes save completion only after the saved reload bytes are durable', async () => { From 879b8ad7c12a50be184b8ce968fc5321d7154120 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 26 Oct 2026 17:39:18 +0800 Subject: [PATCH 03/15] fix(editor): restore usable controls after failed document opens --- lib/ui.ts | 14 ++++- test/e2e/entry-paths.spec.ts | 24 +++++++- test/unit/control-panel-recovery.test.ts | 71 ++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 test/unit/control-panel-recovery.test.ts diff --git a/lib/ui.ts b/lib/ui.ts index 0a9ddc6c..6d6cb243 100644 --- a/lib/ui.ts +++ b/lib/ui.ts @@ -20,8 +20,8 @@ let landingHero: HTMLElement | null = null; const getLandingHero = (): HTMLElement | null => (landingHero ??= document.getElementById('landing-hero')); export const showLanding = (): void => { - document.body.classList.add('landing-active'); const hero = getLandingHero(); + document.body.classList.toggle('landing-active', !!hero); if (hero) hero.style.display = ''; }; @@ -37,6 +37,7 @@ export const hideLanding = (): void => { // create* wrapper at boot, so in practice it is built up front; the laziness // just makes call order a non-issue.) let controlPanel: HTMLElement | null = null; +let visibilityTimer: ReturnType | undefined; const getControlPanel = (): HTMLElement => (controlPanel ??= buildControlPanel()); @@ -45,12 +46,14 @@ export const hideControlPanel = (): void => { // Dismiss the crawlable landing hero with the panel. hideLanding(); + clearTimeout(visibilityTimer); const panel = getControlPanel(); + panel.inert = true; // Immediately disable pointer events to prevent blocking panel.style.pointerEvents = 'none'; panel.style.opacity = '0'; // Hide after transition for smooth animation - setTimeout(() => { + visibilityTimer = setTimeout(() => { panel.style.display = 'none'; }, 300); }; @@ -65,10 +68,15 @@ export const showControlPanel = (): void => { // screen (index.ts sets it before the panel is built). document.body.classList.remove('opening-document'); + clearTimeout(visibilityTimer); const panel = getControlPanel(); + const hasHero = !!getLandingHero(); + panel.inert = hasHero; + panel.style.pointerEvents = hasHero ? 'none' : 'auto'; panel.style.display = 'flex'; - setTimeout(() => { + visibilityTimer = setTimeout(() => { panel.style.opacity = '1'; + if (!hasHero) panel.querySelector('#upload-button')?.focus(); }, 10); }; diff --git a/test/e2e/entry-paths.spec.ts b/test/e2e/entry-paths.spec.ts index 51089d6f..059f5b2b 100644 --- a/test/e2e/entry-paths.spec.ts +++ b/test/e2e/entry-paths.spec.ts @@ -28,7 +28,7 @@ test.beforeAll(async () => { res.end(Buffer.from(DOC)); return; } - res.writeHead(404); + res.writeHead(404, { 'Access-Control-Allow-Origin': '*' }); res.end(); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); @@ -61,6 +61,28 @@ test.describe('entry paths (real editor)', () => { expect(ooxmlText((await zipEntryText(bytes, 'word/document.xml')) || '')).toContain('opened from a URL'); }); + test('a failed URL open restores keyboard-accessible local file controls', async ({ page, l0 }) => { + l0.allowConsole(/Error opening document from URL:.*Failed to fetch document: 404/); + const missing = new URL('/missing.docx', docUrl).href; + await page.goto(`/editor?file=${encodeURIComponent(missing)}`); + const upload = page.locator('#upload-button'); + await expect(upload).toBeVisible(); + await expect(upload).toBeFocused(); + // Exercise the real control with the keyboard, then recover in the same page. + const chooser = page.waitForEvent('filechooser'); + await page.keyboard.press('Enter'); + await ( + await chooser + ).setFiles({ + name: 'recovered.docx', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + buffer: Buffer.from(DOC), + }); + await settleEditor(page); + await expect(page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk')).toBeVisible(); + await expect(page.locator('#control-panel-container')).toBeHidden(); + }); + test('embed document:open-url fetches, opens and round-trips the document', async ({ page }) => { await page.goto('/embed-demo.html'); await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); diff --git a/test/unit/control-panel-recovery.test.ts b/test/unit/control-panel-recovery.test.ts new file mode 100644 index 00000000..6fc18a6f --- /dev/null +++ b/test/unit/control-panel-recovery.test.ts @@ -0,0 +1,71 @@ +import 'ranui/button'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/prefetch', () => ({ prefetchOnIntent: vi.fn() })); +vi.mock('../../lib/document', () => ({ onCreateNew: vi.fn(), onOpenDocument: vi.fn() })); +vi.mock('../../lib/loading', () => ({ showLoading: () => ({ removeLoading: vi.fn() }) })); +vi.mock('@ranuts/shared/i18n', () => ({ t: (key: string) => key })); + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + document.body.replaceChildren(); + document.body.className = ''; +}); +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +it('restores the editor opening controls without activating a missing landing hero', async () => { + const ui = await import('../../lib/ui'); + document.body.classList.add('opening-document'); + ui.hideControlPanel(); + vi.advanceTimersByTime(300); + ui.showControlPanel(); + vi.advanceTimersByTime(10); + const panel = document.getElementById('control-panel-container')!; + expect(document.body.classList.contains('landing-active')).toBe(false); + expect(document.body.classList.contains('opening-document')).toBe(false); + expect(panel.style.display).toBe('flex'); + expect(panel.style.opacity).toBe('1'); + expect(panel.inert).toBe(false); + expect(panel.style.pointerEvents).not.toBe('none'); + expect(document.activeElement).toBe(document.getElementById('upload-button')); +}); + +it('does not let an earlier hide timer hide controls after a quick open failure', async () => { + const ui = await import('../../lib/ui'); + ui.hideControlPanel(); + vi.advanceTimersByTime(100); + ui.showControlPanel(); + vi.advanceTimersByTime(300); + expect(document.getElementById('control-panel-container')!.style.display).toBe('flex'); +}); + +it('keeps hidden controls inert and ignores a superseded show transition', async () => { + const ui = await import('../../lib/ui'); + ui.showControlPanel(); + ui.hideControlPanel(); + vi.advanceTimersByTime(20); + const panel = document.getElementById('control-panel-container')!; + expect(panel.style.opacity).toBe('0'); + expect(panel.inert).toBe(true); + vi.advanceTimersByTime(300); + expect(panel.style.display).toBe('none'); +}); + +it('restores a real landing hero without exposing or focusing duplicate controls', async () => { + const hero = document.createElement('main'); + hero.id = 'landing-hero'; + document.body.append(hero); + const ui = await import('../../lib/ui'); + ui.hideControlPanel(); + ui.showControlPanel(); + vi.advanceTimersByTime(400); + expect(document.body.classList.contains('landing-active')).toBe(true); + expect(hero.style.display).toBe(''); + expect(document.getElementById('control-panel-container')!.inert).toBe(true); + expect(document.activeElement).not.toBe(document.getElementById('upload-button')); +}); From a3bda66f44486824abcb810028695883688e3f46 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 26 Oct 2026 17:42:33 +0800 Subject: [PATCH 04/15] fix(speech): finish healthy dictation gracefully at its time limit --- lib/agent-plugin/ui/speech-input.ts | 24 ++++++++++++++++--- packages/shared/src/i18n/messages/de.ts | 1 + packages/shared/src/i18n/messages/en.ts | 1 + packages/shared/src/i18n/messages/es.ts | 1 + packages/shared/src/i18n/messages/ja.ts | 1 + packages/shared/src/i18n/messages/ko.ts | 1 + packages/shared/src/i18n/messages/pt.ts | 1 + packages/shared/src/i18n/messages/zh-CN.ts | 1 + packages/shared/src/i18n/types.ts | 1 + test/unit/agent-speech-input.test.ts | 27 ++++++++++++++++++++++ 10 files changed, 56 insertions(+), 3 deletions(-) diff --git a/lib/agent-plugin/ui/speech-input.ts b/lib/agent-plugin/ui/speech-input.ts index eab5ad6c..53610fda 100644 --- a/lib/agent-plugin/ui/speech-input.ts +++ b/lib/agent-plugin/ui/speech-input.ts @@ -156,6 +156,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { }); let stopping = false; let listening = false; + let limitReached = false; let recognition: Recognition | undefined; let busy = false; let original = ''; @@ -204,6 +205,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { busy = false; stopping = false; listening = false; + limitReached = false; pauseDisclosure(); privacy.removeAttribute('open'); notice = ''; @@ -224,11 +226,21 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { clearTimeout(deadline); // Some services never emit end after stop. Keep the visible transcript and // release the microphone rather than stranding an explicit send indefinitely. - deadline = setTimeout(() => close(false, false, true), 2000); + const finishWithoutEnd = () => { + const limited = limitReached; + close(false, false, true); + if (limited) { + status.textContent = t('agentSpeechLimit'); + notice = status.textContent; + render(); + showDisclosure(); + } + }; + deadline = setTimeout(finishWithoutEnd, 2000); try { recognition?.stop(); } catch { - close(false, false, true); + finishWithoutEnd(); } return pending; }; @@ -241,6 +253,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { notice = ''; stopping = false; listening = false; + limitReached = false; pauseDisclosure(); privacy.removeAttribute('open'); const token = ++revision; @@ -292,7 +305,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { status.textContent === t('agentSpeechListening') || status.textContent === t('agentSpeechFinishing') ) { - status.textContent = transcript ? '' : t('agentSpeechNoResult'); + status.textContent = limitReached ? t('agentSpeechLimit') : transcript ? '' : t('agentSpeechNoResult'); } stopping = false; busy = false; @@ -311,6 +324,11 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) { } deadline = setTimeout(() => { if (token === revision && busy) { + if (listening) { + limitReached = true; + void finish(); + return; + } close(); status.textContent = t('agentSpeechFailed'); notice = status.textContent; diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index 38c6c5aa..64726c0f 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -32,6 +32,7 @@ export const de: Partial = { agentSpeechDenied: 'Mikrofonzugriff verweigert. Bitte in den Browsereinstellungen erlauben.', agentSpeechFailed: 'Erkennung fehlgeschlagen. Mikrofon, Sprache und Verbindung prüfen.', agentSpeechFinishing: 'Erkennung wird beendet…', + agentSpeechLimit: 'Die Diktierzeit ist abgelaufen. Sie können erneut starten.', agentSpeechUnsupported: 'Spracheingabe ist in diesem Browser nicht verfügbar.', agentDownloadIncomplete: 'Download unvollständig', agentModelInUse: 'Wird verwendet', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index 6df0965c..9554e3c9 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -28,6 +28,7 @@ export const en: I18nMessages = { agentSpeechDenied: 'Microphone permission denied. Allow access in browser settings.', agentSpeechFailed: 'Speech recognition failed. Check your microphone, language and connection, then retry.', agentSpeechFinishing: 'Finishing…', + agentSpeechLimit: 'Dictation limit reached. You can start again.', agentSpeechUnsupported: 'Voice input is unavailable in this browser.', agentDownloadIncomplete: 'Incomplete download', agentModelInUse: 'In use', diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index ded8e467..8b6a2805 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -31,6 +31,7 @@ export const es: Partial = { agentSpeechDenied: 'Permiso del micrófono denegado. Actívalo en los ajustes del navegador.', agentSpeechFailed: 'Error de reconocimiento. Comprueba el micrófono, idioma y conexión.', agentSpeechFinishing: 'Finalizando…', + agentSpeechLimit: 'Se alcanzó el límite de dictado. Puedes volver a empezar.', agentSpeechUnsupported: 'Este navegador no admite entrada de voz.', agentDownloadIncomplete: 'Descarga incompleta', agentModelInUse: 'En uso', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index c7fa93d9..97284078 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -31,6 +31,7 @@ export const ja: Partial = { agentSpeechDenied: 'マイクの許可が拒否されました。ブラウザー設定で許可してください。', agentSpeechFailed: '認識に失敗しました。マイク、言語、接続を確認してください。', agentSpeechFinishing: '認識を終了中…', + agentSpeechLimit: '音声入力の制限時間に達しました。もう一度開始できます。', agentSpeechUnsupported: 'このブラウザーでは音声入力を利用できません。', agentDownloadIncomplete: 'ダウンロード未完了', agentModelInUse: '使用中', diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index 7bb10ff5..58965474 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -31,6 +31,7 @@ export const ko: Partial = { agentSpeechDenied: '마이크 권한이 거부되었습니다. 브라우저 설정에서 허용하세요.', agentSpeechFailed: '음성 인식 실패. 마이크, 언어 및 연결을 확인하세요.', agentSpeechFinishing: '인식 완료 중…', + agentSpeechLimit: '음성 입력 제한 시간에 도달했습니다. 다시 시작할 수 있습니다.', agentSpeechUnsupported: '이 브라우저는 음성 입력을 지원하지 않습니다.', agentDownloadIncomplete: '다운로드 미완료', agentModelInUse: '사용 중', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index 166e5920..c400decd 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -30,6 +30,7 @@ export const pt: Partial = { agentSpeechDenied: 'Permissão do microfone negada. Permita o acesso nas configurações do navegador.', agentSpeechFailed: 'Falha no reconhecimento. Verifique microfone, idioma e conexão.', agentSpeechFinishing: 'Finalizando…', + agentSpeechLimit: 'O limite de ditado foi atingido. Pode começar novamente.', agentSpeechUnsupported: 'Este navegador não suporta entrada de voz.', agentDownloadIncomplete: 'Download incompleto', agentModelInUse: 'Em uso', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index 01ae562d..c1d023ab 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -27,6 +27,7 @@ export const zhCN: I18nMessages = { agentSpeechDenied: '麦克风权限被拒绝,请在浏览器设置中允许访问。', agentSpeechFailed: '语音识别失败,请检查麦克风、语言和网络后重试。', agentSpeechFinishing: '正在完成识别…', + agentSpeechLimit: '已达到单次听写时长,可以继续听写。', agentSpeechUnsupported: '当前浏览器不支持语音输入。', agentDownloadIncomplete: '下载未完成', agentModelInUse: '正在使用', diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index 5de8d7f4..48cc8f81 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -26,6 +26,7 @@ export interface I18nMessages { agentSpeechDenied: string; agentSpeechFailed: string; agentSpeechFinishing: string; + agentSpeechLimit: string; agentSpeechUnsupported: string; agentDownloadIncomplete: string; diff --git a/test/unit/agent-speech-input.test.ts b/test/unit/agent-speech-input.test.ts index b2f4bd56..81cfbfa1 100644 --- a/test/unit/agent-speech-input.test.ts +++ b/test/unit/agent-speech-input.test.ts @@ -381,3 +381,30 @@ it('expires the error popover but retains the error and a readable detail entry' vi.useRealTimers(); } }); + +it.each(['end', 'timeout'] as const)('finishes healthy dictation at the duration limit (%s)', (ending) => { + vi.useFakeTimers(); + try { + const { el, recognition, speech, draft } = setup(); + recognition.onstart?.(); + vi.advanceTimersByTime(59000); + recognition.onresult?.({ results: [{ isFinal: false, 0: { transcript: 'Visible words' } }] }); + vi.advanceTimersByTime(1000); + expect(recognition.stop).toHaveBeenCalledOnce(); + expect(recognition.abort).not.toHaveBeenCalled(); + expect(speech.isBusy()).toBe(true); + if (ending === 'end') { + recognition.onresult?.({ results: [{ isFinal: true, 0: { transcript: 'Final words' } }] }); + recognition.onend?.(); + } else vi.advanceTimersByTime(2000); + expect(speech.isBusy()).toBe(false); + expect(draft()).toBe(ending === 'end' ? 'Original Final words' : 'Original Visible words'); + expect(el.querySelector('[role="status"]')!.textContent).toBe('agentSpeechLimit'); + el.querySelector('.agent-speech-mic')!.click(); + expect(speech.isBusy()).toBe(true); + expect(el.querySelector('[role="status"]')!.textContent).toBe('agentSpeechStarting'); + speech.close(); + } finally { + vi.useRealTimers(); + } +}); From 1eec97d6c1ca949c3ba0f94396ccc8c642c6f450 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 26 Oct 2026 17:43:54 +0800 Subject: [PATCH 05/15] fix(history): preserve search focus and ignore stale results --- lib/history-page.ts | 244 ++++++++++++++--------- test/e2e/history-page.spec.ts | 20 ++ test/unit/history-page-rendering.test.ts | 122 ++++++++++++ 3 files changed, 292 insertions(+), 94 deletions(-) create mode 100644 test/unit/history-page-rendering.test.ts diff --git a/lib/history-page.ts b/lib/history-page.ts index 84940096..4fa8927f 100644 --- a/lib/history-page.ts +++ b/lib/history-page.ts @@ -28,6 +28,10 @@ import { daysUntilExpiry, hasUnsavedWork, type HistoryDoc } from './history/type import { confirmDialog } from './confirm-dialog'; const SEARCH_DEBOUNCE_MS = 200; +const EXPIRY_SWEEP_INTERVAL_MS = 60_000; +let renderRevision = 0; +let lastSweepAt = -Infinity; +let sweep: Promise | undefined; let query = ''; let page = 1; @@ -217,9 +221,12 @@ function buildToolbar(refresh: () => void): HTMLElement { .attr('value', query) .on('input', (event: Event) => { const value = (event as CustomEvent<{ value?: string }>).detail?.value ?? ''; + // Invalidate pending reads immediately, not only after the debounce. + renderRevision++; + query = value; + page = 1; window.clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { - query = value; // A new search starts at the top; staying on page 4 of the previous // result set shows an empty page more often than not. page = 1; @@ -293,53 +300,68 @@ function buildAutosaveToggle(refresh: () => void): HTMLElement { .build(); } -async function render(): Promise { - // Sweep before reading: a row the user is shown must not be one the seven-day - // rule already deleted, and this page is the most likely thing to be open - // when that boundary is crossed. - await pruneExpired(); +async function sweepExpired(force: boolean): Promise { + if (sweep) return sweep; + if (!force && Date.now() - lastSweepAt < EXPIRY_SWEEP_INTERVAL_MS) return; + sweep = pruneExpired().then(() => { + lastSweepAt = Date.now(); + }); + try { + await sweep; + } finally { + sweep = undefined; + } +} + +async function render(forceSweep = false): Promise { + const revision = ++renderRevision; + await sweepExpired(forceSweep); + if (revision !== renderRevision) return; const [{ items, total, page: current, pageSize }, usage] = await Promise.all([ listDocs({ query, page, unsavedOnly }), historyUsage(), ]); + if (revision !== renderRevision) return; page = current; syncUrl(); const pages = Math.max(1, Math.ceil(total / pageSize)); const refresh = () => void render(); - const header = Div() - .class('history-header') - .children( - // chip -> title -> intro: the homepage hero's own opening, so the page a - // visitor reaches from it is recognisably the same product rather than - // an admin screen that happens to share a header. - Div() - .class('history-chip') - .children(View('i').class('history-chip-dot').build(), View('span').text(t('historyChip')).build()) - .build(), - Div() - .class('history-title-row') - .children( - View('h1').class('history-title').text(t('historyTitle')).build(), - Div() - .class('history-header-actions') - .children( - button( - t('historyBack'), - () => { - // Back to the homepage the reader came from, not to English. - window.location.href = localeHomePath(getLanguage()); - }, - { type: 'text', class: 'history-back' }, - ), - ) - .build(), - ) - .build(), - View('p').class('history-intro').text(t('historyIntro')).build(), - ) - .build(); + const header = + root().querySelector('.history-header') ?? + Div() + .class('history-header') + .children( + // chip -> title -> intro: the homepage hero's own opening, so the page a + // visitor reaches from it is recognisably the same product rather than + // an admin screen that happens to share a header. + Div() + .class('history-chip') + .children(View('i').class('history-chip-dot').build(), View('span').text(t('historyChip')).build()) + .build(), + Div() + .class('history-title-row') + .children( + View('h1').class('history-title').text(t('historyTitle')).build(), + Div() + .class('history-header-actions') + .children( + button( + t('historyBack'), + () => { + // Back to the homepage the reader came from, not to English. + window.location.href = localeHomePath(getLanguage()); + }, + { type: 'text', class: 'history-back' }, + ), + ) + .build(), + ) + .build(), + View('p').class('history-intro').text(t('historyIntro')).build(), + ) + .build(); const list = items.length ? Div() @@ -368,6 +390,7 @@ async function render(): Promise { ? button( t('historyClearSearch'), () => { + window.clearTimeout(searchTimer); query = ''; unsavedOnly = false; page = 1; @@ -402,66 +425,96 @@ async function render(): Promise { * these are controls, not navigation) -- it falls to the bottom of the * column, which is where it already was. */ - const rail = View('aside') - .class('history-rail') - .children( - // How much is being kept is a fact about the store, not about the - // search, so it sits at the top of the rail rather than beside the - // search box, where it competed with the filter for the same row. - Div() - .class('history-usage') - .text( - total - ? `${t('historyCount', { count: String(total) })} · ${t('historyUsage', { size: formatBytes(usage) })}` - : '', - ) - .build(), - Div() - .class('history-rail-block') - .children( - Div().class('history-rail-label').text(t('historyRailSettings')).build(), - buildAutosaveToggle(refresh), - ...(isAutosaveEnabled() - ? [] - : [Div().class('history-note history-note-warn').text(t('historyAutosaveOff')).build()]), - button( - t('historyClearAll'), - () => { - void confirmDialog({ - title: t('historyClearTitle'), - body: t('historyClearConfirm'), - confirmLabel: t('historyClearAll'), - cancelLabel: t('historyCancel'), - danger: true, - }).then((ok) => (ok ? clearAllHistory().then(refresh) : undefined)); - }, - { native: true, id: 'history-clear-all', class: 'history-clear' }, - ), - ) - .build(), + const rail = + root().querySelector('.history-rail') ?? + View('aside') + .class('history-rail') + .children( + // How much is being kept is a fact about the store, not about the + // search, so it sits at the top of the rail rather than beside the + // search box, where it competed with the filter for the same row. + Div() + .class('history-usage') + .text( + total + ? `${t('historyCount', { count: String(total) })} · ${t('historyUsage', { size: formatBytes(usage) })}` + : '', + ) + .build(), + Div() + .class('history-rail-block') + .children( + Div().class('history-rail-label').text(t('historyRailSettings')).build(), + buildAutosaveToggle(refresh), + ...(isAutosaveEnabled() + ? [] + : [Div().class('history-note history-note-warn').text(t('historyAutosaveOff')).build()]), + button( + t('historyClearAll'), + () => { + void confirmDialog({ + title: t('historyClearTitle'), + body: t('historyClearConfirm'), + confirmLabel: t('historyClearAll'), + cancelLabel: t('historyCancel'), + danger: true, + }).then((ok) => (ok ? clearAllHistory().then(refresh) : undefined)); + }, + { native: true, id: 'history-clear-all', class: 'history-clear' }, + ), + ) + .build(), + Div() + .class('history-rail-block') + .children( + Div().class('history-rail-label').text(t('historyRailRetention')).build(), + Div().class('history-note').text(t('historyRetention')).build(), + Div().class('history-note').text(t('historyNotBackup')).build(), + ) + .build(), + ) + .build(); + + const usageText = total + ? `${t('historyCount', { count: String(total) })} · ${t('historyUsage', { size: formatBytes(usage) })}` + : ''; + rail.querySelector('.history-usage')!.textContent = usageText; + const autosave = rail.querySelector('#history-autosave')!; + autosave.checked = isAutosaveEnabled(); + const warning = rail.querySelector('.history-note-warn'); + if (autosave.checked) warning?.remove(); + else if (!warning) { + rail + .querySelector('#history-clear-all')! + .before(Div().class('history-note history-note-warn').text(t('historyAutosaveOff')).build()); + } + + const existing = root().querySelector('.history-page'); + if (existing) { + existing.querySelector('#history-list, #history-empty')!.replaceWith(list); + existing.querySelector('.history-pager')?.remove(); + if (pages > 1) existing.append(buildPager(page, pages, refresh)); + } else { + root().append( Div() - .class('history-rail-block') + .class('history-shell') .children( - Div().class('history-rail-label').text(t('historyRailRetention')).build(), - Div().class('history-note').text(t('historyRetention')).build(), - Div().class('history-note').text(t('historyNotBackup')).build(), + Div() + .class('history-page') + .children(header, buildToolbar(refresh), list, ...(pages > 1 ? [buildPager(page, pages, refresh)] : [])) + .build(), + rail, ) .build(), - ) - .build(); - - const pageEl = Div() - .class('history-shell') - .children( - Div() - .class('history-page') - .children(header, buildToolbar(refresh), list, ...(pages > 1 ? [buildPager(page, pages, refresh)] : [])) - .build(), - rail, - ) - .build(); - - root().replaceChildren(pageEl); + ); + } + const search = document.getElementById('history-search') as HTMLElement & { value: string }; + // Leave the live input alone while typing: even assigning its current value + // can reset selection in a custom input's shadow root. + if (search.value !== query) search.setAttribute('value', query); + const filter = document.getElementById('history-filter-unsaved')!; + filter.classList.toggle('is-on', unsavedOnly); + filter.setAttribute('aria-pressed', String(unsavedOnly)); } /** @@ -493,4 +546,7 @@ function syncPageChrome(): void { applyDocumentLanguage(); syncPageChrome(); readUrl(); +window.addEventListener('pageshow', (event) => { + if (event.persisted) void render(true); +}); void render(); diff --git a/test/e2e/history-page.spec.ts b/test/e2e/history-page.spec.ts index 0ec4cdb3..cf6e258f 100644 --- a/test/e2e/history-page.spec.ts +++ b/test/e2e/history-page.spec.ts @@ -138,6 +138,26 @@ test.describe('local history page', () => { await expect(page.locator('#history-empty')).toBeVisible(); }); + test('keeps keyboard focus and the caret while search results refresh', async ({ page }) => { + await seed(page, [ + { id: 'budget', title: 'Budget.docx' }, + { id: 'other', title: 'Report.docx' }, + ]); + await page.reload(); + await expect(titles(page)).toHaveCount(2); + const input = page.locator('#history-search'); + await input.click(); + await page.keyboard.type('Bud'); + await expect(titles(page)).toHaveText(['Budget.docx']); + await expect(input).toBeFocused(); + // No re-click: the original shadow input must still own focus and caret. + await page.keyboard.type('get'); + await expect(input).toHaveJSProperty('value', 'Budget'); + await page.waitForURL(/q=Budget/); + await expect(titles(page)).toHaveText(['Budget.docx']); + await expect(input).toBeFocused(); + }); + test('marks the rows whose only copy is in this browser', async ({ page }) => { await seed(page, [ { id: 'only-here', title: 'OnlyHere.docx' }, diff --git a/test/unit/history-page-rendering.test.ts b/test/unit/history-page-rendering.test.ts new file mode 100644 index 00000000..7a385c64 --- /dev/null +++ b/test/unit/history-page-rendering.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +const store = vi.hoisted(() => ({ listDocs: vi.fn(), historyUsage: vi.fn(), pruneExpired: vi.fn() })); +vi.mock('../../lib/history/store', () => ({ + ...store, + clearAllHistory: vi.fn(), + deleteDoc: vi.fn(), + getLatestSnapshot: vi.fn(), +})); +vi.mock('../../lib/pending-open', () => ({ stashPendingFile: vi.fn() })); +vi.mock('../../lib/confirm-dialog', () => ({ confirmDialog: vi.fn() })); +vi.mock('@ranuts/shared/i18n', () => ({ + applyDocumentLanguage: vi.fn(), + getLanguage: () => 'en', + localeHomePath: () => '/', + t: (key: string) => key, + withLocale: (url: string) => url, +})); +const result = (title: string) => ({ + items: [{ id: title, title, ext: 'docx', updatedAt: Date.now(), size: 10 }], + total: 1, + page: 1, + pageSize: 20, +}); +let removeListeners = () => {}; +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + localStorage.clear(); + document.body.innerHTML = '
'; + window.history.replaceState(null, '', '/history'); + store.listDocs.mockReset().mockResolvedValue(result('Initial.docx')); + store.historyUsage.mockReset().mockResolvedValue(10); + store.pruneExpired.mockReset().mockResolvedValue(undefined); + const listenerSpy = vi.spyOn(window, 'addEventListener'); + removeListeners = () => { + for (const [name, handler] of listenerSpy.mock.calls) window.removeEventListener(name, handler); + listenerSpy.mockRestore(); + }; +}); +afterEach(() => { + removeListeners(); + vi.clearAllTimers(); + vi.useRealTimers(); +}); +async function load() { + await import('../../lib/history-page'); + await vi.advanceTimersByTimeAsync(0); +} +function input(value: string) { + const el = document.getElementById('history-search')!; + el.dispatchEvent(new CustomEvent('input', { detail: { value } })); + return el; +} +it('keeps the focused search host mounted across debounced results', async () => { + await load(); + const search = input('Budget'); + search.focus(); + store.listDocs.mockResolvedValue(result('Budget.docx')); + await vi.advanceTimersByTimeAsync(200); + expect(document.getElementById('history-search')).toBe(search); + expect(document.activeElement).toBe(search); + expect(document.querySelector('.history-row-title')?.textContent).toBe('Budget.docx'); + expect(store.pruneExpired).toHaveBeenCalledOnce(); +}); +it('invalidates an older result as soon as another query is typed, before debounce', async () => { + await load(); + let finish!: (value: ReturnType) => void; + store.listDocs.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + input('Old'); + await vi.advanceTimersByTimeAsync(200); + input('New'); + finish(result('Old.docx')); + await vi.advanceTimersByTimeAsync(0); + expect(document.querySelector('.history-row-title')?.textContent).toBe('Initial.docx'); + store.listDocs.mockResolvedValue(result('New.docx')); + await vi.advanceTimersByTimeAsync(200); + expect(document.querySelector('.history-row-title')?.textContent).toBe('New.docx'); + expect(window.location.search).toBe('?q=New'); +}); +it('throttles expiry cleanup while refreshing it when the page returns', async () => { + await load(); + input('A'); + await vi.advanceTimersByTimeAsync(200); + input('B'); + await vi.advanceTimersByTimeAsync(200); + expect(store.pruneExpired).toHaveBeenCalledOnce(); + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + await vi.advanceTimersByTimeAsync(0); + expect(store.pruneExpired).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(60000); + input('C'); + await vi.advanceTimersByTimeAsync(200); + expect(store.pruneExpired).toHaveBeenCalledTimes(3); +}); + +it('keeps the toolbar and settings interactive after filter and clear-search changes', async () => { + await load(); + const toolbar = document.querySelector('.history-toolbar'); + const toggle = document.querySelector('#history-autosave')!; + toggle.checked = false; + toggle.dispatchEvent(new Event('change')); + await vi.advanceTimersByTimeAsync(0); + expect(document.querySelector('#history-autosave')).toBe(toggle); + expect(document.querySelector('.history-note-warn')).not.toBeNull(); + document.getElementById('history-filter-unsaved')!.click(); + await vi.advanceTimersByTimeAsync(0); + expect(document.getElementById('history-filter-unsaved')!.getAttribute('aria-pressed')).toBe('true'); + store.listDocs.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20 }); + input('Missing'); + await vi.advanceTimersByTimeAsync(200); + document.querySelector('.history-empty-action')!.click(); + await vi.advanceTimersByTimeAsync(0); + expect(document.querySelector('.history-toolbar')).toBe(toolbar); + expect(document.getElementById('history-search')!.getAttribute('value')).toBe(''); + expect(document.getElementById('history-filter-unsaved')!.getAttribute('aria-pressed')).toBe('false'); + expect(window.location.search).toBe(''); +}); From 55be26469305c15fef4338b16ceddb06a4f62a3c Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 26 Oct 2026 17:46:28 +0800 Subject: [PATCH 06/15] fix(history): preserve local copies when recovering chat conflicts --- lib/agent-plugin/ui/conversation-db.ts | 4 +- lib/agent-plugin/ui/history-controls.ts | 15 +++++- lib/agent-plugin/ui/sessions.ts | 44 ++++++++++++++++++ packages/shared/src/i18n/messages/de.ts | 4 +- packages/shared/src/i18n/messages/en.ts | 4 +- packages/shared/src/i18n/messages/es.ts | 5 +- packages/shared/src/i18n/messages/ja.ts | 4 +- packages/shared/src/i18n/messages/ko.ts | 4 +- packages/shared/src/i18n/messages/pt.ts | 4 +- packages/shared/src/i18n/messages/zh-CN.ts | 4 +- packages/shared/src/i18n/types.ts | 2 + test/unit/agent-history-controls.test.ts | 19 ++++++++ test/unit/agent-sessions.test.ts | 53 ++++++++++++++++++++++ 13 files changed, 157 insertions(+), 9 deletions(-) diff --git a/lib/agent-plugin/ui/conversation-db.ts b/lib/agent-plugin/ui/conversation-db.ts index 4a60b70b..cff73948 100644 --- a/lib/agent-plugin/ui/conversation-db.ts +++ b/lib/agent-plugin/ui/conversation-db.ts @@ -70,7 +70,9 @@ function validateStored(value: unknown): StoredConversations | null { export class ConversationConflictError extends Error { constructor() { - super('Saved conversations changed in another tab. Export this conversation before restoring saved history.'); + super( + 'Saved conversations changed in another tab. Keep local copies and restore saved history to preserve both versions.', + ); this.name = 'ConversationConflictError'; } } diff --git a/lib/agent-plugin/ui/history-controls.ts b/lib/agent-plugin/ui/history-controls.ts index dc104ad9..9a92b3ba 100644 --- a/lib/agent-plugin/ui/history-controls.ts +++ b/lib/agent-plugin/ui/history-controls.ts @@ -65,6 +65,7 @@ export function createHistoryControls(options: HistoryControlsOptions) { return item; }; const restore = button('agent-history-restore', 'M3 11a9 9 0 1 1 3 8M3 5v6h6M12 7v5l3 2'); + const recover = button('agent-history-recover', 'M3 11a9 9 0 1 1 3 8M3 5v6h6'); const legacy = button('agent-history-import', 'M12 3v12m-4-4 4 4 4-4M4 15v5h16v-5'); const exported = button('agent-history-export', 'M12 15V3m-4 4 4-4 4 4M4 15v5h16v-5'); const remove = button('agent-history-delete-current', 'M9 8l6 6m0-6-6 6M20 11a8 8 0 0 1-8 8H4v-8a8 8 0 0 1 16 0Z'); @@ -73,7 +74,7 @@ export function createHistoryControls(options: HistoryControlsOptions) { actionLabels.get(item)!.textContent = t(key); }; actions.append( - Div().class('agent-history-transfer-actions').children(restore, legacy, exported).build(), + Div().class('agent-history-transfer-actions').children(restore, recover, legacy, exported).build(), Div().class('agent-history-danger-actions').children(remove, clear).build(), ); const confirmation = document.createElement('div'); @@ -100,6 +101,8 @@ export function createHistoryControls(options: HistoryControlsOptions) { legend.textContent = t('agentHistoryDataControls'); saveText.textContent = t('agentSaveHistory'); labelAction(restore, 'agentRestoreHistory'); + labelAction(recover, 'agentRecoverHistory'); + recover.hidden = failure !== 'agentHistoryConflict'; labelAction(legacy, 'agentImportHistory'); labelAction(exported, 'agentExportHistory'); labelAction(remove, 'agentDeleteConversation'); @@ -162,6 +165,16 @@ export function createHistoryControls(options: HistoryControlsOptions) { restoreView(); }), ); + recover.addEventListener('click', () => { + if (busy) return; + void run(async () => { + options.onBeforeRestore?.(); + await store.recoverSaved(t('agentHistoryLocalCopy')); + localStorageSetItem(SAVE_PREFERENCE, 'false'); + restoreView(); + restore.focus(); + }); + }); legacy.addEventListener( 'click', () => diff --git a/lib/agent-plugin/ui/sessions.ts b/lib/agent-plugin/ui/sessions.ts index 789a87e2..dd5d642b 100644 --- a/lib/agent-plugin/ui/sessions.ts +++ b/lib/agent-plugin/ui/sessions.ts @@ -43,6 +43,7 @@ export function createConversationStore(onChange?: () => void, options: Conversa let epoch = 0; let knownRevision: number | undefined; let memoryRevision = 0; + let historyGeneration = 0; let dirty = false; const touched = new Set(); const removed = new Set(); @@ -191,7 +192,9 @@ export function createConversationStore(onChange?: () => void, options: Conversa .sort((a, b) => Number(b.id === active) - Number(a.id === active) || b.updatedAt - a.updatedAt); const history = (id = active): HistoryStorage => { if (!sessions.some((item) => item.id === id)) throw new Error('Unknown conversation'); + const generation = historyGeneration; const save = (messages: LLMMessage[]) => { + if (generation !== historyGeneration) return; const item = sessions.find((item) => item.id === id); // A detached controller may finish after deletion. It must never resurrect history. if (!item) return; @@ -271,6 +274,47 @@ export function createConversationStore(onChange?: () => void, options: Conversa lastError = undefined; mergeSaved(await repository.load()); }), + // Explicit recovery keeps local variants in memory; saving remains opt-in. + // Do not mutate local state until the read succeeds. A later save still + // compares the loaded database revision, including races with another tab. + recoverSaved: (copyLabel = 'Local copy'): Promise => { + saving = false; + savePending = false; + epoch++; + return enqueue(async () => { + const stored = await repository.load(); + const merged = structuredClone(stored?.sessions ?? []); + const localIds = new Set(); + let nextActive = active; + for (const item of sessions) { + if (!touched.has(item.id) && savedIds.has(item.id)) continue; + if (!item.messages.length && !item.title && !touched.has(item.id)) continue; + const remote = merged.find((entry) => entry.id === item.id); + if (remote && JSON.stringify(remote) === JSON.stringify(item)) continue; + const copy = structuredClone(item); + if (remote) { + copy.id = crypto.randomUUID(); + copy.title = `${copy.title || copyLabel} (${copyLabel})`; + if (active === item.id) nextActive = copy.id; + } + merged.push(copy); + localIds.add(copy.id); + } + if (!merged.length) merged.push(blankConversation()); + sessions = merged; + active = merged.some((item) => item.id === nextActive) ? nextActive : merged[0].id; + knownRevision = stored?.revision ?? 0; + savedIds = new Set(stored?.sessions.map((item) => item.id) ?? []); + touched.clear(); + for (const id of localIds) touched.add(id); + removed.clear(); + dirty = localIds.size > 0; + memoryRevision++; + historyGeneration++; + lastError = undefined; + onChange?.(); + }); + }, setSaving: async (enabled: boolean): Promise => { if (!enabled) { saving = false; diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index 64726c0f..8cb900d4 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -348,7 +348,9 @@ export const de: Partial = { agentHistorySaveFailed: 'Der Verlauf konnte nicht gespeichert werden. Die Unterhaltung bleibt auf dieser Seite; exportieren Sie sie vor dem Schließen.', agentHistoryConflict: - 'Ein anderer Tab hat den gespeicherten Verlauf geändert. Exportieren Sie diese Unterhaltung vor dem Wiederherstellen.', + 'Ein anderer Tab hat den Verlauf geändert. Behalten Sie lokale Kopien und stellen Sie den Verlauf wieder her.', + agentRecoverHistory: 'Lokale Kopien behalten und wiederherstellen', + agentHistoryLocalCopy: 'Lokale Kopie', agentRestoreHistory: 'Gespeicherten Verlauf wiederherstellen', agentImportHistory: 'Früheren Chatverlauf importieren', agentExportHistory: 'Unterhaltungen exportieren', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index 9554e3c9..e12a09ec 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -330,7 +330,9 @@ export const en: I18nMessages = { agentHistorySaving: 'Chat history saving is enabled on this device.', agentHistoryBusy: 'Updating chat history…', agentHistorySaveFailed: 'Could not save history. Your conversation remains in this page; export it before closing.', - agentHistoryConflict: 'Another tab changed saved history. Export this conversation before restoring saved history.', + agentHistoryConflict: 'Another tab changed saved history. Keep local copies and restore to preserve both versions.', + agentRecoverHistory: 'Keep local copies and restore', + agentHistoryLocalCopy: 'Local copy', agentRestoreHistory: 'Restore saved history', agentImportHistory: 'Import previous chat history', agentExportHistory: 'Export conversations', diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index 8b6a2805..cfa79f9d 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -337,7 +337,10 @@ export const es: Partial = { agentHistoryBusy: 'Actualizando el historial de chat…', agentHistorySaveFailed: 'No se pudo guardar el historial. La conversación sigue en esta página; expórtala antes de cerrarla.', - agentHistoryConflict: 'Otra pestaña cambió el historial guardado. Exporta esta conversación antes de restaurarlo.', + agentHistoryConflict: + 'Otra pestaña cambió el historial. Conserva copias locales y restaura para mantener ambas versiones.', + agentRecoverHistory: 'Conservar copias locales y restaurar', + agentHistoryLocalCopy: 'Copia local', agentRestoreHistory: 'Restaurar historial guardado', agentImportHistory: 'Importar historial de chat anterior', agentExportHistory: 'Exportar conversaciones', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index 97284078..28d80a68 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -334,7 +334,9 @@ export const ja: Partial = { agentHistoryBusy: 'チャット履歴を更新中…', agentHistorySaveFailed: '履歴を保存できませんでした。会話はこのページに残っています。閉じる前にエクスポートしてください。', - agentHistoryConflict: '別のタブで保存済み履歴が変更されました。復元する前に現在の会話をエクスポートしてください。', + agentHistoryConflict: '別のタブで履歴が変更されました。ローカルコピーを残して復元すると両方の版を保持できます。', + agentRecoverHistory: 'ローカルコピーを残して復元', + agentHistoryLocalCopy: 'ローカルコピー', agentRestoreHistory: '保存済み履歴を復元', agentImportHistory: '以前のチャット履歴をインポート', agentExportHistory: '会話をエクスポート', diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index 58965474..17528c13 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -325,7 +325,9 @@ export const ko: Partial = { agentHistorySaving: '이 기기에 채팅 기록을 저장합니다.', agentHistoryBusy: '채팅 기록 업데이트 중…', agentHistorySaveFailed: '기록을 저장하지 못했습니다. 대화는 페이지에 남아 있으니 닫기 전에 내보내세요.', - agentHistoryConflict: '다른 탭에서 저장된 기록이 변경되었습니다. 복원하기 전에 현재 대화를 내보내세요.', + agentHistoryConflict: '다른 탭에서 기록이 변경되었습니다. 로컬 사본을 유지하고 복원하면 두 버전을 보존합니다.', + agentRecoverHistory: '로컬 사본 유지 및 복원', + agentHistoryLocalCopy: '로컬 사본', agentRestoreHistory: '저장된 기록 복원', agentImportHistory: '이전 채팅 기록 가져오기', agentExportHistory: '대화 내보내기', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index c400decd..2b077344 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -334,7 +334,9 @@ export const pt: Partial = { agentHistorySaveFailed: 'Não foi possível guardar o histórico. A conversa continua nesta página; exporte-a antes de fechar.', agentHistoryConflict: - 'Outro separador alterou o histórico guardado. Exporte esta conversa antes de restaurar o histórico.', + 'Outro separador alterou o histórico. Mantenha cópias locais e restaure para preservar ambas as versões.', + agentRecoverHistory: 'Manter cópias locais e restaurar', + agentHistoryLocalCopy: 'Cópia local', agentRestoreHistory: 'Restaurar histórico guardado', agentImportHistory: 'Importar histórico de chat anterior', agentExportHistory: 'Exportar conversas', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index c1d023ab..fdd018e1 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -312,7 +312,9 @@ export const zhCN: I18nMessages = { agentHistorySaving: '已开启此设备的聊天记录保存。', agentHistoryBusy: '正在更新聊天记录…', agentHistorySaveFailed: '无法保存记录。当前对话仍在页面中,关闭前请先导出。', - agentHistoryConflict: '其他页面已修改保存的记录。恢复前请先导出当前对话。', + agentHistoryConflict: '其他页面已修改保存的记录。可保留本地副本并恢复,两个版本都会保留。', + agentRecoverHistory: '保留本地副本并恢复', + agentHistoryLocalCopy: '本地副本', agentRestoreHistory: '恢复已保存的记录', agentImportHistory: '导入旧版聊天记录', agentExportHistory: '导出对话', diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index 48cc8f81..210ab36f 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -323,6 +323,8 @@ export interface I18nMessages { agentHistoryBusy: string; agentHistorySaveFailed: string; agentHistoryConflict: string; + agentRecoverHistory: string; + agentHistoryLocalCopy: string; agentRestoreHistory: string; agentImportHistory: string; agentExportHistory: string; diff --git a/test/unit/agent-history-controls.test.ts b/test/unit/agent-history-controls.test.ts index d6ded764..01ee5912 100644 --- a/test/unit/agent-history-controls.test.ts +++ b/test/unit/agent-history-controls.test.ts @@ -359,3 +359,22 @@ it('settles clear-all exactly once only on successful retry while preserving con expect(restored).toHaveBeenCalledExactlyOnceWith('all'); expect(document.activeElement).toBe(trigger); }); + +it('offers explicit preserve-local recovery only after a conflict', async () => { + const store = createConversationStore(); + store.history().save([{ role: 'user', content: 'local' }]); + const before = vi.fn(); + const restored = vi.fn(); + const controls = createHistoryControls({ store, onBeforeRestore: before, onRestore: restored }); + document.body.append(controls.el); + const recover = controls.el.querySelector('.agent-history-recover')!; + expect(recover.hidden).toBe(true); + controls.showError(new ConversationConflictError()); + expect(recover.hidden).toBe(false); + recover.click(); + await vi.waitFor(() => expect(restored).toHaveBeenCalledOnce()); + expect(before).toHaveBeenCalledOnce(); + expect(store.history().load()[0].content).toBe('local'); + expect(store.saving).toBe(false); + expect(recover.hidden).toBe(true); +}); diff --git a/test/unit/agent-sessions.test.ts b/test/unit/agent-sessions.test.ts index d9f742bf..a78e0867 100644 --- a/test/unit/agent-sessions.test.ts +++ b/test/unit/agent-sessions.test.ts @@ -405,3 +405,56 @@ it('retains only explicitly owned volatile sessions while merging saved history' expect(store.activeId).toBe(savedId); expect(store.list().some((item) => item.id === initial)).toBe(false); }); + +it('explicit conflict recovery preserves every local edit without overwriting saved originals', async () => { + const first = createConversationStore(); + first.history().save([{ role: 'user', content: 'first original' }]); + const firstId = first.activeId; + const secondId = first.create().id; + first.history().save([{ role: 'user', content: 'second original' }]); + await first.setSaving(true); + const stale = createConversationStore(); + await stale.restoreSaved(); + await stale.setSaving(true); + first.history(firstId).save([{ role: 'user', content: 'remote edit' }]); + await first.flush(); + stale.history(firstId).save([{ role: 'user', content: 'local first' }]); + stale.history(secondId).save([{ role: 'user', content: 'local second' }]); + await expect(stale.flush()).rejects.toThrow(/another tab/i); + const oldHandle = stale.history(firstId); + await stale.recoverSaved(); + expect(stale.saving).toBe(false); + expect(stale.history(firstId).load()[0].content).toBe('remote edit'); + expect(stale.history(secondId).load()[0].content).toBe('second original'); + expect(stale.list()).toHaveLength(4); + const localIds = stale + .list() + .filter((item) => item.id !== firstId && item.id !== secondId) + .map((item) => item.id); + expect(localIds.map((id) => stale.history(id).load()[0].content).sort()).toEqual(['local first', 'local second']); + expect(localIds).toContain(stale.activeId); + oldHandle.save([{ role: 'assistant', content: 'late old controller' }]); + expect(stale.history(firstId).load()[0].content).toBe('remote edit'); + await stale.recoverSaved(); + expect(stale.list()).toHaveLength(4); + await stale.setSaving(true); + const restored = createConversationStore(); + await restored.restoreSaved(); + expect(restored.list()).toHaveLength(4); + expect(restored.history(firstId).load()[0].content).toBe('remote edit'); +}); + +it('failed explicit recovery leaves local conversations intact for retry', async () => { + const repository = createConversationRepository(); + const store = createConversationStore(undefined, { repository }); + store.history().save([{ role: 'user', content: 'local work' }]); + const id = store.activeId; + const load = vi.spyOn(repository, 'load').mockRejectedValueOnce(new Error('Read unavailable')); + await expect(store.recoverSaved()).rejects.toThrow('Read unavailable'); + expect(store.activeId).toBe(id); + expect(store.history().load()[0].content).toBe('local work'); + load.mockRestore(); + await store.recoverSaved(); + expect(store.activeId).toBe(id); + expect(store.list()).toHaveLength(1); +}); From f55a40f2572abd2b204a88db122173added71f0c Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 26 Oct 2026 17:50:43 +0800 Subject: [PATCH 07/15] perf(converter): run spreadsheet conversions in cancellable workers --- lib/converter.ts | 6 +- lib/document.ts | 4 +- lib/embed-api.ts | 1 + lib/events.ts | 8 ++- packages/converter/src/document-converter.ts | 14 +++-- packages/converter/src/spreadsheet-worker.ts | 57 ++++++++++++++++++ packages/converter/src/spreadsheet.ts | 21 ++++++- public/libs/sheetjs/convert.worker.js | 31 ++++++++++ public/sw.js | 2 + test/e2e/spreadsheet-worker.spec.ts | 43 ++++++++++++++ test/unit/converter-open-currency.test.ts | 29 +++++++++ test/unit/spreadsheet-worker-runtime.test.ts | 42 +++++++++++++ test/unit/spreadsheet-worker.test.ts | 62 ++++++++++++++++++++ test/unit/sw-boot-precache.test.ts | 2 + 14 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 packages/converter/src/spreadsheet-worker.ts create mode 100644 public/libs/sheetjs/convert.worker.js create mode 100644 test/e2e/spreadsheet-worker.spec.ts create mode 100644 test/unit/spreadsheet-worker-runtime.test.ts create mode 100644 test/unit/spreadsheet-worker.test.ts diff --git a/lib/converter.ts b/lib/converter.ts index 3e9315a0..d7657470 100644 --- a/lib/converter.ts +++ b/lib/converter.ts @@ -32,8 +32,10 @@ export async function handleDocumentOperation(options: { file?: File; readonly?: boolean; isCurrent?: () => boolean; + signal?: AbortSignal; }): Promise { try { + options.signal?.throwIfAborted(); // The DocsAPI loader is no longer a render-blocking