Skip to content

Lazy load feed activity history - #3457

Merged
lawrencecchen merged 2 commits into
mainfrom
task-activity-feed-virtualization
May 4, 2026
Merged

lawrencecchen merged 2 commits into
mainfrom
task-activity-feed-virtualization

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Load only the recent Feed history slice at startup, then page older JSONL rows by byte cursor.
  • Add an Activity footer sentinel that fetches older history while keeping rows below the snapshot boundary.
  • Remove per-render Feed sorting and add localized older-activity loader text.

Testing

  • ./scripts/setup.sh succeeded
  • ./scripts/reload.sh --tag feedvirt succeeded
  • Local unit tests not run per repo policy; coverage was added for GitHub Actions.

Issues

  • Task: User-reported cmux nightly Activity feed lag after switching from Actionable to Activity with 2,288 persisted Feed rows.

Summary by cubic

Lazy-loads Activity feed history using JSONL byte-cursor paging to cut initial load and tab-switch time. Adds a localized “Load older activity” footer that auto-fetches previous pages with a spinner.

  • New Features

    • Introduced paging via loadPage(endingBefore:limit:) with a stable byte cursor; loadRecent now uses it.
    • WorkstreamStore loads a small recent slice at start, exposes hasMorePersistedItems and isLoadingOlderItems, and adds loadOlderItems(); new WorkstreamDefaultInitialLoadLimit and WorkstreamDefaultHistoryPageSize.
    • Feed Activity shows a FeedHistoryLoadMoreRow that auto-fetches on appear and after each page; localized “Load older activity” and “Loading older activity…” (en, ja).
  • Refactors

    • Removed per-render sorting in the Feed list and switched to reversing append order for stability.
    • Added tests for persistence paging and lazy loading.
    • Simplified TerminalController sidebar helpers by removing main-thread sync wrappers.

Written for commit f807533. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Load older activity history progressively with a "Load older activity" action, spinner and automatic on-appear loading.
    • Initial startup loads a recent slice with older pages fetched on demand.
  • Behavior Changes

    • Stable byte-offset cursor keeps paging consistent across appends.
    • Recent-item selection and filtered lists preserve input ordering (then reverse for filtered views).
  • Tests

    • Added tests for paginated persistence and lazy-loading.
  • Localization

    • Added localized strings for the history loader UI.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@vercel

vercel Bot commented May 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 4, 2026 1:14am
cmux-staging Building Building Preview, Comment May 4, 2026 1:14am

@coderabbitai

coderabbitai Bot commented May 4, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds stable, byte-cursor pagination for persisted workstream history: persistence returns a new public Page (items, hasMoreBefore, startOffset); store loads an initial page and exposes loadOlderItems(); UI and view-model wire a “load older” row and new localizations.

Changes

Lazy-Loading Persisted History Pagination

Layer / File(s) Summary
Data Shape
Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift
Adds public struct Page { items: [WorkstreamItem], hasMoreBefore: Bool, startOffset: UInt64? }.
Persistence Implementation
.../WorkstreamPersistence.swift
loadPage(endingBefore:limit:) now returns Page; computes newline-delimited byte ranges via lineRanges(in:baseOffset:), slices/decodes selected JSONL ranges, drops malformed lines, and sets startOffset/hasMoreBefore. loadRecent(limit:) delegates to loadPage.
Store API & State
Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift
Adds WorkstreamDefaultInitialLoadLimit, WorkstreamDefaultHistoryPageSize, and state: hasMorePersistedItems, isLoadingOlderItems, initialLoadLimit, historyPageSize, oldestLoadedPersistenceOffset.
Store Load Flow
.../WorkstreamStore.swift
start() loads an initial Page via persistence.loadPage(limit: min(initialLoadLimit, ringCapacity)). Adds public func loadOlderItems() async to request older pages (endingBefore: oldestLoadedPersistenceOffset), deduplicate by id, prepend unseen items, update cursor and flags, and guard concurrent loads with isLoadingOlderItems.
ViewModel & UI Wiring
Sources/Feed/FeedPanelViewModel.swift, Sources/Feed/FeedPanelView.swift
Adds FeedPanelViewModel mirroring store.items, hasMorePersistedItems, isLoadingOlderItems; exposes loadOlderItems(). FeedPanelView/FeedListView receive pagination flags and onLoadOlderItems callback; history list optionally shows a FeedHistoryLoadMoreRow.
History Load-More Row
Sources/Feed/FeedPanelViewModel.swift, Sources/Feed/FeedPanelView.swift
Introduces FeedHistoryLoadMoreRow view: shows localized label or spinner, triggers action() on appear/visibility and when loading completes, and disables duplicate triggers while loading.
Ordering/Presentation Changes
Sources/Feed/FeedPanelView.swift
lastPromptByWorkstream(_:) iterates input order (no createdAt sort); filtered(_:) returns Array(base.reversed()). Empty-state rendering gated by shouldShowActivityHistoryLoader.
Localization
Resources/Localizable.xcstrings
Adds feed.history.loadOlder and feed.history.loadingOlder with en/ja values.
Tests
Packages/CMUXWorkstream/Tests/.../WorkstreamPersistenceTests.swift, .../WorkstreamStoreTests.swift
Adds loadPageBeforeCursor() to validate cursored paging and lazyLoadPersistedHistory() to validate initial bounded load plus subsequent loadOlderItems() calls.

Sequence Diagram(s)

sequenceDiagram
    participant UI as User / UI
    participant VM as FeedPanelViewModel
    participant Store as WorkstreamStore
    participant Persist as WorkstreamPersistence
    participant File as JSONL File

    UI->>VM: arm() / init
    VM->>Store: start()
    Store->>Persist: loadPage(endingBefore: nil, limit: N)
    Persist->>File: read tail, compute newline byte ranges
    Persist-->>Store: Page(items:[recent], hasMoreBefore:true, startOffset:X)
    Store-->>VM: update items, hasMorePersistedItems
    VM-->>UI: render feed (show load-more)

    rect rgba(100, 200, 100, 0.5)
    UI->>VM: loadOlderItems() (appear / tap)
    VM->>Store: loadOlderItems()
    Store->>Persist: loadPage(endingBefore: X, limit: N)
    Persist->>File: read prior ranges, compute offsets
    Persist-->>Store: Page(items:[older], hasMoreBefore:bool, startOffset:Y)
    Store-->>VM: prepend items, update cursor/flags
    VM-->>UI: refresh feed
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

Poem

🐰
I nibble lines with offset care,
hop back through stories stored there,
a page, a cursor—old becomes near,
press the row and older appear,
the rabbit fetches history with cheer.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Lazy load feed activity history' clearly and concisely summarizes the primary change: implementing lazy loading for feed activity history to improve performance.
Description check ✅ Passed The description covers the main changes, testing approach, and related issue, though it lacks a demo video and incomplete checklist items required by the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-activity-feed-virtualization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 6/8 reviews remaining, refill in 11 minutes and 24 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@lawrencecchen
lawrencecchen force-pushed the task-activity-feed-virtualization branch from 9f46179 to 5848881 Compare May 4, 2026 00:35
@greptile-apps

greptile-apps Bot commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces byte-cursor-based pagination for the JSONL activity feed, loading only the most recent 300 rows at startup and fetching older history on demand via a sentinel row in historyList. It also removes per-render sorting in FeedListView, relying on items being in chronological insertion order.

All findings are P2. The most notable: loadOlderItems prepends older pages directly into items without going through the ring-buffer eviction in insert(_:), so items.count can silently exceed ringCapacity as the user pages through history. The lineRanges helper also re-scans the full accumulated tail on every chunk iteration rather than incrementally, which is O(k²) in chunk count.

Confidence Score: 4/5

Safe to merge; all findings are P2 style/design concerns with no definitive current breakage.

No P0 or P1 issues found. Four P2 findings: ring capacity not enforced during older-item prepend, chronological-order assumption in lastPromptByWorkstream, O(k²) line-range rescanning in loadPage, and the auto-load-on-appear vs. explicit button UX inconsistency. P2s alone cap confidence at 4/5.

WorkstreamStore.swift (ring-buffer bypass) and WorkstreamPersistence.swift (lineRanges loop cost) deserve a second look before widespread rollout to users with very large feed histories.

Important Files Changed

Filename Overview
Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift Adds loadPage(endingBefore:limit:) with byte-cursor pagination; the lineRanges helper is correct but rescans the entire accumulated buffer on every chunk iteration (O(k²) cost).
Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift Adds loadOlderItems() and startup slice; ring-buffer eviction is bypassed when prepending older pages, allowing items to grow beyond ringCapacity until the next live ingest.
Sources/Feed/FeedPanelView.swift Adds FeedHistoryLoadMoreRow sentinel and wires pagination state; sort removal in filtered() and lastPromptByWorkstream now depends on items being strictly chronological.
Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamPersistenceTests.swift Adds test for byte-cursor pagination confirming cursor stability after a concurrent append; coverage looks correct.
Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamStoreTests.swift Adds end-to-end test for lazy loading: start with a 2-item slice, page twice, verify item order and hasMorePersistedItems flag.
Resources/Localizable.xcstrings Adds feed.history.loadOlder and feed.history.loadingOlder keys with English and Japanese localizations; no issues.

Sequence Diagram

sequenceDiagram
    participant UI as FeedHistoryLoadMoreRow
    participant VM as FeedPanelViewModel
    participant Store as WorkstreamStore
    participant Persist as WorkstreamPersistence

    Note over Store: start() — initial load
    Store->>Persist: loadPage(limit: min(300, ringCapacity))
    Persist-->>Store: Page(items, hasMoreBefore, startOffset)
    Store-->>VM: items, hasMorePersistedItems, oldestLoadedPersistenceOffset

    Note over UI: Row scrolls into view
    UI->>VM: onAppear → loadOlderItems()
    VM->>Store: loadOlderItems()
    Store->>Store: guard !isLoadingOlderItems
    Store->>Persist: loadPage(endingBefore: oldestOffset, limit: 300)
    Persist-->>Store: Page(olderItems, hasMoreBefore, startOffset)
    Store->>Store: deduplicate & items.insert(olderItems, at: 0)
    Store->>Store: update oldestLoadedPersistenceOffset, hasMorePersistedItems
    Store-->>VM: arm() triggers re-observation
    VM-->>UI: updated isLoadingOlderItems / hasMorePersistedItems
Loading

Comments Outside Diff (1)

  1. Sources/Feed/FeedPanelView.swift, line 417-424 (link)

    P2 lastPromptByWorkstream correctness depends on insertion order

    The removed .sorted { $0.createdAt < $1.createdAt } guaranteed that the latest user-prompt text per workstream overrode earlier ones regardless of accumulation order. The replacement relies on items being strictly oldest-first. Items loaded via loadOlderItems are prepended correctly, but live-ingested events are ordered by event.receivedAt — if two events for the same workstream arrive out of order (network/socket reordering), the "You: …" prompt echo shown in activity cards can display a stale or incorrect message.

Reviews (1): Last reviewed commit: "Lazy load feed activity history" | Re-trigger Greptile

Comment on lines +115 to +122
let existingIds = Set(items.map(\.id))
let olderItems = page.items.filter { !existingIds.contains($0.id) }
if !olderItems.isEmpty {
items.insert(contentsOf: olderItems, at: 0)
}
self.oldestLoadedPersistenceOffset = page.startOffset ?? oldestLoadedPersistenceOffset
hasMorePersistedItems = page.hasMoreBefore
rebuildContextIndex()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ring capacity not enforced during older-item prepend

loadOlderItems inserts directly into items at position 0, bypassing the private insert(_:) helper that enforces the ring-buffer eviction. A user with 2,288 persisted rows (the issue's exact scenario) loading all 8 pages at 300 items/page would accumulate ~2,700 items — 35% above ringCapacity = 2,000 — with no eviction until the next live ingest call. The live-event eviction removes oldest items from the front, which are the freshly-loaded history rows, creating counter-productive churn.

Consider capping total items.count after prepending, or document that ringCapacity is intentionally not enforced for on-demand history pages.

Comment on lines 94 to 106
@@ -66,26 +99,32 @@ public actor WorkstreamPersistence {
break
}
tail.insert(contentsOf: chunk, at: 0)
let newlineCount = tail.reduce(0) { $1 == 0x0A ? $0 + 1 : $0 }
if newlineCount > limit {
lineRanges = Self.lineRanges(in: tail, baseOffset: offset)
if lineRanges.count > limit {
break
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 lineRanges rescans the entire accumulated tail on every chunk iteration

Self.lineRanges(in: tail, baseOffset: offset) walks tail from index 0 on each loop iteration, but tail grows by prepending 64 KiB chunks each pass. For a file where the desired limit lines are spread across many chunks, this is O(k²) in the number of chunks read before lineRanges.count > limit fires. With default historyPageSize = 300 and large rows, this can consume significant CPU per loadPage call. An incremental approach — tracking which prefix has already been scanned and only computing new ranges for the prepended chunk — would keep the inner cost O(chunk) per iteration.

Comment thread Sources/Feed/FeedPanelView.swift Outdated
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.plain)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 .onAppear and button tap both fire the same action

.onAppear(perform: action) makes this an auto-loading sentinel (infinite-scroll style): loading begins as soon as the row scrolls into view without any user tap. The button labeled "Load older activity" is therefore never actually needed to initiate a load — it's redundant. If SwiftUI rebuilds the historyList while this row is in the viewport (e.g., after items are inserted at the top of items), .onAppear may fire again; the isLoadingOlderItems guard prevents a concurrent double-load but a back-to-back second load can still start immediately after the first completes.

If the intent is purely auto-load on scroll, consider removing the tap target entirely (or making the button a non-interactive progress indicator) to avoid misleading the user about the need to click it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift`:
- Around line 93-123: loadOlderItems currently appends persisted pages to items
which can exceed ringCapacity and later be trimmed by insert(_:) when live
events arrive, making previously-paged history permanently inaccessible because
hasMorePersistedItems stays false; fix by pinning or accounting for paged rows
so they are not discarded by insert(_:), or by making insert(_:) respect a
pinned/paged prefix: update loadOlderItems to mark newly loaded items (e.g.,
pinnedIds or pinnedCount) and ensure insert(_:) will not trim those pinned items
(or if trimming occurs, set hasMorePersistedItems = true and adjust
oldestLoadedPersistenceOffset to reflect lost persisted rows), and keep
rebuildContextIndex() consistent after these adjustments (touch loadOlderItems,
insert(_:), items, ringCapacity, hasMorePersistedItems,
oldestLoadedPersistenceOffset, rebuildContextIndex).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e1dd20bc-9d4f-41c5-a570-f53aeba5607a

📥 Commits

Reviewing files that changed from the base of the PR and between 0c532c0 and 9f46179.

📒 Files selected for processing (6)
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift
  • Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamPersistenceTests.swift
  • Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamStoreTests.swift
  • Resources/Localizable.xcstrings
  • Sources/Feed/FeedPanelView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift`:
- Around line 97-103: The guard using try? on persistence.loadPage conflates
transient I/O/decoding errors with an empty-page EOF; replace the try? pattern
around persistence.loadPage(endingBefore: oldestLoadedPersistenceOffset, limit:
historyPageSize) with explicit do-catch so you only set hasMorePersistedItems =
false when the call succeeds and page.items.isEmpty, and on errors either
propagate or log and keep hasMorePersistedItems true to allow retries (handle
specific persistence errors if needed); ensure the surrounding logic that relies
on page is updated to use the successfully loaded page variable from the do
block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 83406eab-ae70-40ca-9830-6f037965d51d

📥 Commits

Reviewing files that changed from the base of the PR and between 5848881 and f807533.

📒 Files selected for processing (7)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift
  • Resources/Localizable.xcstrings
  • Sources/Feed/FeedPanelView.swift
  • Sources/Feed/FeedPanelViewModel.swift
  • Sources/TerminalController.swift
✅ Files skipped from review due to trivial changes (2)
  • Resources/Localizable.xcstrings
  • Sources/Feed/FeedPanelView.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift

Comment on lines +97 to +103
guard let page = try? await persistence.loadPage(
endingBefore: oldestLoadedPersistenceOffset,
limit: historyPageSize
), !page.items.isEmpty else {
hasMorePersistedItems = false
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t treat persistence read failures as end-of-history.

try? here conflates I/O/decode errors with EOF and immediately sets hasMorePersistedItems = false, which removes retry ability for transient failures.

Suggested fix
-        guard let page = try? await persistence.loadPage(
-            endingBefore: oldestLoadedPersistenceOffset,
-            limit: historyPageSize
-        ), !page.items.isEmpty else {
-            hasMorePersistedItems = false
-            return
-        }
+        let page: WorkstreamPersistence.Page
+        do {
+            page = try await persistence.loadPage(
+                endingBefore: oldestLoadedPersistenceOffset,
+                limit: historyPageSize
+            )
+        } catch {
+            // Preserve pagination state so UI can retry.
+            return
+        }
+        guard !page.items.isEmpty else {
+            hasMorePersistedItems = false
+            return
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
guard let page = try? await persistence.loadPage(
endingBefore: oldestLoadedPersistenceOffset,
limit: historyPageSize
), !page.items.isEmpty else {
hasMorePersistedItems = false
return
}
let page: WorkstreamPersistence.Page
do {
page = try await persistence.loadPage(
endingBefore: oldestLoadedPersistenceOffset,
limit: historyPageSize
)
} catch {
// Preserve pagination state so UI can retry.
return
}
guard !page.items.isEmpty else {
hasMorePersistedItems = false
return
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift` around
lines 97 - 103, The guard using try? on persistence.loadPage conflates transient
I/O/decoding errors with an empty-page EOF; replace the try? pattern around
persistence.loadPage(endingBefore: oldestLoadedPersistenceOffset, limit:
historyPageSize) with explicit do-catch so you only set hasMorePersistedItems =
false when the call succeeds and page.items.isEmpty, and on errors either
propagate or log and keep hasMorePersistedItems true to allow retries (handle
specific persistence errors if needed); ensure the surrounding logic that relies
on page is updated to use the successfully loaded page variable from the do
block.

@lawrencecchen
lawrencecchen merged commit 1b2c83a into main May 4, 2026
20 checks passed
@lawrencecchen
lawrencecchen deleted the task-activity-feed-virtualization branch May 4, 2026 03:52

This branch was successfully deployed

1 active deployment
Preview – cmux — f8075331 Deployed May 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant