diff --git a/ui/src/__tests__/activity-store.test.ts b/ui/src/__tests__/activity-store.test.ts index 8fbc4938..c2dfd90f 100644 --- a/ui/src/__tests__/activity-store.test.ts +++ b/ui/src/__tests__/activity-store.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createPinia, setActivePinia } from "pinia"; +import { watch } from "vue"; // Activity store tests (SPEC s11.4; DESIGN s8.3). The seams are // `@tauri-apps/api/core`'s `invoke` (every typed IPC wrapper routes through it) @@ -50,7 +51,12 @@ vi.mock("@tauri-apps/api/event", () => ({ }), })); -import { useActivityStore, ACTIVITY_PAGE_SIZE, LIVE_TAIL_CAP } from "../stores/activity"; +import { + useActivityStore, + ACTIVITY_PAGE_SIZE, + ACTIVITY_RENDER_WINDOW, + LIVE_TAIL_CAP, +} from "../stores/activity"; import type { ActivityEntry } from "../ipc/types"; function makeEntry(over: Partial = {}): ActivityEntry { @@ -164,8 +170,11 @@ describe("activity store: pagination", () => { await store.subscribeLive(); await store.loadInitial(); - // A live event arrives for id 150 before it is paged in. + // A live event arrives for id 150 before it is paged in. Issue #45: live + // events are buffered + coalesced, so flush to apply the burst before + // asserting the rendered tail. liveHandler?.(makeEntry({ id: 150, ts: 900 })); + store.flushLive(); expect(store.entries.map((e) => e.id)).toEqual([150, 200]); // Page 1 includes id 150 again - it must NOT be duplicated. @@ -188,6 +197,8 @@ describe("activity store: live tail", () => { expect(store.total).toBe(1); liveHandler?.(makeEntry({ id: 2, ts: 2000 })); + // Issue #45: the burst is buffered; flush to apply it in one update. + store.flushLive(); expect(store.entries[0].id).toBe(2); expect(store.entries.map((e) => e.id)).toEqual([2, 1]); expect(store.total).toBe(2); @@ -209,11 +220,13 @@ describe("activity store: live tail", () => { const store = useActivityStore(); await store.subscribeLive(); await store.applyFilter({ minLevel: "error" }); - // An info-level live event must be dropped under a min-level=error filter. + // An info-level live event must be dropped under a min-level=error filter + // (filtered out at ingest, never buffered). liveHandler?.(makeEntry({ id: 9, level: "info" })); expect(store.entries).toHaveLength(0); - // A matching error-level event is kept. + // A matching error-level event is kept (buffered, applied on flush). liveHandler?.(makeEntry({ id: 10, level: "error" })); + store.flushLive(); expect(store.entries.map((e) => e.id)).toEqual([10]); }); @@ -343,6 +356,7 @@ describe("activity store: lag reconcile (M7-P1-1)", () => { for (const row of allRows.slice(0, ACTIVITY_PAGE_SIZE)) { liveHandler?.(row); } + store.flushLive(); expect(store.entries).toHaveLength(ACTIVITY_PAGE_SIZE); const pageOf = (p: number) => @@ -385,6 +399,8 @@ describe("activity store: live-tail cap (M7-P2-2)", () => { for (let i = 1; i <= LIVE_TAIL_CAP + overflow; i++) { liveHandler?.(makeEntry({ id: i, ts: i })); } + // Issue #45: apply the buffered burst, then assert the bound holds. + store.flushLive(); // The store is bounded to the cap (oldest live entries evicted). expect(store.entries).toHaveLength(LIVE_TAIL_CAP); // Newest is the last pushed; the oldest retained is id overflow+1. @@ -402,6 +418,7 @@ describe("activity store: live-tail cap (M7-P2-2)", () => { for (let i = 2; i <= LIVE_TAIL_CAP + 100; i++) { liveHandler?.(makeEntry({ id: i, ts: i })); } + store.flushLive(); // Live tail capped at CAP, but the loaded history row survives at the tail. expect(store.entries.length).toBe(LIVE_TAIL_CAP + 1); expect(store.entries[store.entries.length - 1].id).toBe(1); @@ -489,6 +506,37 @@ describe("activity store: request token (M7-P2-1)", () => { expect(store.total).toBe(1); expect(store.loadedPage).toBe(0); }); + + it("does NOT double-count total when a live event arrives during loadInitial (issue #45 codex P2)", async () => { + const store = useActivityStore(); + await store.subscribeLive(); + + // loadInitial's query is slow; capture its resolver so we can inject a live + // event while the page is still in flight. + let resolvePage: (v: unknown) => void = () => {}; + invokeMock.mockImplementationOnce( + () => + new Promise((res) => { + resolvePage = res; + }) + ); + const load = store.loadInitial(); + + // A live `activity:new` lands while the page query is awaiting; its durable + // row is already part of the backend's authoritative total below. + liveHandler!(makeEntry({ id: 7, ts: 5000 })); + + // The page shows 2 of 10 total rows; the live row (id 7) is one of the other + // 8 the backend already counted in `total: 10`. + resolvePage(makePage([makeEntry({ id: 6, ts: 600 }), makeEntry({ id: 5, ts: 500 })], 0, 10)); + await load; + store.flushLive(); + + // The authoritative server total must win - NOT total + the live delta (11). + expect(store.total).toBe(10); + // ...and the buffered live row is not lost from the tail. + expect(store.entries.map((e) => e.id)).toContain(7); + }); }); describe("activity store: backend facets + summary (M7-P2-4, P2-5)", () => { @@ -665,3 +713,162 @@ describe("activity store: recheck-3 polish (M7-R3-P2)", () => { expect(store.entries.map((e) => e.id)).toEqual([200, 100]); }); }); + +describe("activity store: batched live ingestion (issue #45)", () => { + it("buffers a burst and applies it in ONE reactive update to entries AND total", async () => { + invokeMock.mockResolvedValueOnce(makePage([], 0, 0)); + const store = useActivityStore(); + await store.subscribeLive(); + await store.loadInitial(); + + // Count how many times the rendered list and the total actually change. + // `flush: "sync"` fires the watcher on every reactive mutation, so a per-event + // mutation (the pre-fix behavior) would push N entries here, not 1. + const entriesUpdates: number[] = []; + const totalUpdates: number[] = []; + const stopEntries = watch( + () => store.entries.length, + (len) => entriesUpdates.push(len), + { flush: "sync" } + ); + const stopTotal = watch( + () => store.total, + (t) => totalUpdates.push(t), + { flush: "sync" } + ); + + const N = 50; + for (let i = 1; i <= N; i++) { + liveHandler?.(makeEntry({ id: i, ts: i, eventType: "upload_done", bytes: null })); + } + + // The whole burst is buffered: NOT yet reflected in the reactive state. + expect(entriesUpdates).toHaveLength(0); + expect(totalUpdates).toHaveLength(0); + expect(store.entries).toHaveLength(0); + + // A single coalesced flush applies the burst as exactly ONE update each. + store.flushLive(); + expect(entriesUpdates).toEqual([N]); + expect(totalUpdates).toEqual([N]); + + // Ordering preserved (newest-first) and no rows dropped. + expect(store.entries).toHaveLength(N); + expect(store.entries[0].id).toBe(N); + expect(store.entries[N - 1].id).toBe(1); + expect(store.total).toBe(N); + + stopEntries(); + stopTotal(); + }); + + it("dedups within a buffered burst (no duplicate rows, total counts once)", async () => { + invokeMock.mockResolvedValueOnce(makePage([], 0, 0)); + const store = useActivityStore(); + await store.subscribeLive(); + await store.loadInitial(); + + // id 7 arrives twice in the same burst; the second is dropped at ingest. + liveHandler?.(makeEntry({ id: 7, ts: 700 })); + liveHandler?.(makeEntry({ id: 8, ts: 800 })); + liveHandler?.(makeEntry({ id: 7, ts: 700 })); + store.flushLive(); + + expect(store.entries.map((e) => e.id)).toEqual([8, 7]); + expect(store.total).toBe(2); + }); + + it("auto-flushes the buffer on the next frame via the scheduler", async () => { + vi.useFakeTimers(); + try { + invokeMock.mockResolvedValue(undefined); + const store = useActivityStore(); + await store.subscribeLive(); + + for (let i = 1; i <= 10; i++) { + liveHandler?.(makeEntry({ id: i, ts: i, bytes: null })); + } + // No frame has elapsed yet: still buffered. + expect(store.entries).toHaveLength(0); + + // The node test env has no requestAnimationFrame, so the store falls back to + // a ~1-frame setTimeout; advancing past it flushes the burst once. + await vi.advanceTimersByTimeAsync(20); + expect(store.entries).toHaveLength(10); + expect(store.entries[0].id).toBe(10); + expect(store.total).toBe(10); + } finally { + vi.useRealTimers(); + } + }); + + it("eagerly flushes when the buffer reaches the cap, keeping newest-first + bound", async () => { + invokeMock.mockResolvedValueOnce(makePage([], 0, 0)); + const store = useActivityStore(); + await store.subscribeLive(); + await store.loadInitial(); + + // Fire a burst LARGER than the cap WITHOUT any manual flush: the eager + // at-cap flush keeps memory bounded even if no frame fires mid-burst. + const overflow = 25; + for (let i = 1; i <= LIVE_TAIL_CAP + overflow; i++) { + liveHandler?.(makeEntry({ id: i, ts: i, bytes: null })); + } + // Drain the trailing partial buffer (the last < cap events). + store.flushLive(); + + expect(store.entries).toHaveLength(LIVE_TAIL_CAP); + // Newest retained at the front, oldest `overflow` evicted. + expect(store.entries[0].id).toBe(LIVE_TAIL_CAP + overflow); + expect(store.entries[store.entries.length - 1].id).toBe(overflow + 1); + // total counts every ingested event (eviction does not decrement it). + expect(store.total).toBe(LIVE_TAIL_CAP + overflow); + }); + + it("flushes the buffer on unsubscribe so state stays consistent", async () => { + invokeMock.mockResolvedValueOnce(makePage([], 0, 0)); + const store = useActivityStore(); + await store.subscribeLive(); + await store.loadInitial(); + + liveHandler?.(makeEntry({ id: 1, ts: 1, bytes: null })); + liveHandler?.(makeEntry({ id: 2, ts: 2, bytes: null })); + // Not yet applied (buffered). + expect(store.entries).toHaveLength(0); + + store.unsubscribeLive(); + // Teardown drains the buffer so entries / total stay in sync. + expect(store.entries.map((e) => e.id)).toEqual([2, 1]); + expect(store.total).toBe(2); + }); +}); + +describe("activity store: render window (issue #45)", () => { + it("ACTIVITY_RENDER_WINDOW bounds the rendered slice below the live-tail cap", () => { + // The view renders entries.slice(0, ACTIVITY_RENDER_WINDOW); that window must + // be well under the live-tail cap so the mounted DOM never grows to ~1000. + expect(ACTIVITY_RENDER_WINDOW).toBeGreaterThan(0); + expect(ACTIVITY_RENDER_WINDOW).toBeLessThan(LIVE_TAIL_CAP); + }); + + it("the windowed slice keeps the newest rows and is capped at the window size", async () => { + invokeMock.mockResolvedValueOnce(makePage([], 0, 0)); + const store = useActivityStore(); + await store.subscribeLive(); + await store.loadInitial(); + + const burst = ACTIVITY_RENDER_WINDOW + 80; + for (let i = 1; i <= burst; i++) { + liveHandler?.(makeEntry({ id: i, ts: i, bytes: null })); + } + store.flushLive(); + + // The store holds more than one window of entries... + expect(store.entries.length).toBe(burst); + // ...but a render window slices only the newest ACTIVITY_RENDER_WINDOW rows. + const windowed = store.entries.slice(0, ACTIVITY_RENDER_WINDOW); + expect(windowed).toHaveLength(ACTIVITY_RENDER_WINDOW); + expect(windowed[0].id).toBe(burst); + expect(windowed[windowed.length - 1].id).toBe(burst - ACTIVITY_RENDER_WINDOW + 1); + }); +}); diff --git a/ui/src/__tests__/activity-window.test.ts b/ui/src/__tests__/activity-window.test.ts new file mode 100644 index 00000000..acfb9c52 --- /dev/null +++ b/ui/src/__tests__/activity-window.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { mount, flushPromises } from "@vue/test-utils"; +import { nextTick } from "vue"; + +import { i18n } from "../i18n"; +import { useActivityStore, ACTIVITY_RENDER_WINDOW } from "../stores/activity"; +import type { ActivityEntry } from "../ipc/types"; + +// Issue #45: the Activity table must NOT mount one row per accumulated entry - +// a high-rate upload can buffer ~1000 live rows, and rendering them all makes the +// page janky while scrolling. The view renders only the newest +// `ACTIVITY_RENDER_WINDOW` rows and grows the window on demand. This mounts the +// real Activity.vue against faked IPC/event seams, floods the live tail past the +// window, and asserts the MOUNTED row count stays bounded while the store holds +// strictly more entries - then that "load more" reveals the next window. + +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args), +})); + +// Capture the `activity:new` handler so the test can fire a live burst. +let liveHandler: ((payload: unknown) => void) | null = null; +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (event: string, cb: (e: { payload: unknown }) => void) => { + if (event === "activity:new") { + liveHandler = (payload: unknown) => cb({ payload }); + } + return () => undefined; + }), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), + save: vi.fn(), +})); +vi.mock("vue-router", () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ params: {} }), +})); + +import Activity from "../views/Activity.vue"; + +function makeEntry(over: Partial = {}): ActivityEntry { + return { + id: 1, + ts: 1000, + sourceId: null, + level: "info", + eventType: "upload_done", + fileCount: null, + bytes: null, + message: null, + ...over, + }; +} + +beforeEach(() => { + setActivePinia(createPinia()); + invokeMock.mockReset(); + liveHandler = null; + // Every on-mount IPC call resolves to a benign empty shape (no history rows). + invokeMock.mockImplementation((cmd: string) => { + switch (cmd) { + case "query_activity": + return Promise.resolve({ + entries: [], + total: 0, + limit: 100, + hasMore: false, + nextBeforeTs: null, + nextBeforeId: null, + }); + case "distinct_activity_event_types": + return Promise.resolve(["upload_done"]); + case "activity_summary": + return Promise.resolve({ + bytesToday: 0, + bytesWeek: 0, + fileStatusCounts: [], + throughputWindowBytes: 0, + throughputWindowMs: 60000, + }); + case "list_sources": + return Promise.resolve([]); + default: + return Promise.resolve(undefined); + } + }); +}); + +describe("Activity render window (issue #45)", () => { + it("bounds the mounted rows to the render window even with a much larger live tail", async () => { + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + const store = useActivityStore(); + + // Flood the live tail with more than one render window of events. + const burst = ACTIVITY_RENDER_WINDOW + 90; + for (let i = 1; i <= burst; i++) { + liveHandler?.(makeEntry({ id: i, ts: i })); + } + // Apply the coalesced burst, then let the view re-render. + store.flushLive(); + await nextTick(); + + // The store holds the whole tail... + expect(store.entries.length).toBe(burst); + // ...but the DOM only mounts the newest ACTIVITY_RENDER_WINDOW rows. + const rows = wrapper.findAll('[data-testid="activity-row"]'); + expect(rows).toHaveLength(ACTIVITY_RENDER_WINDOW); + + // "Load more" is offered because more buffered rows exist than are rendered. + const loadMore = wrapper.find('[data-testid="activity-load-more"]'); + expect(loadMore.exists()).toBe(true); + + // Revealing the next window grows the mounted rows by one window (capped at + // the number of entries actually held). + await loadMore.trigger("click"); + await nextTick(); + const rowsAfter = wrapper.findAll('[data-testid="activity-row"]'); + expect(rowsAfter.length).toBe(Math.min(burst, ACTIVITY_RENDER_WINDOW * 2)); + expect(rowsAfter.length).toBeGreaterThan(ACTIVITY_RENDER_WINDOW); + + wrapper.unmount(); + }); + + it("renders all rows (no window button) when the tail fits in the window", async () => { + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + const store = useActivityStore(); + const fits = ACTIVITY_RENDER_WINDOW - 5; + for (let i = 1; i <= fits; i++) { + liveHandler?.(makeEntry({ id: i, ts: i })); + } + store.flushLive(); + await nextTick(); + + expect(store.entries.length).toBe(fits); + const rows = wrapper.findAll('[data-testid="activity-row"]'); + expect(rows).toHaveLength(fits); + // Nothing more to reveal and no more history -> no "load more". + expect(wrapper.find('[data-testid="activity-load-more"]').exists()).toBe(false); + + wrapper.unmount(); + }); +}); diff --git a/ui/src/stores/activity.ts b/ui/src/stores/activity.ts index c6600a74..227ff53d 100644 --- a/ui/src/stores/activity.ts +++ b/ui/src/stores/activity.ts @@ -23,6 +23,14 @@ export const ACTIVITY_PAGE_SIZE = 100; * NOT subject to this cap (they live in a separate list). */ export const LIVE_TAIL_CAP = 1000; +/** Issue #45: the number of activity rows the view mounts at once. The store can + * hold up to LIVE_TAIL_CAP (1000) live entries plus paged history, but rendering + * that many table rows makes the page janky while an upload streams new rows in. + * The view renders only the newest `slice(0, ACTIVITY_RENDER_WINDOW)` of the + * accumulated entries and grows the window on demand (the "load more" control), + * so the mounted DOM stays bounded no matter how large the live tail grows. */ +export const ACTIVITY_RENDER_WINDOW = 200; + /** M7-P2-5: the recent-throughput window the header summary uses (ms). The * backend sums `activity_log.bytes` over this window; the UI divides by the * window seconds for a current bytes/sec rate. */ @@ -52,6 +60,12 @@ const LEVEL_RANK: Record = { * the oldest live entry on overflow - so an error storm can never grow the * store / DOM unbounded, while loaded history pages are preserved. * + * Issue #45: `activity:new` events do NOT mutate `liveEntries` (or `total`) + * per event - a high-rate upload would re-render the table once per event. They + * are appended to a non-reactive buffer and a single coalesced flush per + * animation frame applies the whole burst in ONE reactive update (newest-first, + * deduped, capped), so a burst costs one render, not one per row. + * * The rendered `entries` is `liveEntries` (newest, deduped against history) * followed by `historyEntries`; both ingestion paths dedup by row id so a row * that arrives live and is later paged in (or vice versa) appears exactly once. @@ -99,9 +113,108 @@ export const useActivityStore = defineStore("activity", () => { // M7-P2-5: the header aggregate summary (null until first load). const summary = ref(null); - // Membership index by row id so dedup is O(1) across both lists. + // Membership index by row id so dedup is O(1) across both lists. A plain Set + // (NOT reactive) so the per-event dedup bookkeeping never triggers a render. const seenIds = new Set(); + // --- Issue #45: batched live-event ingestion ----------------------------- + // A high-rate upload emits many `activity:new` events, each arriving in its + // OWN event-loop task. Applying each one straight to the reactive `liveEntries` + // (and bumping the reactive `total`) re-queues the component render per event, + // so a burst of N events costs N renders + N v-for diffs -> jank, especially + // while scrolling. Instead each event is appended to a NON-reactive buffer and + // a single coalesced flush per animation frame applies the whole burst in ONE + // reactive update (one `liveEntries` assignment + one `total` write). Ordering, + // the seenIds dedup, the lagged-reconcile path, and the LIVE_TAIL_CAP eviction + // are all preserved; no event is dropped or duplicated. + // + // The buffer holds entries in ARRIVAL order (oldest arrival first, newest + // last), mirroring the backend's emission order; the flush reverses it so the + // newest arrival lands at the FRONT of the newest-first tail. + const pendingLive: ActivityEntry[] = []; + // The not-yet-applied increment to `total` for the buffered events (applied in + // the same synchronous flush as the `liveEntries` assignment, so `total` - a + // render dependency via the count summary - mutates ONCE per burst, not once + // per event). + let pendingTotalDelta = 0; + // True while a frame flush is queued (flag-based so a stale frame callback that + // fires after a manual flush / cancel simply no-ops; no handle to track). + let flushScheduled = false; + + // Schedule a callback for the next paint. `requestAnimationFrame` aligns the + // flush with rendering in the app (browser); under vitest's node environment + // rAF is undefined, so fall back to a ~1-frame timer that fake timers drive. + const scheduleFrame: (cb: () => void) => void = + typeof requestAnimationFrame === "function" + ? (cb) => { + requestAnimationFrame(cb); + } + : (cb) => { + setTimeout(cb, 16); + }; + + /** Trim `list` (newest-first) to LIVE_TAIL_CAP, returning the kept newest + * slice and dropping each evicted id from the dedup index unless it is also in + * loaded history. Eviction is from the END (the oldest live entries). */ + function trimLiveTail(list: ActivityEntry[]): ActivityEntry[] { + if (list.length <= LIVE_TAIL_CAP) return list; + const kept = list.slice(0, LIVE_TAIL_CAP); + for (const evicted of list.slice(LIVE_TAIL_CAP)) { + if (!historyEntries.value.some((e) => e.id === evicted.id)) { + seenIds.delete(evicted.id); + } + } + return kept; + } + + /** Apply the buffered live events to the reactive tail in ONE update: prepend + * them newest-first, apply the accumulated `total` delta, and cap the tail. + * Idempotent - a no-op when nothing is buffered. Exposed so the view can drain + * the buffer on teardown and tests can flush deterministically. */ + function flushLive(): void { + flushScheduled = false; + if (pendingLive.length === 0) { + // Still settle any count delta accrued by dropped-then-empty edge paths. + if (pendingTotalDelta !== 0) { + total.value += pendingTotalDelta; + pendingTotalDelta = 0; + } + return; + } + // Arrival order is oldest-first; reverse so the newest arrival leads, then + // prepend the existing newest-first tail. A single reactive assignment. + const incoming = pendingLive.splice(0).reverse(); + liveEntries.value = trimLiveTail([...incoming, ...liveEntries.value]); + if (pendingTotalDelta !== 0) { + total.value += pendingTotalDelta; + pendingTotalDelta = 0; + } + } + + /** Queue a coalesced flush for the next frame (idempotent). If the buffer + * reaches a full tail's worth before a frame fires (e.g. the window is + * backgrounded so rAF is throttled), flush eagerly so memory stays bounded - + * no event is dropped, the flush trims to LIVE_TAIL_CAP exactly as a + * steady-state flush would. */ + function scheduleFlush(): void { + if (pendingLive.length >= LIVE_TAIL_CAP) { + flushLive(); + return; + } + if (flushScheduled) return; + flushScheduled = true; + scheduleFrame(() => { + if (flushScheduled) flushLive(); + }); + } + + /** Drop a pending flush and clear the buffer (used on reset/teardown). */ + function discardPendingLive(): void { + flushScheduled = false; + pendingLive.length = 0; + pendingTotalDelta = 0; + } + // M7-P2-1: the request generation. Bumped on every (re)load; a response whose // token is stale (a newer load started) or whose filter snapshot no longer // matches the current filter is discarded. @@ -120,6 +233,7 @@ export const useActivityStore = defineStore("activity", () => { /** Reset all accumulated state (entries, dedup index, paging, live tail). */ function reset(): void { + discardPendingLive(); historyEntries.value = []; liveEntries.value = []; seenIds.clear(); @@ -164,26 +278,6 @@ export const useActivityStore = defineStore("activity", () => { return true; } - /** Evict oldest live entries until the tail is within `LIVE_TAIL_CAP`, - * dropping each evicted id from the dedup index unless it is also in loaded - * history. The live tail is kept newest-first, so eviction is from the END. */ - function capLiveTail(): void { - while (liveEntries.value.length > LIVE_TAIL_CAP) { - const evicted = liveEntries.value.pop(); - if (evicted && !historyEntries.value.some((e) => e.id === evicted.id)) { - seenIds.delete(evicted.id); - } - } - } - - /** Prepend a live entry (it is the newest, from `activity:new`), capping the - * live tail to `LIVE_TAIL_CAP`. */ - function pushLive(entry: ActivityEntry): void { - seenIds.add(entry.id); - liveEntries.value.unshift(entry); - capLiveTail(); - } - /** M7-R3-P2 (recheck-3): record an event type seen on a live / recovered row * into the filter dropdown source if it is not already there. Without this a * NEW event type that first appears live (or via lag reconcile) shows up in @@ -197,23 +291,30 @@ export const useActivityStore = defineStore("activity", () => { /** R2-P1-1: merge reconciled `rows` (recovered durable rows that the live * broadcast dropped) into the live tail, keeping it strictly newest-first. - * Unlike `pushLive`, recovered rows can be OLDER than rows already in the tail - * (a ring-buffer drop evicts the OLDEST of a burst, so the recovered rows sit - * below the latest delivered), so they must be INSERTED in sort order, not + * Unlike a live prepend, recovered rows can be OLDER than rows already in the + * tail (a ring-buffer drop evicts the OLDEST of a burst, so the recovered rows + * sit below the latest delivered), so they must be INSERTED in sort order, not * blindly prepended. Dedup by id; then re-sort + cap. */ function mergeRecoveredLive(rows: ActivityEntry[]): void { + // Issue #45: apply any buffered live events FIRST so the merge operates on + // an up-to-date tail (and the pending `total` delta settles) - the recovered + // rows are then sorted into a consistent newest-first order. Unconditional so + // the buffer never lingers across a reconcile even when nothing is recovered. + flushLive(); + const merged = [...liveEntries.value]; let added = false; for (const row of rows) { if (seenIds.has(row.id)) continue; seenIds.add(row.id); - liveEntries.value.push(row); + merged.push(row); added = true; } if (!added) return; // Newest-first: ts desc, then id desc (the same total order the backend - // keyset uses), so the rendered tail stays globally ordered. - liveEntries.value.sort((a, b) => b.ts - a.ts || b.id - a.id); - capLiveTail(); + // keyset uses), so the rendered tail stays globally ordered. One reactive + // assignment, trimmed to the cap. + merged.sort((a, b) => b.ts - a.ts || b.id - a.id); + liveEntries.value = trimLiveTail(merged); } /** Load the first history page for the current filter (resets accumulation). @@ -233,6 +334,12 @@ export const useActivityStore = defineStore("activity", () => { if (token !== requestToken || !sameFilter(snapshot, filter.value)) return; appendHistoryUnique(pageDto.entries); loadedPage.value = 0; + // Issue #45 (codex P2): a live `activity:new` buffered during the await + // above would otherwise have its count added on top of this authoritative + // page total on the next frame, double-counting. Drain the buffer now (so + // no row is lost from the tail) and let the server total supersede the + // pending delta - same flush-first idiom as mergeRecoveredLive. + flushLive(); total.value = pageDto.total; hasMore.value = pageDto.hasMore; } catch (e) { @@ -269,6 +376,10 @@ export const useActivityStore = defineStore("activity", () => { if (token !== requestToken || !sameFilter(snapshot, filter.value)) return; appendHistoryUnique(pageDto.entries); loadedPage.value = next; + // Issue #45 (codex P2): drain any live event buffered during the await so + // the authoritative page total supersedes the pending delta (no double + // count); see loadInitial. + flushLive(); total.value = pageDto.total; hasMore.value = pageDto.hasMore; } catch (e) { @@ -300,12 +411,19 @@ export const useActivityStore = defineStore("activity", () => { } // M7-R3-P2 (recheck-3): expose a brand-new event type to the filter dropdown // even when the row itself is filtered out of the current view, so the user - // can then select it. Done before the dedup / filter gate below. + // can then select it. Done before the dedup / filter gate below. (noteEventType + // only writes on a genuinely NEW type, so a same-type burst stays a no-op.) noteEventType(entry.eventType); if (seenIds.has(entry.id)) return; if (!matchesFilter(entry)) return; - pushLive(entry); - total.value += 1; + // Issue #45: reserve the id (synchronous dedup) and BUFFER the row + its + // count; a single coalesced flush per frame applies the whole burst in one + // reactive update. seenIds + the dropdown facets stay synchronously correct; + // only the reactive tail + total apply on the flush. + seenIds.add(entry.id); + pendingLive.push(entry); + pendingTotalDelta += 1; + scheduleFlush(); } /** M7-P1-1 / R1-P1-2 / R2-P1-1: reconcile from the durable `activity_log` @@ -346,9 +464,9 @@ export const useActivityStore = defineStore("activity", () => { ); // Collect all NEW (not-yet-held, filter-matching) rows across the pages - // FIRST, preserving global newest-first order. They are pushed at the end in - // reverse (oldest first) so the newest overall lands at the FRONT of the live - // tail - pushLive prepends, so pushing oldest-first yields newest-first. + // FIRST, preserving global newest-first order. `mergeRecoveredLive` then + // dedup-merges them into the live tail and re-sorts newest-first, so the + // collection order here only needs to be the natural keyset (newest-first). const recovered: ActivityEntry[] = []; let lastTotal: number | null = null; let cursor: { ts: number; id: number } | null = null; @@ -512,6 +630,9 @@ export const useActivityStore = defineStore("activity", () => { unlistenLagged(); unlistenLagged = null; } + // Issue #45: apply any buffered live events on teardown so liveEntries / + // total / seenIds stay mutually consistent (and cancel the pending frame). + flushLive(); // R1-P2-1: cancel any pending debounced summary refresh on teardown. if (summaryRefreshTimer != null) { clearTimeout(summaryRefreshTimer); @@ -534,6 +655,7 @@ export const useActivityStore = defineStore("activity", () => { loadMore, applyFilter, onLiveEvent, + flushLive, reconcileFromHistory, loadEventTypeOptions, loadSummary, diff --git a/ui/src/views/Activity.vue b/ui/src/views/Activity.vue index 59d7ca32..dc49b599 100644 --- a/ui/src/views/Activity.vue +++ b/ui/src/views/Activity.vue @@ -5,7 +5,7 @@ import { useI18n } from "vue-i18n"; import * as ipc from "../ipc/commands"; import { toErrorCode } from "../ipc/errors"; import { activityEventLabel } from "../stores/activityEventLabel"; -import { useActivityStore } from "../stores/activity"; +import { ACTIVITY_PAGE_SIZE, ACTIVITY_RENDER_WINDOW, useActivityStore } from "../stores/activity"; import { useSourcesStore } from "../stores/sources"; import type { ActivityEntry, ActivityLevel, FileStateStatus } from "../ipc/types"; @@ -106,7 +106,38 @@ const sourceNameById = computed>(() => { return map; }); -const shownCount = computed(() => activity.entries.length); +// Issue #45: bound the rendered DOM. The store can accumulate up to ~1000 live +// entries plus paged history; mounting every row makes the page janky while an +// upload streams new rows in. Render only the newest `renderLimit` rows and grow +// the window on demand, so the mounted row count never grows with the live tail. +const renderLimit = ref(ACTIVITY_RENDER_WINDOW); +const visibleEntries = computed(() => activity.entries.slice(0, renderLimit.value)); + +// Issue #45 (codex P2): "Showing N of total" must reflect the rows actually +// mounted, not everything buffered in the store - otherwise it claims e.g. 290 +// shown while only the windowed `renderLimit` rows are in the DOM (with a "load +// more" control present). +const shownCount = computed(() => visibleEntries.value.length); + +// More accumulated (in-memory) entries exist than are currently rendered. +const canShowMore = computed(() => renderLimit.value < activity.entries.length); +// The "load more" control is available when there are more buffered rows to +// reveal OR more history pages to fetch from the backend. +const canLoadMore = computed(() => canShowMore.value || activity.hasMore); + +/** One progressive-disclosure "load more": first reveal any already-loaded rows + * beyond the render window (instant, no fetch), then page older history from the + * backend (growing the window to keep the freshly fetched page visible). */ +async function loadMoreRows(): Promise { + if (canShowMore.value) { + renderLimit.value += ACTIVITY_RENDER_WINDOW; + return; + } + if (activity.hasMore) { + await activity.loadMore(); + renderLimit.value += ACTIVITY_PAGE_SIZE; + } +} function formatTime(entry: ActivityEntry): string { return dateTimeFormatter.value.format(new Date(entry.ts)); @@ -143,6 +174,8 @@ function sourceLabel(entry: ActivityEntry): string { // Build the filter DTO from the form and apply it (re-query from page 0). async function applyFilters(): Promise { const eventTypes = filterEventType.value.length > 0 ? [filterEventType.value] : []; + // A re-query resets the accumulated rows, so collapse the render window too. + renderLimit.value = ACTIVITY_RENDER_WINDOW; await activity.applyFilter({ sourceId: filterSourceId.value.length > 0 ? filterSourceId.value : null, minLevel: filterLevel.value.length > 0 ? (filterLevel.value as ActivityLevel) : null, @@ -154,6 +187,7 @@ async function clearFilters(): Promise { filterSourceId.value = ""; filterLevel.value = ""; filterEventType.value = ""; + renderLimit.value = ACTIVITY_RENDER_WINDOW; await activity.applyFilter({}); } @@ -425,7 +459,7 @@ onUnmounted(() => { - + {{ formatTime(entry) }} @@ -453,13 +487,13 @@ onUnmounted(() => { -
+