diff --git a/.oxlintrc.json b/.oxlintrc.json index 66b09d7a2..820df7802 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -10,7 +10,7 @@ "public/sdkjs/**", "public/web-apps/**", "public/fonts/**", - "public/libs/**", + "public/libs/sheetjs/xlsx.full.min.js", "public/ranui-iife/**", "public/ran-iife/**", "public/help/**", diff --git a/CHANGELOG.md b/CHANGELOG.md index dabf6ab74..4b7a1cd13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Exporting a PDF or another format keeps unsaved changes in the editable original visible until the original is saved. +- Failed file opens restore usable file controls, including keyboard access. +- Document history search keeps focus and ignores outdated results while typing. +- Chat history conflicts offer an explicit way to keep local copies and restore saved conversations. +- Dictation reaching its time limit finishes normally and preserves the final recognized words. +- Spreadsheet conversions run in a background worker where supported, keeping the page responsive and canceling superseded opens. +- Saving long chat histories avoids repeatedly processing unchanged conversations. + - The language menu no longer flashes open and stretches the page header while the page is still loading. - Select lists and the dictation privacy note open above the AI assistant panel diff --git a/docs/explorations/2026-10-26-reliability-and-responsiveness.md b/docs/explorations/2026-10-26-reliability-and-responsiveness.md new file mode 100644 index 000000000..87004348b --- /dev/null +++ b/docs/explorations/2026-10-26-reliability-and-responsiveness.md @@ -0,0 +1,85 @@ +# Reliability and responsiveness acceptance + +Base: `e06953cf25bb1d1849ba9af6c959d8b805388226`. + +## Changes + +- Other-format exports keep the editable source dirty and leave its SDK save point unchanged. A subsequent ordinary save still clears it. +- Failed opens restore visible, focusable controls; visibility timers cannot race, and pages with a landing hero retain their distinct layout. +- Document history retains its toolbar/input, rejects stale async results, and throttles expiry cleanup while forcing a sweep on BFCache restoration. +- Explicit conflict recovery preserves all locally touched conversations as separate in-memory copies when they differ from saved originals. Saving remains off until the user opts in again. A later save still checks the loaded database revision. Old controller history handles cannot write into restored originals. +- Healthy dictation reaches the existing one-minute limit through stop/final-result collection, with a two-second release fallback and an accurate limit notice. Startup/service errors remain separate. +- A local classic worker performs SheetJS parse/write operations. Each conversion owns its worker and releases it on completion, failure, cancellation or timeout. Caller-owned buffers stay intact. Main-thread text decoding retains existing encoding behavior; browsers without Worker retain the old compatibility path. Both worker and SheetJS are precached for first offline use. +- Conversation saves reuse validated serialization only for owned, deeply frozen plain-data snapshots. External mutable records and exotic mutable objects remain fully processed. Database schema and revision conflict guards are unchanged. + +## Verification + +- Confirmed RED regressions before fixes for save state, control recovery, history focus/stale results, chat conflicts and dictation duration. +- Full unit coverage: **6,844 tests in 256 files passed**. Statements 88.55%, branches 84.34%, functions 89.91%, lines 91.06%; configured package thresholds passed. +- Workspace types/lint, formatting and production build passed. Both archive gates verified 1,511 immutable evaluation artifacts. +- Real SheetJS VM tests round-trip Chinese CSV and HTML tables through XLSX and BOM-prefixed CSV without a DOM. +- Chromium production-build acceptance: **37 tests passed** (2.8 minutes), covering four-editor dictation, history actions/rendering, local document history keyboard focus, failed-open recovery, CSV encodings, responsive 50,000-row worker conversion and first offline conversion. + +## Repeated-save benchmark + +Node/Vitest with fake IndexedDB, 20 messages of roughly 1 KB each per conversation. Initial save and snapshot preparation are excluded; each sample changes one conversation. Five repeated saves per configuration, median elapsed time: + +| Conversations | Mutable snapshots | Prepared immutable snapshots | +| ------------- | ----------------: | ---------------------------: | +| 100 | 1.19 ms | 0.18 ms | +| 1,000 | 11.09 ms | 0.60 ms | + +This measures reduced repeated serialization work in a controlled harness, not browser disk latency or model first-token time. A deterministic regression also asserts that unchanged prepared records are not serialized again. First history loading still reads all conversation bodies. + +## Limits and follow-ups + +Physical microphone recognition and actual GPU generation latency were not measured. CPU-model cancellation teardown and the existing full-format offline preparation contract remain unchanged. They require separate lifecycle/network measurements before changing their guarantees. The worker improves responsiveness, not necessarily total conversion duration; decoding still runs on the main thread. Conflict recovery copies remain in memory until saving is explicitly re-enabled. + +Source and caller review covered save ownership, stale view results, conflict-copy ownership, mutable external records, worker cleanup and static offline dependencies. + +## Follow-up review + +A second pass over this branch found two problems in how the new SheetJS worker +ships. Neither affects the conversion logic. + +### The worker was served stale after a deploy + +`public/libs/sheetjs/convert.worker.js` has a fixed name, and its message +protocol (`operation`, `data`, `{ ok, data | error }`) changes with the app +bundle that posts to it. + +- **Before:** the Service Worker precached it into the runtime cache and served + it stale-while-revalidate. The first CSV or HTML-table conversion after a + deploy that changed the protocol would run against the previous worker. This + is the deploy-coupled case CLAUDE.md describes. +- **After:** the worker is in `DEPLOY_COUPLED`, in the `public/_headers` + no-cache group, and in the core precache list. Network-first's offline + fallback reads only the core cache, so offline conversion keeps working. + `xlsx.full.min.js` rarely changes, so it stays stale-while-revalidate. +- **Precache location:** both files are precached into the core cache. They are + not under the vendor trees (`sdkjs`, `web-apps`, `fonts`), so when a deploy + that leaves vendor unchanged activates with no window open, + `pruneAppAssets` would delete them from the runtime cache, and the first + offline CSV conversion would fail. The stale-while-revalidate branch reads + through `caches.match`, which also finds the core copy. +- **Tests:** `sw-routing.test.ts` asserts network-first for the worker, and + `sw-boot-precache.test.ts` now records cache entries by cache name and asserts + the worker lands in core. Both fail with the previous `sw.js`. + `spreadsheet-worker.spec.ts`, including the first offline conversion, still + passes. + +### The worker was never linted + +- **Before:** `.oxlintrc.json` ignored `public/libs/**`, which took the + repository's own worker out of the lint along with the vendored bundle. +- **After:** only `public/libs/sheetjs/xlsx.full.min.js` is ignored, and the + worker lints clean. +- **Test:** `workflow-contract.test.ts` checks that `public/sw.js` and the + worker match no ignore pattern. It fails against the old configuration. + +### Checked and left as is + +`recoverSaved` compares the local and saved records with `JSON.stringify`. Both +build their keys in the same order (`id, title, createdAt, updatedAt, +messages`), and saving updates through an object spread that keeps that order. +An unchanged conversation therefore never becomes a spurious local copy. 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 000000000..02fbd0450 --- /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 + +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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 + +- [x] 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. +- [x] 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 + +- [x] Specification review and source/caller quality review; resolve findings. Final independent agent review was unavailable due to tool-service errors; primary review completed. +- [x] Run focused tests then full coverage, lint/types, format and production build. Only one browser/build runner. +- [x] 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 000000000..a9e421f4d --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-reliability-and-responsiveness-design.md @@ -0,0 +1,25 @@ +# 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. + +## Review constraints + +- Conflict recovery preserves all locally modified conversations, revalidates remote revisions and remains safe to retry without duplicate copies. +- History expiry cleanup is throttled and runs on page restoration as well as initialization. Stale results never publish. +- Worker load failure, timeout, cancellation and document replacement settle the caller and release resources. Offline manifests retain the worker and its local dependencies. diff --git a/lib/agent-plugin/ui/conversation-db.ts b/lib/agent-plugin/ui/conversation-db.ts index 4a60b70bd..9b98a277e 100644 --- a/lib/agent-plugin/ui/conversation-db.ts +++ b/lib/agent-plugin/ui/conversation-db.ts @@ -24,6 +24,30 @@ export interface ConversationRepository { clear(): Promise; } +// Only detached, recursively immutable plain data may skip repeated validation. +// Object.freeze(Date/Map) does not freeze their contents: those remain uncached. +const preparedRecords = new WeakMap(); +function freezePlainData(value: unknown, seen = new WeakSet()): boolean { + if (value === null || typeof value !== 'object') return true; + if (seen.has(value)) return true; + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return false; + seen.add(value); + const plain = Object.values(value) + .map((child) => freezePlainData(child, seen)) + .every(Boolean); + if (plain) Object.freeze(value); + return plain; +} + +/** Prepare an owned snapshot once; arbitrary repository callers remain mutable. */ +export function prepareConversationRecord(record: ConversationRecord): ConversationRecord { + if (!isConversationRecord(record)) throw new Error('Saved conversation data is invalid'); + const copy = structuredClone(normalizeRecord(record)); + if (freezePlainData(copy)) preparedRecords.set(copy, JSON.stringify(copy)); + return copy; +} + export const CONVERSATION_DB_NAME = 'document-ai-conversations'; export function validConversationId(value: unknown): value is string { return typeof value === 'string' && /^(?:default|[a-f0-9-]{36})$/.test(value); @@ -32,6 +56,7 @@ export function validConversationId(value: unknown): value is string { export function isConversationRecord(value: unknown): value is ConversationRecord { if (typeof value !== 'object' || value === null) return false; const item = value as ConversationRecord; + if (preparedRecords.has(item)) return true; return ( validConversationId(item.id) && typeof item.title === 'string' && @@ -42,7 +67,9 @@ export function isConversationRecord(value: unknown): value is ConversationRecor ); } -function normalizeRecord({ id, title, createdAt, updatedAt, messages }: ConversationRecord): ConversationRecord { +function normalizeRecord(record: ConversationRecord): ConversationRecord { + if (preparedRecords.has(record)) return record; + const { id, title, createdAt, updatedAt, messages } = record; return { id, title, createdAt, updatedAt, messages }; } @@ -70,7 +97,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'; } } @@ -214,7 +243,9 @@ export function createConversationRepository(): ConversationRepository { if ((index?.revision ?? 0) !== expectedRevision) throw new ConversationConflictError(); // Normalize host records too, so unrelated properties never enter saved history. result = validateStored({ ...data, revision: (index?.revision ?? 0) + 1 })!; - serialized = new Map(result.sessions.map((item) => [item.id, JSON.stringify(item)])); + serialized = new Map( + result.sessions.map((item) => [item.id, preparedRecords.get(item) ?? JSON.stringify(item)]), + ); const nextIds = new Set(serialized.keys()); for (const id of index?.sessionIds ?? []) if (!nextIds.has(id)) sessions.delete(id); for (const item of result.sessions) { diff --git a/lib/agent-plugin/ui/history-controls.ts b/lib/agent-plugin/ui/history-controls.ts index dc104ad9a..9a92b3bac 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 789a87e2e..52bc0958b 100644 --- a/lib/agent-plugin/ui/sessions.ts +++ b/lib/agent-plugin/ui/sessions.ts @@ -4,6 +4,7 @@ import { createHistoryStorage, type HistoryStorage } from './storage'; import { ConversationConflictError, createConversationRepository, + prepareConversationRecord, validConversationId, type Conversation, type ConversationData, @@ -43,6 +44,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(); @@ -101,7 +103,7 @@ export function createConversationStore(onChange?: () => void, options: Conversa sessions: sessions.map((item) => { let copy = detached.get(item); if (!copy) { - copy = structuredClone(item); + copy = prepareConversationRecord(item); detached.set(item, copy); } return copy; @@ -191,7 +193,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 +275,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/lib/agent-plugin/ui/speech-input.ts b/lib/agent-plugin/ui/speech-input.ts index eab5ad6cc..53610fdad 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/lib/converter.ts b/lib/converter.ts index 3e9315a0a..d76574704 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