PERF: make the chat sidebar linear and virtualized for large chat collections #18

Open
opened 2026-08-24 00:45:50 +00:00 by lost-rob0t · 0 comments
lost-rob0t commented 2026-08-24 00:45:50 +00:00 (Migrated from github.com)

Finding

The current chat sidebar does repeated whole-collection scans/sorts from Alpine bindings. With hundreds or thousands of chats this turns one reactive render into quadratic-style work and mounts every visible row into the DOM.

Hot paths:

  • webui/components/sidebar/chats/chats-store.js
    • applyContexts() filters + sorts the entire incoming list every snapshot.
    • topLevelContexts() filters the whole list and then calls extension sorting.
    • childContexts(parentId) filters the whole list and then calls extension sorting.
    • hasChildren(parentId) calls childContexts(parentId).
    • setSelected, contains, delete/rollback paths repeatedly use find/some scans.
  • webui/components/sidebar/chats/chats-list.html
    • calls topLevelContexts() repeatedly in x-show, x-for, and divider bindings.
    • each top-level row calls hasChildren(context.id) multiple times.
    • expanded children call childContexts(context.id) again for x-for and again for divider calculation.
    • there is no list/window virtualization; every listed chat creates a full Alpine row with buttons/icons/bindings.
  • webui/components/sidebar/sidebar-store.js
    • sortRows() clones the complete row list before every extension sort pass.
    • hasRowDividerBefore() invokes every divider extension for every rendered row and receives the complete row array.

The message timeline already has a bounded render window; this issue is specifically the chat collection navigator/sidebar, which does not.

Direction

Build a derived chat index once per roster version rather than recomputing it from template expressions:

  • id -> context map
  • materialized ordered top-level IDs
  • parent_id -> ordered child IDs
  • constant-time child existence and selected lookup
  • extension sort/group/decorator work applied once when the roster/version changes, not per row render
  • viewport/window virtualization for the chat collection

Do not solve this by removing plugin row customization. Move plugin sorting/grouping/divider/decorator semantics to an indexed/materialized pipeline with explicit invalidation.

Acceptance

  • A roster update performs O(n log n) or better bounded derivation, not repeated O(n) scans from each row binding.
  • hasChildren, ID lookup, and child lookup are O(1) / proportional to that parent's child count.
  • Alpine templates consume precomputed/materialized lists rather than calling collection-building functions repeatedly.
  • Only a bounded viewport/overscan of chat rows is mounted for large collections.
  • Existing chat parent/child behavior remains correct.
  • Existing plugin sort/divider/row actions remain possible through a documented compatibility path.
  • Benchmarks cover at least 100, 1,000, and 10,000 chat metadata rows.
  • Measure roster-apply time, render time, long-task/frame stalls, and mounted DOM node count.
  • Selecting, deleting, expanding, and receiving running-state updates do not rebuild the entire visible list unnecessarily.
## Finding The current chat sidebar does repeated whole-collection scans/sorts from Alpine bindings. With hundreds or thousands of chats this turns one reactive render into quadratic-style work and mounts every visible row into the DOM. Hot paths: - `webui/components/sidebar/chats/chats-store.js` - `applyContexts()` filters + sorts the entire incoming list every snapshot. - `topLevelContexts()` filters the whole list and then calls extension sorting. - `childContexts(parentId)` filters the whole list and then calls extension sorting. - `hasChildren(parentId)` calls `childContexts(parentId)`. - `setSelected`, `contains`, delete/rollback paths repeatedly use `find`/`some` scans. - `webui/components/sidebar/chats/chats-list.html` - calls `topLevelContexts()` repeatedly in `x-show`, `x-for`, and divider bindings. - each top-level row calls `hasChildren(context.id)` multiple times. - expanded children call `childContexts(context.id)` again for `x-for` and again for divider calculation. - there is no list/window virtualization; every listed chat creates a full Alpine row with buttons/icons/bindings. - `webui/components/sidebar/sidebar-store.js` - `sortRows()` clones the complete row list before every extension sort pass. - `hasRowDividerBefore()` invokes every divider extension for every rendered row and receives the complete row array. The message timeline already has a bounded render window; this issue is specifically the **chat collection navigator/sidebar**, which does not. ## Direction Build a derived chat index once per roster version rather than recomputing it from template expressions: - `id -> context` map - materialized ordered top-level IDs - `parent_id -> ordered child IDs` - constant-time child existence and selected lookup - extension sort/group/decorator work applied once when the roster/version changes, not per row render - viewport/window virtualization for the chat collection Do not solve this by removing plugin row customization. Move plugin sorting/grouping/divider/decorator semantics to an indexed/materialized pipeline with explicit invalidation. ## Acceptance - [ ] A roster update performs O(n log n) or better bounded derivation, not repeated O(n) scans from each row binding. - [ ] `hasChildren`, ID lookup, and child lookup are O(1) / proportional to that parent's child count. - [ ] Alpine templates consume precomputed/materialized lists rather than calling collection-building functions repeatedly. - [ ] Only a bounded viewport/overscan of chat rows is mounted for large collections. - [ ] Existing chat parent/child behavior remains correct. - [ ] Existing plugin sort/divider/row actions remain possible through a documented compatibility path. - [ ] Benchmarks cover at least 100, 1,000, and 10,000 chat metadata rows. - [ ] Measure roster-apply time, render time, long-task/frame stalls, and mounted DOM node count. - [ ] Selecting, deleting, expanding, and receiving running-state updates do not rebuild the entire visible list unnecessarily.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
nsaspy/a0-symbolics#18
No description provided.