Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions docs/explorations/2026-10-26-reliability-and-responsiveness.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 34 additions & 3 deletions lib/agent-plugin/ui/conversation-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,30 @@ export interface ConversationRepository {
clear(): Promise<number>;
}

// 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<ConversationRecord, string>();
function freezePlainData(value: unknown, seen = new WeakSet<object>()): 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);
Expand All @@ -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' &&
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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';
}
}
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion lib/agent-plugin/ui/history-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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',
() =>
Expand Down
Loading
Loading