Final step of the virtualization plan. Turns the virtualized render
path on in production behind a row-count threshold, and adds regression
tests covering every gate.
- `VIRTUALIZATION_ROW_THRESHOLD = 60`. Short lists stay on the direct
render path (no wrapper, no position: absolute, no measurement
churn). Above the threshold the virtualizer takes over. Threshold is
sized so conversations under ~one session of activity don't pay the
virtualization cost; it activates once scrolling through a longer
history.
- `shouldVirtualize` now requires `renderRows.length >= threshold` in
addition to the existing opt-in and scroll-ref checks.
- `MessagesPanel` opts into virtualization for every layout it wires
(inline / sidebar / bottom-sheet). The internal threshold then
decides when to actually enable it, so callers don't need per-layout
heuristics.
- Tests: adds a new `ActivityTimeline virtualization threshold` block
covering (a) below-threshold list stays on the direct path,
(b) no viewport → direct path regardless of count, (c) above
threshold + viewport with `virtualizationEnabled` flips to the
virtualized render path (simulated by clicking "show all" past
pagination).
With this in, #70 → #74 combine to deliver:
- correct IntersectionObserver roots in scroll containers
- atomic render rows with stable keys
- windowed rendering with DOM-measured scrollMargin and measureElement
- auto-on when the cost of direct rendering actually shows up
Second step of the virtualization plan. No virtualization yet. This PR
makes IntersectionObserver-based visibility tracking correct inside
scroll containers (sidebar, bottom-sheet), which is a prerequisite for
virtualizing the timeline.
- Introduces `TimelineViewport` — a grouped contract passed as a single
`viewport` prop on `ActivityTimeline`. Holds `scrollElementRef`,
`observerRoot`, `scrollMargin`, and `virtualizationEnabled`.
- `MessageRowWithObserver` and `LeadThoughtsGroupRow` now create their
`IntersectionObserver` with `root = observerRoot?.current ?? null`
instead of defaulting to the document viewport. Unread marking now
fires when rows enter their real scroll parent.
- `MessagesPanel` resolves the active scroll owner from `position`
(inline from parent ref, sidebar from `sidebarScrollRef`, bottom-sheet
from `bottomSheetScrollRef`) and passes it into ActivityTimeline.
- Tests: stubs `IntersectionObserver` to capture `options.root` and
asserts null when no viewport is passed, and the provided element when
`viewport.observerRoot` is set.
`scrollMargin` and `virtualizationEnabled` are included in the contract
but not consumed yet — they land in follow-up PRs (#4/#5).
- Introduced caching mechanism with expiration for message feeds to improve performance.
- Added logging for cache expiration events to aid in debugging.
- Updated MessagesPanel to reopen search bar when participant filters are active.
- Added test cases for handling tmux server errors and message panel behavior with filters.
First step toward virtualizing ActivityTimeline. Makes the real scroll
container observable per layout, without changing behavior.
- `TeamDetailView` forwards `contentRef` to `MessagesPanel` as
`inlineScrollContainerRef`. `MessagesPanel` accepts it as an optional
prop (unused in this release) so a follow-up can wire the inline
viewport to virtualization consumers.
- `MessagesPanel` creates `bottomSheetScrollRef` and passes it to
`Sheet.Content scrollRef`. react-modal-sheet merges it with its
internal scroll ref, so the element stays the same DOM node; this
only exposes it to us.
- Sidebar already owns `sidebarScrollRef`; no change there.
Behavior is unchanged — this only exposes refs for the follow-up that
will thread a viewport contract into ActivityTimeline.
- Integrated pending replies state management for team members.
- Updated TeamDetailView to initialize pending replies from state.
- Added logic to refresh team messages and member activity on tab focus.
- Improved UI components by increasing dialog content width for better layout.
- Enhanced member draft rows with avatar support for better visual representation.
- Implemented reconciliation logic for pending replies based on message history.
- Updated tests to cover new functionality and ensure reliability.
P1: Poller no longer overwrites nextCursor/hasMore — those belong
to the "Load older" flow. Both poller and loadOlder now dedup
messages by messageId or timestamp+from fingerprint.
P1: Cursor is now compound (timestamp|messageId) with stable
tie-breaking sort. Messages sharing the same timestamp at page
boundaries are no longer lost.
P2: getMessagesPage now applies the same enrichment as getTeamData:
leadSessionId propagation and slash-command-result annotation.
P3: Added 3 tests for getMessagesPage covering pagination, cursor
stability with same-timestamp messages, and slash command annotation.
New cursor-based IPC endpoint for the messages timeline panel:
- team:getMessagesPage(teamName, { beforeTimestamp?, limit? })
returns { messages, nextCursor, hasMore }
- Cursor is timestamp-based — stable under live message insertion
- Default page size 50, max 200
getTeamData no longer includes messages in its response, eliminating
the ~1MB messages payload from every team refresh. Messages are now
fetched independently by MessagesPanel.
MessagesPanel changes:
- Fetches initial page on mount via getMessagesPage API
- Auto-refreshes newest page every 5s when team is alive
- "Load older messages" button for pagination
- Falls back to prop messages if API fails (graceful degradation)
Main process — worker thread for team data:
- New team-data-worker thread handles getTeamData and findLogsForTask,
isolating heavy file I/O (scanning 300+ subagent JSONL files) from
Electron's main event loop. getTeamData dropped from ~2000ms on the
main thread to ~110ms via the worker.
- Worker-side dedup and 10s result cache for findLogsForTask prevents
redundant scans when the same task is queried multiple times.
- Discovery cache TTL raised from 5s to 30s — avoids re-scanning the
entire project directory on every call.
- Message cap at 200 in TeamDataService to keep IPC payloads under 1MB
(was sending 2200+ messages / ~3MB, stalling Chromium IPC serialization).
- IPC handlers fall back to main-thread execution if the worker is
unavailable (graceful degradation).
Renderer — useShallow and memoization (55 files):
- Added useShallow to store selectors across 55 renderer files. Batched
individual useStore() calls (e.g. 17 calls in ExtensionStoreView,
10 in ConnectionSection) into single useShallow selectors, cutting
unnecessary re-render checks on every store update.
- MemberLogsTab: three 5-second polling intervals now pause when the
parent tab is hidden (display:none). Previously 5 hidden tabs × 3
intervals = 15 polling timers firing continuously.
- KanbanColumn wrapped in React.memo to skip re-renders when props
haven't changed.
- MemberList: memoized activeMembers/removedMembers/colorMap; replaced
O(n×m) per-member task scan with a pre-computed reviewer map.
- Bounded timer Maps in store initialization to prevent unbounded growth
of debounce/throttle tracking maps during long sessions.