# Session Log — tabvana

## 2026-05-27T060000Z

- Completed: **Search result scrolling hides independent matches below tag section** — Root cause: `useVirtualizer` in `RawPageList` used the scroll container as `getScrollElement` but assumed the list starts at the container's top. When a tag section precedes the list (in search results), virtual item `start` values were correct relative to the scroll container but used as offsets within the list container, placing items too far down. Fix: `FlexView` wraps all above-list content in a `<div ref={aboveListRef}>`, measures its height with `ResizeObserver`, and passes it to `RawPageList` as `scrollMargin`. `RawPageList` feeds `scrollMargin` to `useVirtualizer` and uses `translateY(virtualItem.start - virtualizer.options.scrollMargin)` for positioning.
- The `ResizeObserver` on `aboveListRef` re-fires on tag expand/collapse (the wrapper's height changes), keeping `scrollMargin` up to date during user interaction.
- All 121 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-27T000000Z

- Completed: **Search result double-click opened new tab instead of focusing existing** — Root cause: `RawPageList.tsx` renders `PageRow` without `windowId`/`tabId` props in flat-list (search) mode. `PageRow`'s double-click handler passed these undefined props to `activateOrOpenTab`, which fell through to `chrome.tabs.create({ url })` — opening a new tab in a random window. Fix: double-click handler now uses `windowId ?? tabs[0]?.windowId` and `tabId ?? tabs[0]?.id`, falling back to the Redux-tracked tab when props are absent.
- Also fixed: eye-button (close-tab) click in flat-list mode did nothing for open tabs because `tabId` prop was undefined. Same `?? tabs[0]?.id` fallback applied there.
- Behavior preserved: windowed view (where `windowId`/`tabId` props are set per-row) still uses the explicit props — the nullish coalesce only kicks in when props are absent.
- All 112 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

_Workers append a short summary here before stopping. Read the most recent entry at session start._

## 2026-05-25T000000Z

- Completed: **392 pending embedding ops crash** — Three root causes found and fixed:
  1. **All 392 ops processed in one processQueue run** (~16 minutes): Chrome's SW watchdog killed mid-run, resetting all ops to pending and repeating the cycle. Fixed: `processQueue` now uses a `skipEmbedding` flag so only ONE embedding batch (≤50 URLs) runs per call; non-embedding ops still run in the same turn; a 2s `setTimeout` re-triggers for remaining ops.
  2. **WASM heap grew monotonically across batches (OOM)**: The offscreen doc was never closed between batches, so 8 batches × 16 ONNX calls accumulated unbounded memory. Fixed: `processOperation` finally block calls `embeddingProxy.closeDocument()` after every embedding op, freeing the heap before the next batch.
  3. **URL-specific ops accumulated despite bulk op pending**: `enqueueOperation` now early-exits URL-specific `generateEmbedding` ops when a bulk op `{}` is already pending — the bulk sweep covers all pages anyway.
- Added: `EmbeddingProxy.closeDocument()` method; `isQueueProcessing()` exported from queueProcessor and included in heartbeat log.
- "Skipping duplicate" messages were meaningful: they confirmed a pending op existed for that URL (dedup working), but the ops were stuck due to the single-run processing problem.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: With these changes, 392 ops → 8 processQueue runs of 2–3 minutes each, chained via setTimeout. Each run closes+reopens the offscreen doc (fresh WASM heap). If crashes recur, check heartbeat for `queueProcessing=false` with many pending ops — that would indicate the setTimeout re-trigger is being lost (SW killed in the 2s gap). Workaround if that happens: use `chrome.alarms` instead of `setTimeout` for the re-trigger (alarms survive SW restarts, but have a 1-minute minimum).

## 2026-05-18T120000Z

- Completed: **Recompute All Embeddings button** — added to `src/options/Options.tsx` near the Renormalize button. Uses `window.confirm` for confirmation, then chunks through all pages in 500-item batches (each in its own narrow `db.transaction('rw', [db.pages])`), clears `embedding`/`embeddingErrored`, then enqueues a `generateEmbedding {}` bulk op. Shows a local `LinearProgress` bar during the clear phase. Progress also written via `reportProgress`/`clearProgress` to `db.progress`. Styled `color=danger variant=outlined`. Updated the warning Alert to mention the button is needed after embedding-logic changes.
- No regressions: all 123 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-18T000000Z

- Completed: **System tags excluded from embeddings** — `queueProcessor.ts:processEmbeddingBatch` was including all `humanTags` (including `#stash`, `#flag`, `#archive`) in the embedding text. Added a filter using the existing `SystemTags` constant before joining tags into the text. The similarity worker already excluded system tags from tag suggestions; this change brings embedding generation in line.
- No regressions: all 123 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-14T193000Z

- Completed: **generateEmbedding accumulation — additional diagnostic logging** — Root cause remains unconfirmed (no new insight from log analysis alone), but added targeted logging to narrow it down in production.
- Added: `enqueueOperation` now logs new `generateEmbedding` op creation at `info` level (was `debug`, so never persisted). This will show WHEN and from WHERE (by payload) new ops are being created after a supposedly-complete run.
- Added: `enqueueOperation` now logs dedup-skip for `generateEmbedding` at `info` level — tells us when the dedup fires correctly vs when it's bypassed (e.g. a completed op with same payload hash no longer blocks new creation).
- Added: `enqueueOperation` sendMessage failure upgraded from `debug` to `warn` — most likely explanation for "op created but processQueue never triggered": SW is shutting down and sendMessage throws; the op sits in DB until next SW restart.
- Added: `processEmbeddingBatch` now logs per-batch skip count (pages already embedded) and a final summary: "N embedded, M skipped, K errored". This will reveal if ops are running but producing no work (all pages already have embeddings).
- Added: `processQueue` now logs a summary after the while loop: "processed N ops. M pending ops remain" — directly surfaces the "pending op left behind" scenario the user observed.
- Added: "Finished processing queue" upgraded from `debug` to `info` so it always persists.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: Check `db.logs` after the next accumulation event for: (1) `[Queue] Enqueuing generateEmbedding op` entries — these show what's creating new ops and with what payload; (2) `[QueueWorker] processQueue done: ... N pending ops remain` — this shows whether an op was left behind; (3) `[Queue] Failed to notify background worker` — if present, confirms the sendMessage-failure hypothesis.

## 2026-05-14T120000Z

- Completed: **Duplicate React key warning for tag names in Tags view** — Added defensive deduplication in `FlexView.tsx` when building `tagList`: `tagsSorted` is filtered through a `Set<string>` to remove any duplicate `tag.name` entries before mapping to `<CollapsibleTag key={tag.name} .../>`. The `tags` table uses `name` as its Dexie primary key so duplicates are impossible in normal operation, but this guards against any historic corrupt DB state or future code path that might slip through.
- Discovered: Root cause analysis found no current code path that can produce duplicate tag names in the DB (primary key enforces uniqueness from schema v1 onwards). The warning was most likely from a one-time corrupt DB state in the user's extension instance.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-14T000000Z

- Completed: **Backup logging cleanup** — all `logger.info` calls in the backup path (`chunkedExportDB`, `chunkedExportIncremental`, `doBackup`, `backupAndThin`) downgraded to `logger.debug`. Removed the "already in progress, skipping" log entirely (no log on skip). Added a single `logger.info` summary on successful completion: `[Backup] Completed: X.XMB | N pages | N tags | thinned N/N files | Nms` (thinning segment omitted when nothing was deleted).
- Changed `chunkedExportDB` and `chunkedExportIncremental` to return `{ blob, pageCount, tagCount }` (previously returned just `Blob`); `doBackup` now returns `DoBackupResult { success, sizeMB?, pageCount?, tagCount? }` instead of `boolean`.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-13T160000Z

- Completed: **Gate bulk tab-close paths with hasUnsavedChanges** — all six call sites addressed.
- `stashTabs` (`pages.ts`): added `hasUnsavedChanges` per-tab check; close is skipped for dirty tabs, DB stash always proceeds.
- `PageTags.tsx` (per-row Stash icon): async IIFE pattern; skips close + dispatches `setNotification('Tab not closed — unsaved changes')` for dirty tabs; stash tag written regardless.
- `WindowGroupHeader.tsx` (`handleStashAll`, `handleSnoozeAll`): extracted `closeBulkTabsWithConfirmation` inner helper; checks all tabs, uses `window.confirm("N tabs have unsaved changes — close anyway?")`, closes all on confirm or only clean tabs on cancel. DB stash (`stash(urls)`) always runs in `handleStashAll`.
- `MultiSelectActions.tsx` (Forget, Snooze): same bulk pattern; Forget also gates `deletePage` per-URL (only deleted for clean/confirmed-close URLs, preserving dirty tabs' DB records on cancel).
- Removed unused `stashTabs` import from `WindowGroupHeader.tsx`.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-13T140000Z

- Completed: **Clicking duplicate in Open view focuses wrong tab** — Root cause: `PageRow.tsx` received `windowId` and `tabId` as props (specific to each rendered row) but click/double-click handlers used `tabs[0]?.windowId` and `tabs[0]?.id` (always the first tab matching the URL). For duplicate URLs, this always focused the first tab. Fixed by replacing all `tabs[0]?.windowId`/`tabs[0]?.id` references in handlers with the `windowId`/`tabId` props. Eye-button close-tab logic (`hasUnsavedChanges` + `closeTab`) similarly fixed to use `tabId` prop.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-13T120000Z

- Completed: **Ignored URLs not filtered in chrome tab listeners** — Root cause: `Main.tsx` called `registerTabsListeners(store)` without passing `shouldIgnoreUrl`, so the listener defaulted to `() => false` (never ignore). Fixed by importing `shouldIgnore` from `urlNormalizationPrefs` and passing `{ shouldIgnoreUrl: shouldIgnore }` as the options argument.
- Note: The fix means filtering relies on `updateUrlNormalizationCache` having been called before tabs fire events. `loadPrefsFromDB()` runs at module load time in `Main.tsx`, so in practice the cache is populated before any tab events arrive.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-13T000000Z

- Completed: **generateEmbedding queue accumulation investigation + logging** — Diagnosed the causes and added diagnostic logging across the embedding pipeline.
- Root cause analysis: No permanent "stuck" path exists. Ops accumulate in pending when: (a) WASM backend is in backoff (intentional, timer-driven retry); (b) SW is killed before the backoff retry timer fires (on restart, the module-level backoff resets, so the startup sweep recovers); (c) many tag changes each enqueue URL-specific ops faster than they coalesce.
- Added: `processQueue` now logs a per-type breakdown of all pending ops (e.g. `{"generateEmbedding":12}`) plus backoff state at every call.
- Added: `handleGenerateEmbedding` logs how many ops were coalesced into the current batch vs left pending.
- Added: `embeddingProxy.ts` logs offscreen document creation/reuse and logs full error message on failure response from offscreen.
- Added: `embeddingProxy.init()` failure now distinguishes "no available backend found" vs unexpected errors and logs both clearly.
- Added: `embedding.ts` logs WASM path and model name at init time, with full error stack on failure.
- Added: `offscreen.ts` logs full error message and stack on both single/batch embedding failures.
- Added: Heartbeat (`background/index.ts`) now logs pending ops broken down by type, not just the total count.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: If generateEmbedding ops are still accumulating after this logging, check `db.logs` for: `[EmbeddingService] Failed to load model` (WASM didn't initialise), `[EmbeddingProxy] Offscreen returned failure response` (model loaded but inference failed), or `[QueueWorker] Pending ops: {"generateEmbedding":N} | embedding backoff: Xs` (backoff cycle). These will pinpoint the root cause.

## 2026-05-11T000000Z

- Completed: **Stuck pending embedding ops + resource leak investigation** — three root causes found and fixed:
  1. **Silent backoff skip** (`queueProcessor.ts`): when the WASM backend was in backoff, `handleGenerateEmbedding` returned early without throwing, so `processOperation` marked each op as `completed` without doing any work. Pages permanently lost their embedding work. Fixed: `processQueue` now uses `getNextProcessableOp()` which skips embedding ops during backoff (they stay `pending`), plus `maybeScheduleEmbeddingRetry()` which schedules a single `processQueue` call for when the backoff expires. Module-level `embeddingRetryTimer` prevents duplicate timers.
  2. **No startup embedding sweep** (`background/index.ts`): after a backoff event or crash, pages without embeddings had no recovery path until user action. Fixed: `enqueueOperation('generateEmbedding', {}, 30)` added to startup (priority 30; deduplication prevents overlap with existing bulk ops).
  3. **Offscreen document not recreated in retry loop** (`embeddingProxy.ts`): when "Receiving end does not exist" fired inside `generateEmbedding`/`generateEmbeddings` retry loops, the document wasn't recreated — each retry hit the same dead endpoint. Fixed: `setupOffscreenDocument()` called inside the catch branch before the 100ms delay.
- Discovered: **The 4G memory in heartbeat is system RAM** (`chrome.system.memory.getInfo()` — free/total physical), not WASM heap. The offscreen document's WASM heap is separate and grows monotonically (WASM memory can't shrink), but is reset whenever Chrome recycles the offscreen document. On OOM, Chrome kills the document; the next `embeddingProxy.init()` call recreates it fresh. No persistent leak under normal operation.
- Discovered: `embeddingBackoff` is module-level state that resets to zero on every SW restart. So after a crash, backoff is always cleared — the 86 pending ops were most likely stuck in a compounding crash cycle (SW killed mid-ONNX → ops reset to pending → SW killed again). The 50-URL-per-run cap (`MAX_URLS_PER_EMBEDDING_RUN`) from the previous session should be the main fix for that; the new startup sweep ensures recovery even if some slip through.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-09T000000Z

- Completed: **MCP server README docs** — added a "MCP Server (dev mode)" section to `README.md` covering architecture diagram, setup steps (dev build → load extension → `npm run mcp-bridge` → `claude mcp add`), health check, all 7 tools, and custom port config.
- Next session: No Approved tasks remain.

## 2026-05-08T200000Z

- Completed: **Dev-mode MCP bridge** — `mcp-bridge/` is a standalone Node.js package (ws + @modelcontextprotocol/sdk + zod + tsx). `npm run mcp-bridge` from repo root installs deps and starts the daemon. Listens on `ws://127.0.0.1:7878` for the extension and serves MCP at `http://127.0.0.1:7878/mcp`. Tools: `search_pages`, `get_page`, `list_tags`, `get_pages_by_tag`, `find_similar`, `recent_pages`, `get_stats`.
- Completed: `src/background/mcpDevClient.ts` — extension-side WS client with exponential backoff (1s→30s). Dispatches all 7 methods to existing DB/search helpers. `find_similar` does batched cosine similarity in the SW without triggering embedding generation.
- Discovered: **`import.meta.env.DEV` is always `false` in `vite build`** (even `--mode development`). Used `import.meta.env.MODE === 'development'` instead — this is `true` for `vite build --mode development` (the `dev` script) and `false` for production builds. Rollup tree-shakes `mcpDevClient` completely from production builds; confirmed `ws://127.0.0.1:7878` absent from prod bundle and present in dev bundle.
- Discovered: **`yarn lint` was already broken before this session** — `eslint-config-react-app` and `eslint-plugin-simple-import-sort` were absent from `node_modules` (not installed by yarn despite being referenced in `.eslintrc.json`). `yarn build` and `tsc --noEmit` both pass; 120 tests pass.
- Next session: No Approved tasks remain. To verify bridge end-to-end: `npm run dev` (build watch), load `dist/` as unpacked extension, open Tabvana window, `npm run mcp-bridge`, then `claude mcp add tabvana --transport http http://127.0.0.1:7878/mcp`.

## 2026-05-08T000000Z

- Completed: **400-500% CPU / 132 pending generateEmbedding tasks** — Three root causes found and fixed:
  1. **`handleFetchMissingTitle` BULK_LIMIT 50 → 5** (`queueProcessor.ts`): each title fetch opens a real browser tab (full page load). 50 tabs × 10s timeout = 500s blocking the queue, enough CPU/memory pressure to kill the SW mid-run. Reduced to 5 (≤50s).
  2. **Unbounded embedding batch per run** (`queueProcessor.ts`): `handleGenerateEmbedding` was absorbing ALL pending ops and processing all their URLs in one shot. With 132 URLs × ~5–30s per 16-URL ONNX batch, the SW was killed before completing. On restart, ops were reset to pending and the cycle repeated. Fixed with `MAX_URLS_PER_EMBEDDING_RUN = 50`: coalescing stops absorbing at 50 URLs; bulk mode slices to 50 and re-enqueues a fresh bulk op if more pages remain. Each run now completes in ≤120s, well within Chrome's watchdog.
  3. **No timeout on offscreen message** (`embeddingProxy.ts`): `chrome.runtime.sendMessage` has no built-in timeout. If the offscreen document crashes or hangs silently, `generateEmbeddings`/`generateEmbedding` waited forever, permanently stalling the queue. Added a 180s timeout that throws `'Embedding message timeout'`; `processEmbeddingBatch` now propagates this without marking pages as `embeddingErrored` (they can be retried), unlike permanent backend failures.
- Discovered: The 132 pending ops scenario is a compounding bug: SW killed during WASM → ops reset to pending → next run absorbs all 132 again → SW killed again → repeat. The per-run cap breaks this cycle.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain. Watch for: (a) 'Embedding message timeout' in logs (indicates WASM consistently slow — consider reducing MAX_URLS_PER_EMBEDDING_RUN further); (b) CPU still high after fixes (fetchMissingTitle might still be too frequent — consider longer re-enqueue delay or a per-URL fetch-failed-after-N-attempts guard).

## 2026-05-07T170000Z

- Completed: **Incremental backup restore** — `restore()` in `backup.ts` now handles incremental backups (`tabvana-incr-*.json`) correctly. It reads and parses the selected file; if incremental, it auto-locates the base full backup from the configured backup directory (trying `baseFullBackupName`, then `tabvana-full-{ts}.json`, then legacy `tabvana-{ts}.json`). If not found automatically, a second OS file picker opens pre-navigated to the backup directory. Then it calls `importInto(db, fullBlob, { clearTablesBeforeImport: true })` to restore the full backup, followed by chunked `bulkPut` (500/batch) to overlay incremental pages and tags.
- Changed: `chunkedExportIncremental` now embeds `baseFullBackupName` (e.g. `tabvana-full-{ts}.json`) in the incremental JSON so restore can resolve the full backup without guessing.
- Changed: The old "throw error on incr backup" guard in `restore()` is replaced by the full two-step restore.
- Changed: Options.tsx confirm dialog mentions that incremental restores will locate the full backup automatically or prompt for it.
- All 123 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-07T150000Z

- Completed: **SerializableStateInvariantMiddleware 1912ms** — disabled `serializableCheck` in `getDefaultMiddleware` (`store.ts`). The middleware recursively walks the full Redux state tree on every dispatch; with thousands of tabs and URL maps it took ~2s per action. State only contains plain JS values so the check provided no safety benefit.
- Completed: **580s incremental backup diagnosis + fix** — two changes in `backup.ts`: (1) `chunkedExportIncremental` was doing a single `.toArray()` loading all recently-accessed pages (each with a 384-dim embedding, ~1.5KB of JSON) into memory at once — converted to the same chunked-read pattern as `chunkedExportDB` with 200-row batches and event-loop yields between chunks; (2) added granular timing logs at every sub-step (pages read, tags read, JSON serialization, `write()`, `close()`, thinning) plus the `sinceTimestamp`/`cutoff` values so the next crash log will identify exactly which phase consumed the 580s.
- Discovered: `sinceTimestamp=0` would make `.below(0)` match all pages (all `lastActiveDesc` values are negative), effectively doing a full-table scan disguised as an incremental. The new logging captures this. The code path that would produce this (lastFullTs=0 → isFull=true) is guarded, but the log will confirm it if the guard ever misses.
- Next session: No Approved tasks remain. After a crash, check logs for `[Backup] Incremental pages read: N pages in Xms` and `[Backup] write() done in Xms` to pinpoint the slow step.

## 2026-05-07T120000Z

- Completed: **React duplicate key warning** — Root cause: `detached` reducer used position-based `splice(oldPosition, 1)` to remove a tab from its old window. If tab positions had shifted (due to other reorders), the wrong tab was removed, leaving the detached tab in the old window. When `attached` then added it to the new window, the same tab ID existed in both windows — causing duplicate `key={tabId}` in `Windows.tsx` flat view. Additionally, both `onDetached` and `onAttached` listeners do an async `checkIgnore` call, creating a race where `attached` could dispatch before `detached`. Three-part fix: (1) `detached` now uses `filter(id !== tabId)` like `removed` does; (2) `attached` now removes the tab from all other windows before inserting into the new window; (3) `allTabIds` in `Windows.tsx` flat view deduplicates with `new Set` as a belt-and-suspenders render guard.
- Discovered: `makeSelectTabsForWindow` already added `new Set` deduplication for intra-window duplicates — confirming this was a known class of bug. The inter-window duplicate from the `detached` race was not covered.
- Next session: No Approved tasks remain.

## 2026-05-06T190000Z

- Completed: **Inactive tabs produce blank rows in Open view** — Root cause: `WindowTabList.tsx` drag path (always active since `isDragEnabled={true}`) rendered `<div style={{ height: '1.4rem' }}>` for any tabId in `window.tabIds` that was missing from `state.tabs`. Newly created tabs are added to `tabIds` before their first `onUpdated` event provides a URL, so `updateTab` returns early and the tab is absent from `state.tabs`. Fix: added `activeDragId?: number | null` prop to `WindowTabList`; the 1.4rem placeholder is shown only when `activeDragId != null` (an active drag needs the height to reserve drop-target space); otherwise `null` is returned. All 123 tests pass, `tsc --noEmit` clean.
- Discovered: The blank-row placeholder was designed for DnD drop-target reservation but was triggering for ALL untracked tabs (any tabId in `window.tabIds` without a corresponding `state.tabs` entry). The discard fix in the previous session addressed a separate case (`changeInfo.discarded`) but did not fix this structural one.
- Next session: No Approved tasks remain.

## 2026-05-06T180000Z

- Completed: **Handle Chrome memory-saver discarded tabs** — Two-line fix in two files. (1) `dbSync.ts` `handleTabUpdated`: early-return when `changeInfo.discarded === true` so we never call `ensurePageTracked` with Chrome's transient state during discard. (2) `windowsSlice.ts` `updated` reducer: early-return when `changeInfo.discarded === true` so the Redux tab URL is never overwritten with `about:blank` (which Chrome may briefly report), preventing the blank-row symptom in the Open view. Added 2 unit tests in `dbSync.test.ts` (discard skips tracking; re-activation with `discarded: false` processes normally). All 123 tests pass, `tsc --noEmit` clean.
- Discovered: `about:blank` is NOT in `shouldIgnore`'s default substring list. If Chrome sends `about:blank` as a tab URL (during discard or otherwise), it bypasses all URL guards and would create a DB entry or a blank Redux row. The discard guard in the `updated` reducer covers this, but `about:blank` could still slip through other `onUpdated` events (e.g., a new-tab before navigation). Low risk in practice.
- Next session: No Approved tasks remain.

## 2026-05-05T120000Z

- Completed: **Incremental backup strategy** — `backup.ts` now writes full backups (`tabvana-full-{ts}.json`) once every 24 hours and incremental backups (`tabvana-incr-{ts}.json`) on the normal frequent schedule. Incrementals export only pages accessed since the last full backup (via `lastActiveDesc` index) plus all tags. New thinning policy: keep last 5 incrementals, all full backups within 14 days, one per ISO week for the next 6 weeks; delete everything older. `prefs.ts` gains `getLastFullBackup`/`setLastFullBackup`. The `restore()` function now detects `-incr-` filenames and throws a user-visible error instead of corrupting the DB. Added 19 unit tests for the pure thinning helpers (`parseBackupFilename`, `getISOWeekKey`, `computeFilesToDelete`) in `src/db/__tests__/backup.test.ts`.
- Discovered: The old thinning code iterated the backup directory twice (first loop result was unused) and deleted non-backup files. Fixed: single pass, only deletes files matching our naming patterns.
- Note: Incrementals miss tag-only changes to pages not recently visited (no `modifiedAt` timestamp on DBPage). The next daily full backup always captures them. This is documented in the code comment.
- All 121 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-06T000000Z

- Completed: **Memory logging always "n/a"** — `performance.memory` is a Chrome-specific `Window`-only API; MV3 extension service workers don't have it. Fixed by switching to `chrome.system.memory.getInfo()` (available in SW contexts) in both `backup.ts` and `background/index.ts` heartbeat. Added `"system.memory"` permission to `manifest.ts`. Memory is now reported as "free/total MB free" (physical RAM), which is more useful for diagnosing OOM kills than JS heap anyway. Also removed the `PerformanceWithMemory` type alias from `backup.ts`. All 85 relevant tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-05T210000Z

- Completed: **InvalidStateError on IDBTransaction abort** — fixed `dexieUtils.ts`'s `cancellableDexieQuery`. When `useLiveQuery` re-fired `fetchTagsAndPagesForQuery` on a new query, the previous call's cancel function called `tx.abort()` on an already-finished transaction. Fix: (1) clear `tx` via `promise.then/catch` when the transaction settles, so cancel() sees null and skips; (2) wrap `tx.abort()` in try-catch as belt-and-suspenders. All 98 tests pass.
- Next session: No Approved tasks remain.

## 2026-05-05T000000Z

- Completed: **Crash diagnostics** — added memory/timing logging to backup.ts and a 60-second persistent heartbeat in background/index.ts.
- Analysis: The "Backup already in progress, skipping..." messages are **expected**. `debouncedMaybeBackup` fires every 5s (debounce) on any DB write; if backup takes 45s, ~9 calls each find `isBackingUp=true` and log the skip. The crash almost certainly happens at the END of backup, not during it.
- Root cause hypothesis: `chunkedExportDB` accumulates the entire DB as a JSON string + Blob simultaneously in the JS heap. For a large DB with embeddings (384 floats × 10K+ pages), the peak footprint could exceed Chrome's MV3 service-worker memory limit (~512MB), causing a silent OOM kill — no JS exception, no log.
- New log trail: backup.ts now logs `[Backup] Starting export. Mem: XMB`, `[Backup] Export done: Y.ZMB in Nms. Mem: XMB`, `[Backup] Written to disk. Mem: XMB`, and `[Backup] Backup completed/failed in Nms. Mem: XMB`. This makes the memory spike visible across restarts.
- New heartbeat: `background/index.ts` logs `[Heartbeat] mem=XMB pendingOps=N activeProgress=M` every 60 seconds (persisted to db.logs). Provides a memory trend leading up to any future crash.
- Next session: After seeing a crash with the new logs, check: (a) what `mem=` was at `[Backup] Export done` vs prior heartbeats — a large spike confirms OOM; (b) if the blob size is >50MB, consider streaming the backup file instead of buffering the whole blob. A streaming implementation would write JSON chunks directly to the `FileSystemWritableFileStream` as it reads each table batch, eliminating the in-memory accumulation entirely.

## 2026-05-01T180000Z

- Completed: **PageRow scalar selectors** — replaced `useAppSelector(selectSelectedUrls)` and `useAppSelector(selectSimilarUrls)` (whole-array) in PageRow with per-URL `selectIsSelected`/`selectPageSimilarity` scalar selectors. Only rows whose specific value changed now re-render on selection or similarity update. `handleAutomateClick` reads the full array from `store.getState()` at call-time (no subscription).
- Completed: **Batch useLiveQuery in Main.tsx** — replaced 7 isolated mini-components (TabsCount + 6 count components) with one `TabListContent` component using 2 combined Dexie subscriptions (7 → 2), rendering the full TabList.
- Completed: **Virtual scrolling on All tab** — installed `@tanstack/react-virtual`; `FlexView` passes scroll container element (via callback ref + useState) to `TagPageList` → `RawPageList`; `RawPageList` uses `useVirtualizer` in the flat (!groupByWindow) path when `scrollEl` is provided. Added `skipInView` prop to `PageRow` (passes `initialInView={true}` to InView) to avoid 1-frame placeholder flash in virtual rows.
- Discovered: The `@tanstack/react-virtual` `useVirtualizer` hook picks up a new `scrollEl` on re-render via `setOptions`, so the callback-ref/useState pattern in FlexView correctly initializes the virtualizer after first mount.
- All 102 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-01T140000Z

- Completed: **UI thread unresponsiveness investigation + fixes** — comprehensive audit of main thread, service worker, and rendering pipeline.
- Implemented Fix 1 (HIGH): `redux-logger` was active in all builds — every Redux dispatch synchronously serialised the full state tree to console, directly blocking the UI thread during rapid tab events. Fixed: conditional on `import.meta.env.DEV`; Vite tree-shakes it in production.
- Implemented Fix 2 (MEDIUM): `updateAdjacentAccessLinks` opened ~300 separate `rw` transactions on `db.pages` per tab activation (one per recent tab pair). Each write lock delayed `useLiveQuery` reads in the UI window. Fixed: new `accessTimeAdjacentBatch()` in pages.ts collapses all pairs into a single bulk-read + bulk-write transaction.
- Implemented Fix 3 (MEDIUM): `groupPagesByWindow` used `Math.min(...array.map(...))` spread — potential call-stack overflow for large arrays. Fixed: replaced with `Array.reduce`.
- Implemented Fix 4 (MEDIUM): `filterPages → sortPages → map` in `RawPageList` ran on every parent re-render without memoisation. Fixed: wrapped in `useMemo` with correct dependency array.
- Remaining suggestions (not implemented — require more invasive restructuring):
  - **PageRow `selectedUrls`/`similarUrls` selectors**: both `useAppSelector(selectSelectedUrls)` and `useAppSelector(selectSimilarUrls)` return whole arrays/maps, causing all ~N PageRows to re-render on any selection change or similarity update. Fix: inline the per-page lookup into the selector (return a boolean/number); keep the full array only in event handlers via store.getState().
  - **Batch the 6 tab-count useLiveQuery subscriptions** in Main.tsx into one combined query — reduces 6 independent re-renders to 1 per DB change.
  - **Virtual scrolling on the All tab** — RawPageList renders all 10K+ pages at once; InView provides lazy rendering of window groups but the initial DOM creation is still expensive.
- All 102 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-05-01T120000Z

- Completed: **Memory/crash analysis and fixes** — comprehensive audit of all background files for leaks, fork bombs, and high memory usage.
- Root cause 1 (HIGH): `db.logs` table was unbounded in practice. `logger.info` in hot paths (`ensurePageTracked`, `enqueueOperation`) wrote to DB on every tab activation. With 7-day TTL and hourly pruning, the table could accumulate 500K+ rows in a week. Fixed: (a) `debug` level no longer persists to DB at all — console-only; (b) added a 10,000-row hard cap enforced during the hourly prune via `bulkDelete`. Changed hot-path `logger.info` → `logger.debug` in `ensurePageTracked`, `enqueueOperation` skip/enqueue paths, and `preparePagesAndTagsToPut` new-page creation.
- Root cause 2 (MEDIUM): `clearProgress(id)` was not awaited in the `finally` blocks of `addTag`, `removeTag`, `stash`, `unstash`, `setTags`, and `refreshHotness`. Progress entries lingered in `db.progress` until next-startup `clearAllProgress()`. Fixed: added `await` to all 6 call sites.
- Root cause 3 (LOW): `embedding.ts` used `Float32Array.slice()` (creates a copy) inside `generateEmbeddings`. Changed to `subarray()` (zero-copy view) to reduce intermediate allocation pressure during batch ONNX inference.
- Root cause 4 (LOW): Coalesced embedding ops could contain overlapping URLs (two `addTag` calls on overlapping page sets). Added `urlsToProcess = [...new Set(urlsToProcess)]` after coalescing to eliminate redundant DB reads and ONNX calls.
- Discovered: No fork bomb. The op queue is finite: `cleanup/stash → generateEmbedding → done` (max 2-level depth). `handleGenerateEmbedding` never enqueues new ops. The "hundreds of thousands of embedding tasks" hypothesis is unfounded — dedup in `enqueueOperation` prevents duplicate pending ops per URL set, and coalescing collapses all pending ops into one batch run.
- Discovered: No unbounded Sets. `restoredTabIds` drains as tabs are activated. `newTabIds` drains on activation, navigation, or removal. `throttleMap` is always cleared by `clearProgress` or `clearAllProgress`.
- Next session: The WASM heap (offscreen document) is a remaining unknown — it grows with each ONNX inference and never shrinks unless the document is closed. If crashes recur specifically during bulk embedding, consider adding offscreen document recycling after N batches.

## 2026-05-01T000000Z

- Completed: **Don't show "todo" pill on Todo tab** — added `hideTodoPill?: boolean` prop to `PageRowProps`; `TodoView.tsx` passes `hideTodoPill` to each `<PageRow>`. Pill is suppressed by `!hideTodoPill` guard in the render. 102 tests pass.
- Next session: No Approved tasks remain.

## 2026-04-29T080000Z

- Completed: **"Readwrite transaction in liveQuery context" from SearchResult** — root cause: `SearchResult.tsx` called `logger.debug()` inside the `.then()` chain of a `useLiveQuery` callback. `Dexie.ignoreTransaction()` clears `PSD.trans` but NOT `PSD.subscr`, so the DB write in `persistLog` still triggered Dexie's liveQuery guard. Fix: removed the `logger.debug("[SearchResult]", ...)` call and the now-unused `logger` import. Also hardened `persistLog` in `logger.ts` to detect the liveQuery error by message and retry via `setTimeout(0)` to escape the Dexie zone — belt-and-suspenders against future misuse.
- Verified: 102 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-04-29T060000Z

- Completed: **Stringify DB log error** — `logger.ts` catch block now uses `instanceof Error` check to format `e` with name/message/stack, falling back to `JSON.stringify`. Also includes the original `message` string and `level` so the failed log entry is visible in the console output.
- Next session: No Approved tasks remain.

## 2026-04-29T000000Z

- Completed: **Tag timestamp uses max(existing, page last-view) on addTag/setTags** — `addTag` and `setTags` in `pages.ts` now read the existing tag's `last_active` and the pages' last access times *before* `preparePagesAndTagsToPut` modifies them (it adds `Date.now()` to page access times for user-interaction tracking). The tag timestamp is set to `max(existing_tag.last_active, max_page_last_view)` instead of `now()`.
- `addTag` also simplified: no longer bulk-updates pre-existing tags on the affected pages (they already have correct timestamps from when they were originally added/viewed).
- Removed unused `import { now } from 'lodash'` from `pages.ts`.
- Verified: `tsc --noEmit` clean, 102 tests pass (1 pre-existing queue.test.ts ordering flake; passes in isolation).
- Next session: No Approved tasks remain.

## 2026-04-21T190000Z

- Completed: **Ignored tabs aligned with regular rows** — updated `ignoredMode` render path in `PageRow.tsx` to use the same flex column structure as regular rows: similarity dot (opacity 0), eye button, two `noicon` placeholders (select and duplicate slots), favicon, title. Preserves double-click to open.
- In drag mode (`WindowTabList.tsx`), ignored tabs now get the same outer wrapper as `SortablePageRow` (flex container + non-functional ⠿ drag handle placeholder) so their left edge aligns.
- No opacity change — `pagerow__ignored` still applies 0.55 opacity to the whole row.
- Verified: tsc clean, 64 tests pass.
- Next session: No Approved tasks remain.

## 2026-04-21T180000Z

- Completed: **Don't close tabs with unsaved changes** — `chrome.scripting.executeScript` injects a form-field check (value vs defaultValue for inputs/textareas/selects) before closing. Falls back to safe-to-close on any injection error (chrome://, loading tabs, file:// without permission).
- Added `hasUnsavedChanges(tabId)` to `chromeActions.ts`; added `scripting` permission to `src/manifest.ts`.
- Eye button in `PageRow.tsx` (both normal and ignoredMode paths) now checks before calling `closeTab`.
- Dedup: replaced the synchronous `dedupAll` reducer call in `Windows.tsx` with a new `dedupTabsAsync` thunk that checks each duplicate tab before closing.
- Butterbar: added `notification` state to `uiSlice.ts` and a `<Butterbar />` component in `Main.tsx` that auto-dismisses after 4 seconds. Both eye-button and dedup paths dispatch `setNotification(...)` when tabs are skipped.
- Verified: 99 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-04-21T160000Z

- Completed: **Ignored tabs now use PageRow with ignoredMode prop** — deleted `IgnoredTabRow.tsx`; added `ignoredMode?: boolean` to `PageRowProps`; added an early-return render path in `PageRow` (after all hooks) that shows only the open/close eye button, favicon, and title with 0.55 opacity (`.pagerow__ignored` class). No tags, no selection, no context menu.
- Logic fix: `WindowTabList.tsx` and `Windows.tsx` now check `shouldIgnore(url)` before showing a tab in ignoredMode. Truly ignored tabs (chrome://, meet.google.com, etc.) get a synthetic `Page` built from tab data and render via `PageRow ignoredMode`. Non-ignored tabs without DB entries (transient loading state) are now hidden (`return null`) instead of incorrectly showing as "ignored".
- Flat view filter in `Windows.tsx` updated to only include pageless tabs where `shouldIgnore()` is true, so they appear in the list.
- Verified: 102 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-04-21T140000Z

- Completed: **"Syncing from browser" slow/wedged for batch of 10** — two root causes fixed in `updateFromBrowser` (`pages.ts`):
  1. **Single huge `modify()` lock**: `db.pages.where('visible').equals('active').modify()` held ONE write lock on `db.pages` for the entire duration. With thousands of pages stuck as 'active' from prior service-worker kills (MV3 can kill SW mid-sync), this blocked for minutes. Fixed: replaced with `primaryKeys()` read (no lock) + batched `anyOf().modify()` in 200-page chunks.
  2. **`Promise.all` with concurrent `rw` transactions**: 50 concurrent `ensurePageTracked` calls all tried to acquire `rw` locks on `[db.pages, db.tags]`. IndexedDB serialises these anyway, so concurrency gave zero throughput benefit but added transaction-queuing overhead. Fixed: simple sequential `for` loop.
- Also fixed: `totalTabs` now counts only non-ignored tabs (was including `chrome://` etc.), so the progress fraction is correct. `clearProgress` is now `await`-ed in the `finally` block.
- Verified: 102 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-04-21T120000Z

- Completed: **Show ignored tabs on Open page** — tabs filtered by `shouldIgnore()` (chrome://, chrome-extension://, Google Meet, Calendar, etc.) now appear in the Open view as read-only `IgnoredTabRow` entries (dimmed, close-button only). They are never written to the DB.
- Key design: `normalizeUrl.ts` now passes through non-http(s) URLs unchanged (prevents garbage from normalize-url's `forceHttps` option). `updateFromBrowser` and `handleTabActivated` each got a `shouldIgnore()` guard. `overwriteActiveTabs` and `chromeTabsListeners` no longer filter ignored tabs from Redux state. `WindowTabList` early-return fixed: `pages === undefined` (loading) vs `pages.length === 0` (all ignored — still render).
- Discovered: `handleTabActivated` in `dbSync.ts` was missing a `shouldIgnore()` guard — activating a `chrome://` tab would have called `ensurePageTracked` and written a garbage entry (or errored). Fixed.
- Discovered: `updateFromBrowser` was also calling `normalizeUrl` on `chrome://` URLs (which previously would mangle them via `forceHttps`). Fixed by adding `shouldIgnore` guard and the normalizeUrl passthrough.
- Verified: 102 tests pass, `npm run build` clean.
- Next session: No Approved tasks remain.

## 2026-04-20T200000Z

- Completed: **Highlight selected rows more visibly, even if they are also open** — root cause was CSS ordering: `.pagerow__open` was defined after `.pagerow__selected` in PageRow.less, so the subtle blue open-background overrode the selected background when both classes were present. Fixed by moving `__selected` after `__open` in the stylesheet so it wins on equal specificity. Also brightened `@bgselected` from `rgb(73,72,61)` to `rgb(85,80,42)` and added `box-shadow: inset 3px 0 0 rgb(200,180,60)` as a left accent bar to make selection unmistakable regardless of open/focused state.
- Verified: 102 tests pass, no TypeScript errors.
- Next session: No Approved tasks remain.

## 2026-04-20T180000Z

- Completed: **Revert "Needs Review" todo feature** — removed `'review'` from `completionStatus` union type in `db.ts` and `pages.ts`; reverted `updateTodoCompletion` signature to `'pending' | 'completed'`; removed `reviewTodos` query and "Needs Review" section from `TodoView.tsx`; removed purple review badge and "Mark as Needs Review"/"Mark as Pending" context menu items from `PageRow.tsx`. DB schema v18 (completionStatus index) retained — it was pre-existing and supports the remaining pending/completed todo queries.
- Verified: 98 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-04-20T120000Z

- Completed: **Double-click on todo opens page** — removed the `completionStatus !== undefined` guard; double-click on any row always calls `activateOrOpenTab`. Partial implementation was already committed.
- Completed: **"Needs Review" todo state** — added `'review'` as a third `completionStatus` value in `db.ts` and `pages.ts`; updated `updateTodoCompletion` type; added context menu items "Mark as Needs Review" / "Mark as Pending" in PageRow.tsx; added a collapsible "Needs Review" section in TodoView.tsx with Approve/Complete/Remove action buttons. The `?` badge (purple) shows in page rows for review todos.
- Completed: **Open-tab visibility indicator** — moved eye/eye-slash button to the left of the selection checkbox in PageRow.tsx (always visible, not hover-only); eye icon = open (click to close), eye-slash = closed (click to open); removed the old "Close Tab" button from PageTags.tsx; added subtle blue background (`rgba(100,160,255,0.07)`) to `pagerow__open` rows in PageRow.less.
- Discovered: All 102 tests pass. TypeScript clean. One test showed a transient `window.location.protocol` failure on the first full run but passes consistently in isolation and on re-run — likely a jsdom test-ordering flake.
- Next session: No Approved tasks remain.

## 2026-04-20T000000Z

- Completed: **Async operation clogging / Dexie stack overflow** — fixed four root causes (see below)
- Root cause 1: `renormalizeAll` held an exclusive `rw` write lock on `db.progress` and `db.logs` for every 100-page batch. Fixed: narrowed transaction scope to `[db.pages]` only. The progress callback is called outside the transaction, and logger uses `Dexie.ignoreTransaction`.
- Root cause 2: `addTag`, `removeTag`, `setTags`, `stash`, `unstash`, `stashTabs`, `pageOpenedFrom`, `accessTimeAdjacent` all included `db.progress` and/or `db.logs` in their `rw` transactions unnecessarily. Fixed: removed them (and dropped the fire-and-forget inner `reportProgress` calls that required `db.progress` in scope, since they reported intermediate state inside an atomic transaction the UI can't observe anyway).
- Root cause 3: `handleCleanupGraphRefs` single-URL mode used `filter().modify()` over the full pages table in ONE write transaction — holding an exclusive lock on `db.pages` for potentially tens of seconds, blocking backup reads and similarity-worker reads. Fixed: replaced with a two-pass approach — batched reads (500/batch) to find referencing pages, then targeted writes (100/batch) to those pages only.
- Root cause 4: The `db.operations` table accumulated completed/failed operations forever. A large table slows `chunkedExportDB` (backup reads all rows) and can hit Dexie stack limits. Fixed: `cleanupOldOperations()` runs at startup and deletes completed/failed ops older than 7 days in batches of 100.
- All 102 tests pass; `tsc --noEmit` clean.
- Next session: No Approved tasks remain.


## 2026-04-20T210000Z

- Completed: **Make open-page background highlight more prominent** — bumped `rgba(100, 160, 255, 0.07)` to `rgba(100, 160, 255, 0.13)` in PageRow.less. Roughly 85% brighter; still subtle enough not to clash with selected/focused states.
- Verified: 102 tests pass.
- Next session: No Approved tasks remain.

## 2026-04-20T220000Z

- Completed: **MiniSearch "cannot discard document" error on addTag** — root cause was `pageSearchIndex.ts`'s `updating` hook calling `searchIndex.discard(url)` unconditionally. Pages created between the initial `toArray()` snapshot and hook registration (or any race with `addAllAsync` yielding between chunks) would be in DB but not in the index; updating them threw. Fix: replaced `discard` + `add` with the same `has()` → `replace` / `add` pattern already used in the `creating` hook.
- Verified: 102 tests pass.
- Next session: No Approved tasks remain.

## 2026-04-21T000000Z

- Completed: **Dexie stack overflow when selecting a page on the All tab** — three root causes fixed:
  1. `refreshHotness` (`pages.ts`): had `await setTimeout(0)` INSIDE a `db.transaction('rw', [db.pages, db.progress, db.logs])`. `setTimeout` yields to the macrotask queue, causing IDBTransaction to auto-commit mid-loop. Fixed: each 500-page batch now has its own narrow `db.transaction('rw', [db.pages])`, the yield runs between transactions, and `db.progress`/`db.logs` removed from scope.
  2. `deleteTag`/`renameTag` (`tags.ts`): used `db.pages.toCollection().modify()` inside a broad transaction — the exact documented cause of Dexie call-stack overflow on large DBs. Fixed: replaced with batched bulkGet+bulkPut per 500-URL chunk, each in its own narrow `[db.pages]` transaction.
  3. `PageRow` called `useLiveQuery(getSimilarityScaler)` per row, creating N concurrent Dexie subscriptions on the All tab (N = page count). Fixed: lifted to `RawPageList` (single subscription) and passed as prop.
- Verified: all 11 test suites pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.

## 2026-04-21T000000Z

- Completed: **"Syncing from browser" hung at 100/129 + 226875 similarity task investigation** — two root causes found and fixed:
  1. Stale progress entries: Chrome (MV3) can kill the service worker mid-sync; the `db.progress` entry at `100/129` persists in IndexedDB until the next startup's `clearAllProgress()`. The Tabvana UI (still open in the main window) keeps showing the orphaned entry. Fixed by filtering entries where `updatedAt > 30s` in `useActiveProgress` and `useBackgroundProgress`.
  2. Visual gap: progress only updates at multiples of 10. After the last chunk, the remaining tabs are processed silently and the UI shows the stale value until `clearProgress` removes the entry. Fixed by adding a forced final `reportProgress(current=processed, total=processed)` write after the chunk loop — `current === total` bypasses the 100ms throttle and writes immediately.
- Discovered: **226875 similarity tasks is NOT a leak.** Formula is `fetchAllCount() * selectedPages.length` = 605 total pages × 375 selected pages. Computation completes cleanly, `clearProgress(taskId, true)` removes all state. No persistent artifacts. The large number likely means the user had 375 pages selected (e.g. select-all in a large tag group).
- Next session: No Approved tasks remain.

## 2026-04-20T230000Z

- Completed: **Backup freshness text + pulsating save icon + exclude backup from spinner** — three sub-features in GlobalButtons.tsx and progress.ts:
  1. `useActiveProgress()` in `progress.ts` now filters out `type === 'backup'` operations so they don't count toward the async spinner.
  2. `GlobalButtons.tsx` queries `getLatestBackup` and `getBackupStatus` via `useLiveQuery`; renders "Backed up X minutes/hours/days ago" text (with a 30s interval ticker for freshness).
  3. A pulsating MUI `Box`-wrapped `faFloppyDisk` icon appears next to the freshness text only when `backupStatus === BackupStatus.BackupInProgress`.
- Verified: 102 tests pass, `tsc --noEmit` clean.
- Next session: No Approved tasks remain.
