148 KiB
План: TeamDetail Snapshot / Messages / Member Activity Split
Дата: 2026-04-15
Статус: Detailed execution-ready architecture plan
Цель: убрать structural render churn из TeamDetailView и отделить message-heavy данные от structural snapshot команды
Executive Summary
Выбранный вариант:
Split TeamDetail data flow into structural snapshot + paginated messages + member activity meta
🎯 9 🛡️ 9 🧠 8
Примерно 1600-2600 строк изменений
Это не "ещё один локальный guard", а нормализация границ данных:
getData(teamName)перестаёт быть transport для message-heavy UIgetMessagesPage(teamName, { limit, cursor })остаётся единственным сообщенческим feed API- добавляется новый IPC endpoint
getMemberActivityMeta(teamName) - renderer хранит structural snapshot отдельно от message cache
refreshTeamData()получает structural sharing + no-op suppression даже после split
Самое важное:
- текущий semantic-equality guard перед
set()- правильная мысль, но это только часть решения - если сделать только guard, можно снять текущий crash, но архитектурная сцепка
TeamData <-> messages <-> TeamDetailViewостанется - если сделать split + guard вместе, это уже похоже на правильный долгоживущий вариант
Quick Execution Path
Если исполнитель не хочет читать весь документ линейно, безопасный порядок такой:
- Сначала новые shared contracts и worker ops.
- Потом
TeamMessageFeedServiceс stable effective identity иfeedRevision. - Потом structural
getData()и отдельныйMemberActivityMetaService. - Потом store ownership for messages/meta, single-flight и stale-response guards.
- Потом migration consumers:
MessagesPanel,ActivityTimeline,MemberDetailDialog,MemberMessagesTab,MemberHoverCard,StatusBlock,TeamDetailView, graph. - Потом event routing split.
- Потом structural sharing + no-op suppression.
- Только после этого выпиливать legacy fields и compatibility plumbing.
Неправильный порядок, которого надо избегать:
- Сначала менять UI consumers, пока feed/meta/store contracts ещё не зафиксированы.
- Сначала удалять
TeamData.messages, пока graph/dialog/messages consumers ещё на нём сидят. - Сначала добавлять polling в store без single-flight/coalescing.
Locked Decisions
Ниже решения, которые в этом плане считаются закрытыми, а не оставленными "на потом".
1. Naming and transport
- IPC route name не меняем: остаётся
team:getData - public method name не меняем: остаётся
getData(teamName) - но тип ответа меняем на новый structural contract
TeamViewSnapshot - repo-wide alias вида
type TeamData = TeamViewSnapshotне оставляем после merge
Причина:
- transport rename сейчас только раздует diff
- а вот новый тип нужен, чтобы код и тесты перестали мыслить
getData()как message transport
Допустимо локально во время промежуточной сборки держать временный compatibility alias, но в merged коде его быть не должно.
Дополнительное правило:
- если temporary compatibility alias или adapter переживает тот commit slice, в котором переводится его последний consumer, это уже smell и план выполняется неверно
2. Snapshot is structural only
- merged код не должен читать
messagesиз snapshot messagesбольше не частьTeamViewSnapshotmembersв snapshot больше не считаются от full message history
3. Message ownership
- единственный message feed API в этом PR -
getMessagesPage() - новый отдельный
getMessagesHead()в этом PR не добавляем - если понадобится оптимизация, делаем её внутри
getMessagesPage()или store caching, без второго transport contract - existing
MessagesPagecontract расширяем полемfeedRevision - store action
refreshTeamMessagesHead()должен возвращать semantic result c минимум двумя флагами:feedChanged- изменился ли revision всего normalized feedheadChanged- изменился ли реально текущий canonical head slice в store
- но hot path
getMessagesPage()при этом обязательно должен перестать быть full rescan/full normalize на каждый вызов - для этого в main добавляется shared normalized message feed cache/index, которым пользуются и
getMessagesPage(), иgetMemberActivityMeta()
Причина:
- исторический backfill может менять exact member activity semantics без видимого изменения top page
- store не должен гадать про full-feed change только по diff первой страницы
4. Message activity ownership
- exact full-history message-derived facts идут в
getMemberActivityMeta() - renderer не должен вычислять exact
messageCountилиlastActiveAtтолько по head page messages - итоговый member
statusкак display field не храним как final truth в meta - meta хранит raw facts, а display status собирается в renderer overlay из:
lastAuthoredMessageAtlatestAuthoredMessageSignalsTerminationcurrentTaskId- spawn/runtime state
5. messageCount semantics
- в этом PR semantics сохраняем
messageCountостаётся exact historical count- для этого закладываем shared normalized feed cache + meta cache по
feedRevision - вариант с
recentMessageCountв этом PR не принимаем
6. Pending replies semantics
pendingRepliesByMemberостаётся renderer-local UI statecrossTeamPendingRepliesостаётся renderer-derived состоянием от message cache + local TTLTeamMemberActivityMetaне становится ticking transport для этих таймерных состояний
Причина:
- эти состояния частично зависят от local wall clock и текущего UX контекста таба
- перенос их в main/meta создаст лишнюю связанность и сломает текущую интерактивную модель
6.1 Frozen semantics in this PR
Чтобы performance refactor не превратился в скрытый product-change PR, в этом PR не меняем:
- значение и смысл existing pending-reply waiting windows
- значение и смысл cross-team pending TTL badges
- значение coarse fallback polling intervals, кроме случаев where implementation forces tiny mechanical adjustment
- смысл
active/idlemember status thresholds - exact-vs-recent meaning of
messageCount - default head page size / default first-screen message density без отдельного явного решения
- текущий default head request limit остаётся
50, пока не принято отдельное явное решение его менять
Если какой-то из этих пунктов всё-таки приходится менять ради correctness:
- это должно быть отдельно отмечено в PR description
- change должен иметь отдельный тест
- и это уже считается product-semantic change, а не "просто часть split"
7. Fetch ownership after migration
- после split компоненты UI не вызывают
api.teams.getMessagesPage(...)напрямую - message fetching ownership переезжает в store actions
MessagesPanel,MemberMessagesTab, graph-consumers становятся passive consumers store state
8. Worker boundary
- raw feed rebuild и meta build не должны неожиданно вернуться на main event loop
- в этом PR используем существующий
team-data-workerboundary, а не заводим второй отдельный worker getData(), expensivegetMessagesPage()rebuild path иgetMemberActivityMeta()должны идти через одну и ту же worker strategy
Важная практическая оговорка:
- текущий
TeamDataWorkerClientумеет fallback на main-thread execution, если worker artifact недоступен - для новых hot paths это допустимо только как test/unpacked-dev escape hatch
- packaged runtime не должен молча остаться без worker и продолжить heavy feed rebuild на main loop
Значит в плане реализации надо предусмотреть:
- явную проверку availability для packaged runtime
- диагностический log/metric, если worker path не найден
- тест или smoke check, что message/meta ops реально доходят до worker path в нормальном runtime
Причина:
- иначе можно исправить renderer stall, но занести новую main-thread stall точку
- в кодовой базе уже есть готовый паттерн для heavy team I/O
9. Polling ownership
- fallback polling после миграции остаётся, но переезжает в store
- компоненты не владеют polling lifecycle
- polling нужен только как safety net на случай missed file/runtime events
10. Temporary old-shape guard policy
Если semantic-equality guard на старом mixed TeamData shape уже существует или приземлится раньше полного split, его статус в этом плане фиксированный:
- это temporary mitigation, а не final architecture endpoint
- он не является причиной откладывать snapshot/messages/activity split
- новые consumers не должны начинать зависеть от старой mixed compare semantics
- после перехода на
TeamViewSnapshotfinal no-op suppression должен работать уже на новом structural shape - в merged target не должно остаться comparator logic, которое продолжает сравнивать
messagesвнутри legacy snapshot только потому, что "так уже было"
Иначе легко зацементировать старую неверную data boundary под видом performance fix.
11. Store shape ownership
- canonical owner structural snapshot state после split -
teamDataCacheByName selectedTeamDataв этом PR можно оставить как convenience field для текущей команды- но
selectedTeamDataне должен жить отдельной второй жизнью - если
selectedTeamDataприсутствует, он всегда должен ссылаться на тот же object ref, что иteamDataCacheByName[selectedTeamName]
Дополнительная жёсткая оговорка:
- предпочтительный merged target - удалить
selectedTeamDataцеликом, как только это станет механически просто - сохранять его допустимо только как literal alias/pointer convenience field без собственной логики пересборки и без второго write path
- если для поддержки
selectedTeamDataнужен отдельный код синхронизации, значит поле уже не оправдано и должно быть удалено
Причина:
- иначе можно вроде бы "починить snapshot cache", но оставить hidden churn через second selected-only copy
- для no-op suppression важен именно ref reuse одного canonical объекта, а не две почти одинаковые структуры
12. Out of scope for this PR
- не делаем новый REST API
- не делаем
PaneContentunmount refactor - не делаем virtualization как primary fix
- не делаем graph redesign beyond data-source migration
- не делаем вторую параллельную message model "на время"
Source Of Truth Map
Это обязательная карта владения данными. Если при реализации какая-то часть начнёт читаться не отсюда, это почти наверняка путь к регрессии.
| Concern | Source of truth | Who derives view state | Must not come from |
|---|---|---|---|
| Structural team detail | getData() -> TeamViewSnapshot |
store selectors / view-model adapters | message cache, MessagesPanel props |
| Normalized message feed | main-side shared feed cache/index | getMessagesPage(), getMemberActivityMeta() |
repeated raw full rescans in each consumer |
| Message feed | getMessagesPage() -> teamMessagesByName + selectTeamMessages(teamName) |
MessagesPanel, MemberMessagesTab, graph |
selectedTeamData, TeamViewSnapshot |
| Full-feed freshness | MessagesPage.feedRevision + store cache entry revision |
refresh routing / meta invalidation | head-slice diff heuristics only |
| Message identity | main-side effective message identity emitted in feed/page responses | store merge, cursor stability, read state, optimistic confirmation | ad-hoc renderer-only fallback identity |
| Exact member activity facts | getMemberActivityMeta() -> memberActivityMetaByTeam |
member list / headers / hover / status presentation | loaded head messages only |
| Member awaiting-reply state | renderer-local pendingRepliesByMember |
TeamDetailView, MemberList, PendingRepliesBlock |
main/meta snapshot |
| Cross-team pending reply TTL state | renderer-derived from message cache + Date.now() |
StatusBlock |
main/meta snapshot |
| Spawn liveness | memberSpawnStatusesByTeam |
member badges / merged display status | message meta |
| Message dedup semantics | main-side message services | renderer only consumes normalized output | renderer re-dedup logic |
Hard Invariants
Если любой из пунктов ниже нарушается, значит реализация ушла в неправильную сторону.
- В merged коде не должно остаться чтения
selectedTeamData.messages. - Exact
messageCountиlastActiveAtне считаются в renderer поselectTeamMessages(teamName). MessagesPanelиMemberMessagesTabне имеют собственного IPC fetching logic после миграции.- Main остаётся единственным местом, где выполняется dedup
lead_session/lead_process. - Pending-reply timer logic не переезжает в main process.
lead-messageevent не вызывает fullrefreshTeamData()по умолчанию.- В merged коде не живут две долгоживущие message models одновременно.
- Message/meta refresh не крутятся бесконтрольно для hidden inactive teams.
getMessagesPage()иgetMemberActivityMeta()не делают независимый полный raw rescan истории на каждый hot refresh.- Expensive feed rebuild path не выполняется на Electron main event loop.
- Store не выводит "full feed changed" только по diff первого page slice; для этого используется
feedRevision. TeamListViewи любые multi-team overview screens не гидратят messages/meta для каждой команды по умолчанию.getMessagesPage()отдаёт stable effective message identity для каждого message row; store merge/cursor logic не живут на двух разных key semantics.selectedTeamData, если сохраняется, reuse'ит ref изteamDataCacheByName, а не создаёт вторую independent snapshot copy.feedRevisionотражает состояние full normalized feed, а не время rebuild или raw invalidation fingerprint.- Если older history после revision change нельзя склеить без сомнений, canonical older tail сбрасывается, а не показывается mixed inconsistent state.
Forbidden Shortcuts
Ниже shortcuts, которые выглядят как "быстро и почти правильно", но в контексте этого плана считаются ошибкой реализации.
- Оставить
messagesв snapshot "пока временно", а потом забыть убрать. - Считать
messageCount/lastActiveAtпо head page или по уже загруженным сообщениям в renderer. - Перенести fetching в store, но оставить прямые
api.teams.getMessagesPage(...)вMessagesPanelилиMemberMessagesTab. - Сделать
refreshMemberActivityMeta()зависимым только от head-slice diff безfeedRevision. - Держать два merge paths для messages: один в store, второй в компоненте.
- Позволить packaged runtime тихо выполнять expensive message rebuild path на main thread при пропавшем worker.
- Сохранить и
teamDataCacheByName, и отдельно пересобираемыйselectedTeamData. - Начать греть
getMessagesPage()/getMemberActivityMeta()для multi-team overview "ради удобства".
1. Top 3 Variants
1. Full split: structural snapshot + messages cache + member activity meta
🎯 9 🛡️ 9 🧠 8
Примерно 1600-2600 строк
Идея:
TeamDataбольше не является message transport- сообщения живут в отдельном cache/store path
- member list/status/meta перестают зависеть от нового
messagesarray ref на каждый refresh lead-messageиinboxevents больше не триггерят full detail refresh
Плюсы:
- бьёт в корень renderer saturation
- уменьшает payload, churn и layout/paint rework
- делает поведение предсказуемым для долгих soaks
- готовит нормальную основу для graph/activity/members
Минусы:
- широкий blast radius
- надо аккуратно мигрировать graph и dialog consumers
2. Только semantic-equality guard перед set() в refreshTeamData
🎯 7 🛡️ 6 🧠 4
Примерно 250-450 строк
Идея:
- оставить
getData()как есть - сравнивать новый snapshot с предыдущим
- не вызывать
set()если semantic state не изменился
Плюсы:
- быстро
- скорее всего снимет именно observed "new ref without visible change"
Минусы:
TeamDataостаётся перегруженным transport'ом- любой реальный message change всё ещё трогает большой subtree
- архитектурная связка не исправляется
- остаётся риск новых форм churn вокруг graph/member dialogs/status blocks
3. UI-side memoization / virtualization / more throttling без data split
🎯 5 🛡️ 5 🧠 6
Примерно 500-900 строк
Идея:
- сильнее мемоизировать
MessagesPanel,ActivityTimeline,TeamDetailView - агрессивнее throttle / debounce refreshes
- возможно добавить virtualization
Плюсы:
- может уменьшить симптомы
- полезно как secondary optimization
Минусы:
- не чинит wrong data boundary
- будет лечить последствия вместо причины
- легко получить сложную, хрупкую UI-логику
Final Choice
Берём вариант 1.
Но важная поправка: semantic guard из варианта 2 всё равно нужен внутри варианта 1.
2. Краткая суть проблемы
Проблема уже не в persistLaunchStateSnapshot storm. Он был причиной A и, судя по логам, уже прижат.
Текущая причина B выглядит так:
refreshTeamData()регулярно создаёт новыйselectedTeamDatarefTeamDetailViewподписан на весьselectedTeamData- даже когда по смыслу ничего не изменилось, вниз уходит новый
messagesref MessagesPanel,ActivityTimeline, member activity derivations и часть graph-related logic заново гонят filter/group/layout/paint- React Profiler молчит, потому что commit time сам по себе не гигантский, а дорогой кусок сидит в browser layout+paint на 50+ message DOM nodes
- из-за mounted tabs через CSS toggle скрытые team tabs тоже могут держать живые тяжелые subtree
Итог:
- sustained long tasks по 150-500ms
- почти нет idle gaps
- heap распухает как следствие sustained work
- дальше уже возможен Chromium/V8 native fault
132/133
Это очень похоже на "renderer saturates itself useful-looking no-op work", а не на обычную JS memory leak.
3. Факты из текущего кода
3.1 Что уже хорошо
messages уже частично вынесены:
src/main/services/team/TeamDataService.tsуже имеетgetMessagesPage()src/preload/index.tsуже прокидываетteam:getMessagesPagesrc/shared/types/api.tsуже описываетTeamsAPI.getMessagesPage(...)src/renderer/components/team/messages/MessagesPanel.tsxуже грузит страницы черезgetMessagesPage()src/renderer/components/team/members/MemberMessagesTab.tsxтоже умеет грузить страницы черезgetMessagesPage()
То есть messages feed как отдельная boundary уже существует. Это важный факт.
3.2 Что всё ещё дорого даже после split, если это не исправить
Текущий getMessagesPage() в TeamDataService на каждый вызов:
- заново читает inbox / lead texts / sent messages
- заново делает dedup
lead_session/lead_process - заново делает enrichment
leadSessionId - заново сортирует весь массив
- и только потом режет страницу
Это значит:
- если после split мы просто чаще зовём
getMessagesPage()наlead-message/inbox, можно перенести часть нагрузки из renderer обратно в main - transport boundary сама по себе не гарантирует дешёвый hot path
Поэтому shared main-side normalized message feed cache - не nice-to-have, а часть правильного решения.
3.3 Что всё ещё не разделено
getData() всё ещё остаётся смешанным transport'ом:
- собирает messages
- режет их до
MAX_RETURN_MESSAGES = 50 - возвращает
messagesвнутриTeamData - передаёт
messagesвTeamMemberResolver.resolveMembers(...)
Это означает:
- даже "structural" refresh тянет message-derived часть модели
- members в snapshot зависят от message history
- новый
TeamDataref почти гарантирован даже при пустом visible diff
3.4 Где сейчас сцепка особенно сильная
src/renderer/store/slices/teamSlice.ts-refreshTeamData()всегда пишет новыйselectedTeamDatasrc/renderer/components/team/TeamDetailView.tsx- подписка на весьselectedTeamDatasrc/renderer/components/team/messages/MessagesPanel.tsx-effectiveMessages = merge(fetchedMessages, propMessages)src/renderer/components/team/activity/ActivityTimeline.tsx- filter/group/visible timeline расчёты идут от whole messages arraysrc/renderer/components/team/members/MemberDetailDialog.tsx- диалог получаетmessagesиз team snapshotsrc/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts- graph всё ещё читаетTeamData.messagessrc/renderer/components/layout/PaneContent.tsx- табы не размонтируются, а скрываются черезdisplay: none
3.5 Вывод из этих фактов
Messages уже выделены как feed API, но snapshot модели и renderer subscriptions ещё живут так, как будто messages по-прежнему часть основной detail модели.
Значит реально надо разделять не "messages вообще", а вот это:
- structural team snapshot
- message feed
- message-derived lightweight member/team activity meta
Именно getMemberActivityMeta здесь ключевой новый слой.
4. Почему текущий semantic guard - хороший, но недостаточный
Фраза "semantic-equality guard перед set() звучит как самый правильный следующий шаг" по сути верная.
Но глубже:
- как immediate mitigation - да, это правильный следующий шаг
- как final architecture - нет, этого мало
Почему он всё равно нужен:
- он гасит no-op churn
- он дешёв относительно эффекта
- в кодовой базе уже есть хороший precedent в
fetchMemberSpawnStatuses()с semantic equality suppression
Почему его мало:
TeamDataвсё ещё останется слишком широким контрактом- message churn всё ещё будет инвалидировать большой subtree
- graph/member dialogs/status block всё ещё будут сидеть на том же data blob
- сама форма данных останется неправильно сцепленной
Правильная формулировка:
semantic guard нужен обязательно, но как часть split architecture, а не вместо неё
5. Что именно надо разделить
Здесь важно не запутаться.
5.1 Нет, messages не надо "разделять с нуля"
Они уже разделены:
- есть
getMessagesPage() - есть pagination
- renderer уже умеет этим пользоваться
5.2 Да, в основном надо разделить member activity meta
Потому что именно она сейчас скрыто живёт внутри TeamData через:
ResolvedTeamMember.statusResolvedTeamMember.messageCountResolvedTeamMember.lastActiveAt- status blocks и pending replies, которые сейчас фактически упираются в
messages
5.3 И да, getData() надо сделать более structural
Не в смысле "разрезать на 20 endpoints", а в смысле:
- убрать из него message-heavy responsibility
- перестать использовать full message array как часть canonical detail snapshot
То есть ответ на вопрос "мы что в основном разделяем getMemberActivityMeta?" такой:
Да.
Но это работает только вместе с тем, что getData() перестаёт быть message-derived snapshot'ом.
6. Endpoint ли это REST
Нет.
В этом проекте это должен быть IPC endpoint, а не REST API endpoint.
То есть по форме это будет что-то в таком духе:
TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta'- wiring в
src/main/ipc/teams.ts - preload bridge в
src/preload/index.ts - тип в
src/shared/types/api.ts
Так что слово "endpoint" здесь надо понимать как app-internal IPC surface.
7. Целевая архитектура
7.1 Data boundaries
Нормальная финальная схема должна выглядеть так:
getData(teamName)возвращает structural snapshotgetMessagesPage(teamName, { limit, cursor })возвращает сообщенияgetMemberActivityMeta(teamName)возвращает лёгкие message-derived aggregate данные
В renderer это хранится раздельно:
teamDataCacheByName[teamName]teamMessagesByName[teamName]memberActivityMetaByTeam[teamName]
Concrete naming note
Чтобы не плодить в документе две конкурирующие сущности, structural snapshot cache в renderer дальше следует понимать так:
- концептуально - snapshot cache per team
- конкретно в текущем плане и store shape -
teamDataCacheByName
Отдельный bucket teamSnapshotByName в этом плане не вводится.
А UI собирает view-model как overlay:
- base structural team snapshot
- overlay member activity meta
- overlay latest loaded messages
- overlay member spawn statuses
7.2 Что остаётся в structural snapshot
Должно остаться:
teamNameconfigtaskskanbanStateprocesseswarningsisAlive- structural member description из config/meta
Важная корректировка по members
Сейчас ResolvedTeamMember смешивает structural и message-derived поля.
Это надо разрулить.
Есть два пути:
- Либо ввести новый тип
TeamMemberSnapshot - Либо оставить
ResolvedTeamMember, но вытащить из него message-derived смысл в отдельный overlay
Для надёжности и понятности лучше путь 1.
Предлагаемый structural member type
export interface TeamMemberSnapshot {
name: string;
currentTaskId: string | null;
taskCount: number;
color?: string;
agentType?: string;
role?: string;
workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
cwd?: string;
gitBranch?: string;
runtimeAdvisory?: MemberRuntimeAdvisory;
removedAt?: number;
}
Обратите внимание:
- тут нет
messageCount - тут нет
lastActiveAt - тут нет
status, если он message-derived
Если нужен unified UI member status, он должен собираться поверх:
- spawn status
- member activity meta
- active task presence
7.3 Что уходит в member activity meta
Туда должны уйти поля, которые меняются от message/inbox/head activity:
export interface MemberActivityMetaEntry {
memberName: string;
/**
* Последнее сообщение, написанное самим участником.
* Важно: это не "последнее сообщение, где участник упомянут",
* а именно authored activity, чтобы сохранить текущую семантику `lastActiveAt`.
*/
lastAuthoredMessageAt: string | null;
/** Exact historical count of authored messages for this member. */
messageCountExact: number;
/**
* True, если последнее authored message было terminal signal
* вроде shutdown approval. Это raw fact, а не итоговый display status.
*/
latestAuthoredMessageSignalsTermination: boolean;
}
export interface TeamMemberActivityMeta {
teamName: string;
computedAt: string;
members: Record<string, MemberActivityMetaEntry>;
/**
* Revision shared normalized message feed, на котором собрана meta.
* Если revision не менялся, meta можно переиспользовать без пересчёта.
*/
feedRevision: string;
}
Что важно не тащить в этот контракт
Не надо класть туда:
- full messages
- rendered timeline groups
- React-specific computed state
- tab-specific UI toggles
- ticking pending-reply booleans, зависящие от local clock
crossTeamPendingRepliesс TTL-логикой
Важная смысловая граница
TeamMemberActivityMeta хранит только стабильные message-derived факты.
Туда не должны попадать:
- локальные optimistic "ждём ответ"
- таймерные TTL-состояния
- всё, что должно тикать раз в секунду от
Date.now()
7.4 Что остаётся у messages
Messages должны жить только здесь:
getMessagesPage()- renderer message cache
- специализированные consumers:
MessagesPanel,MemberMessagesTab, graph/activity features
Это снимает главный structural problem:
- message changes больше не обязаны пересоздавать весь team detail snapshot
8. Предлагаемые контракты
8.1 Shared types
Рекомендуемый набор типов:
export interface TeamViewSnapshot {
teamName: string;
config: TeamConfig;
tasks: TeamTaskWithKanban[];
members: TeamMemberSnapshot[];
kanbanState: KanbanState;
processes: TeamProcess[];
warnings?: string[];
isAlive?: boolean;
}
export interface TeamMemberSnapshot {
name: string;
currentTaskId: string | null;
taskCount: number;
color?: string;
agentType?: string;
role?: string;
workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
cwd?: string;
gitBranch?: string;
runtimeAdvisory?: MemberRuntimeAdvisory;
removedAt?: number;
}
export interface MemberActivityMetaEntry {
memberName: string;
lastAuthoredMessageAt: string | null;
messageCountExact: number;
latestAuthoredMessageSignalsTermination: boolean;
}
export interface TeamMemberActivityMeta {
teamName: string;
computedAt: string;
members: Record<string, MemberActivityMetaEntry>;
feedRevision: string;
}
export interface MessagesPage {
messages: InboxMessage[];
nextCursor: string | null;
hasMore: boolean;
/** Revision всего normalized feed, а не только текущего page slice. */
feedRevision: string;
}
8.2 API surface
export interface TeamsAPI {
getData: (teamName: string) => Promise<TeamViewSnapshot>;
getMessagesPage: (
teamName: string,
options?: { cursor?: string | null; limit?: number }
) => Promise<MessagesPage>;
getMemberActivityMeta: (teamName: string) => Promise<TeamMemberActivityMeta>;
}
Paging contract for getMessagesPage()
Здесь нельзя оставлять двоякость между "timestamp paging" и "cursor paging".
Locked choice:
- request принимает
cursor, а неbeforeTimestamp cursor === nullили отсутствие cursor означает "дай head page"cursor- opaque compound token, построенный main-side из boundary message, минимумtimestamp|effectiveMessageId- older-page semantics строго exclusive: response не должен повторно включать boundary row, из которой был выдан
nextCursor nextCursor === nullозначает, что более старой canonical history больше нет
Следствие:
- renderer/store не реконструируют cursor самостоятельно
- timestamp-only paging в merged target отсутствует
- equality/merge semantics не зависят от неустойчивого порядка сообщений с одинаковым timestamp
Почему можно оставить имя getData
С практической точки зрения это снизит churn:
- старый IPC name можно не переименовывать сразу
- меняется shape, но не transport route
Мой вывод:
- каноническое понятие в плане должно называться
TeamViewSnapshot - IPC method name в этом PR остаётся
getData
8.3 Old to new field mapping
Это важная таблица миграции. По ней проще всего проверять, не оставили ли мы hidden legacy coupling.
| Old place | Old field / responsibility | New owner after split |
|---|---|---|
TeamData.messages |
recent message batch | selectTeamMessages(teamName) over canonicalMessages + optimisticMessages |
ResolvedTeamMember.messageCount |
exact historical authored count | memberActivityMetaByTeam[teamName].members[name].messageCountExact |
ResolvedTeamMember.lastActiveAt |
last authored message timestamp | memberActivityMetaByTeam[teamName].members[name].lastAuthoredMessageAt |
ResolvedTeamMember.status |
display-ready member status | renderer overlay helper from snapshot + meta + spawn state |
MessagesPanel local fetch state |
page loading / cursors / merge | store-owned teamMessagesByName[teamName] |
MemberMessagesTab direct IPC fetch |
member message loading | store-owned message feed + selector filtering |
StatusBlock snapshot messages prop |
cross-team pending TTL derivation | store-backed messages + local timer |
8.4 Member type migration strategy
Это место нельзя оставлять неявным, потому что сейчас слишком много renderer кода ожидает ResolvedTeamMember.
Правильная миграция такая:
- IPC transport перестаёт возвращать
ResolvedTeamMember[] - IPC transport начинает возвращать
TeamMemberSnapshot[] - renderer собирает поверх этого
ResolvedTeamMemberView[] - UI-компоненты постепенно переводятся на
ResolvedTeamMemberView
Важное правило
ResolvedTeamMember больше не должен означать одновременно:
- и IPC transport type
- и renderer display model
Это две разные ответственности.
Рекомендуемый тип
interface ResolvedTeamMemberView extends TeamMemberSnapshot {
status: MemberStatus;
lastActiveAt: string | null;
messageCount: number;
hasPendingReply?: boolean;
}
Locked choice
Для этого PR лучше:
- оставить
ResolvedTeamMemberViewrenderer-only type - не возвращать его из main
- не держать старый
ResolvedTeamMemberкак transport alias "для удобства"
9. Main-process design
9.1 TeamDataService.getData() должен стать structural
Сейчас внутри getTeamData() messages делают две большие вещи:
- сами попадают в response
- участвуют в
resolveMembers(...)
Значит надо:
- перестать возвращать
messagesв snapshot - перестать рассчитывать members от full message array
Новый shape flow
Примерно так:
const members = this.memberResolver.resolveStructuralMembers(
config,
metaMembers,
inboxNames,
tasksWithKanban
);
return {
teamName,
config,
tasks: tasksWithKanban,
members,
kanbanState,
processes,
warnings,
};
9.2 TeamMemberResolver надо разделить
Сейчас TeamMemberResolver делает слишком много:
- собирает member roster
- считает task ownership
- выводит status/messageCount/lastActiveAt из full messages
Это надо разрезать.
Правильнее так
TeamMemberResolver.resolveStructuralMembers(...)
Отвечает только за:
- список имён
- merge config/meta/inbox-derived members
- task ownership
- structural member props
MemberActivityMetaService.getMeta(teamName)
Отвечает за:
- last authored activity
- exact historical counts
- terminal message facts
Это даст нормальный SRP и снимет скрытую message coupling из snapshot.
9.3 Как реализовать getMemberActivityMeta() надёжно
Здесь тонкое место не только в meta, а в общем hot path сообщений.
Если после split:
getMessagesPage()сам продолжит на каждый вызов делать raw full rescan + normalize- и
getMemberActivityMeta()отдельно тоже будет делать raw full rescan
то мы просто перенесём часть нагрузки из renderer обратно в main.
Поэтому правильный вариант такой:
Strategy A - shared normalized message feed cache + meta cache by feed revision
Нужны два слоя.
Layer 1 - TeamMessageFeedService
Отвечает за:
- чтение raw sources
- присвоение каждому message row stable effective identity
- dedup
lead_session/lead_process - enrichment
leadSessionId - annotate slash responses
- stable newest-first sort
- shared cache normalized message feed по
teamName - вычисление
feedRevision
Важно:
- этот слой становится единым backend для
getMessagesPage() - и единым backend для
getMemberActivityMeta() - нельзя оставлять старый inline normalize flow внутри
getMessagesPage()параллельно с новым сервисом
Примерно такой contract:
interface TeamNormalizedMessageFeed {
teamName: string;
revision: string;
messages: InboxMessage[];
newestTimestamp: string | null;
builtAt: number;
}
feedRevision contract
Это один из самых критичных контрактов всего плана.
Правило:
feedRevision- это opaque, но content-stable revision full normalized feed- если normalized feed семантически тот же,
feedRevisionобязан остаться тем же - если normalized feed реально изменился,
feedRevisionобязан измениться
Что запрещено:
- генерировать
feedRevisionотbuiltAt - генерировать
feedRevisionотDate.now() - протаскивать наружу raw source fingerprint вида "mtime изменился, значит revision новый", если normalized output по факту не изменился
Разрешённый компромисс:
- internal source fingerprint может быть более консервативным и использоваться только для решения "rebuild or reuse cache"
- но наружу в
MessagesPage.feedRevisionиTeamMemberActivityMeta.feedRevisionдолжен попадать именно revision нормализованного feed result, а не internal invalidation token
Иначе:
feedChangedстанет почти всегдаtruerefreshMemberActivityMeta()начнёт зря крутиться- store снова получит churn без реального изменения данных
Message identity contract
Это место нужно зафиксировать жёстко, иначе pagination и merge легко станут источником скрытых дублей.
Правило:
TeamMessageFeedServiceдолжен выдавать feed, где у каждого message row уже есть stable effective identity- для этого reuse existing main-side identity semantics вроде
getEffectiveInboxMessageId(...), а не вводить ещё один независимый renderer fallback algorithm - cursor
timestamp|messageIdдолжен строиться по effective message id, а не по "сырым optional ids"
Следствие:
- store merge older pages / head refresh / optimistic confirmation работают по одной и той же identity semantics
- read-state keys и message expansion keys не расходятся с transport identity
- исчезает класс багов "дубль после head refresh", когда у одной и той же canonical message в разных местах разные fallback keys
Locked implementation choice:
- целевой merged state этого PR - canonical feed rows всегда приходят с non-empty
messageId, уже нормализованным main-side effective identity - renderer helpers вроде
toMessageKey()после этого должны фактически опираться наmessageIdкак на normal path - fallback key branch остаётся только как defensive guard для старых/optimistic/local edge cases, а не как вторая равноправная identity model
Cache invalidation strategy for feed service
Первая реализация должна быть консервативной, а не "слишком умной".
Разрешённый подход:
- feed service хранит source fingerprint per team
- если fingerprint совпал, возвращаем cached feed
- если fingerprint изменился или есть любая неуверенность, rebuild whole normalized feed
Что может входить в fingerprint:
- inbox source revision / mtime / count
- lead session id / session history related revision
- sent messages store revision
Что не надо делать в первой реализации:
- partial in-place patching normalized feed несколькими независимыми эвристиками
- сложный delta merge между raw sources до появления профилирования
Правило:
- на первом шаге correctness важнее микрооптимизации
- optimisation boundary здесь - reuse cached feed when unchanged, а не умный partial patch when changed
- exposed
feedRevisionпосле rebuild должен вычисляться по normalized feed result, а не копировать internal fingerprint один в один
Layer 2 - MemberActivityMetaService
Отвечает за:
- построение
TeamMemberActivityMetaиз normalized feed - кэширование результата по
feedRevision
Примерно такой cache entry:
interface TeamMemberActivityMetaCacheEntry {
teamName: string;
feedRevision: string;
meta: TeamMemberActivityMeta;
builtAt: number;
}
Важная деталь про no-op meta churn
Даже если feedRevision изменился, это не всегда значит, что поменялись member-facing activity facts.
Пример:
- пользователь отправил новое сообщение участнику
- head feed изменился
- exact authored counters самих участников не изменились
lastAuthoredMessageAtучастников тоже не изменился
Следствие:
MemberActivityMetaServiceможет вернуть новый wrapper с новымfeedRevision- но
membersrecord внутри должен использовать structural sharing для неизменившихся entry - UI selectors не должны подписываться на
computedAtкак на render-driving поле
Иначе можно случайно вернуть churn в member list уже после правильного split.
Как должна выглядеть зависимость
const feed = await teamMessageFeedService.getFeed(teamName);
const meta = await memberActivityMetaService.getMeta(teamName, feed);
Почему это лучший баланс для этого PR
- дорогой raw normalization живёт в одном месте
getMessagesPage()просто режет page из cached normalized feedgetMemberActivityMeta()не трогает raw storage напрямую- если revision не изменился, meta возвращается без пересчёта
- O(n) meta rebuild по cached normalized feed при текущих observed объёмах сообщений выглядит безопаснее и проще, чем отдельный delta engine
Как сохраняем старую authored semantics
Meta строится по authored activity:
lastAuthoredMessageAtсчитается по сообщениямfrom === member.namemessageCountExact- это exact historical count authored messageslatestAuthoredMessageSignalsTerminationсмотрит на последнее authored message и повторяет старую termination semantics
То есть member-specific facts не считаются по любому сообщению, где member просто фигурирует в to.
9.4 Почему не нужен отдельный delta engine в этом PR
Отдельный delta engine можно добавить потом, если появятся реальные цифры, что даже meta rebuild по cached normalized feed стал горячей точкой.
Но в этом PR он не обязателен, потому что:
- shared feed cache уже убирает главную проблему repeated raw rescans
- solution с
feedRevisionпроще тестировать - меньше риск сломать edge cases и дедуп-семантику
10. Renderer-side design
10.1 Новые store slices
Нужны отдельные state buckets:
interface TeamMessagesCacheEntry {
canonicalMessages: InboxMessage[];
optimisticMessages: InboxMessage[];
feedRevision: string | null;
nextCursor: string | null;
hasMore: boolean;
lastFetchedAt: number | null;
loadingHead: boolean;
loadingOlder: boolean;
headHydrated: boolean;
}
interface TeamSlice {
selectedTeamData: TeamViewSnapshot | null;
teamDataCacheByName: Record<string, TeamViewSnapshot>;
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
refreshTeamData: (teamName: string, opts?: RefreshTeamDataOptions) => Promise<void>;
refreshTeamMessagesHead: (teamName: string) => Promise<RefreshTeamMessagesHeadResult>;
loadOlderTeamMessages: (teamName: string) => Promise<void>;
refreshMemberActivityMeta: (teamName: string) => Promise<void>;
applyOptimisticTeamMessage: (teamName: string, message: InboxMessage) => void;
}
Snapshot/meta bootstrap semantics
До первой successful hydration отсутствие cache entry - это нормальное состояние.
Правила:
- отсутствие
teamDataCacheByName[teamName]означает "structural snapshot ещё не загружен", а не ошибку - отсутствие
memberActivityMetaByTeam[teamName]означает "activity meta ещё не загружена или ещё ни разу успешно не доезжала" - store не должен создавать фиктивные placeholder-объекты только ради того, чтобы избежать
null/undefined - UI selectors и view-model layer должны уметь работать с отсутствием этих записей через стабильные fallback selectors, а не через ad-hoc object fabrication в компонентах
Причина:
- placeholder wrappers легко создают лишние ref changes и запутывают разницу между "нет данных пока" и "есть пустые данные"
- canonical source of truth должен оставаться простым: entry либо реально есть, либо его ещё нет
Non-reactive orchestration internals
Не весь orchestration state должен жить в observable store.
Допустимо и желательно держать вне reactive state:
- in-flight promise maps per team/action
- dirty flags / follow-up flags
- explicit visibility registry
- internal cooldown / debounce bookkeeping
Нельзя без необходимости тащить эти вещи в публичный reactive store shape, если UI не должен на них рендериться.
Причина:
- иначе сам orchestration layer начинает становиться источником re-render churn
- reactive store должен хранить в первую очередь данные и только те control flags, которые реально нужны UI
TeamMessagesCacheEntry field semantics
Чтобы не было двух трактовок, значения полей должны пониматься так:
canonicalMessages- весь уже загруженный canonical message window для команды, newest-first, включая head page и все успешно догруженные older pagesoptimisticMessages- только локальные ещё не подтверждённые rowsfeedRevision- revision full normalized feed, на котором построен текущий canonical head statenextCursor- cursor для следующей older page после самого старого canonical message, уже находящегося вcanonicalMessageshasMore- есть ли ещё canonical history старше текущегоnextCursor; до первой successful head hydration это bootstrap flag и не интерпретируется как terminal exhaustionlastFetchedAt- timestamp последнего успешного canonical message fetch/merge для этой команды; до первого success равенnullи не обновляется на failed attemptloadingHead- в полёте primary head refresh для canonical windowloadingOlder- в полёте older-page extension текущего canonical windowheadHydrated- был ли хотя бы один успешный canonical head fetch
Следствие:
- head refresh обновляет canonical head portion, но не "забывает" уже загруженные older pages
- older-page loading расширяет
canonicalMessagesвниз по истории, а не создаёт отдельный side bucket
Bootstrap empty entry
До первой successful head hydration canonical message entry должен иметь предсказуемый bootstrap state:
{
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
}
Важно:
- bootstrap
hasMore: falseдо first hydration не означает, что history exhausted - terminal meaning у
hasMore === falseиnextCursor === nullпоявляется только послеheadHydrated === true
Successful empty head hydration
У команды может быть корректный successful head refresh и при этом ноль canonical messages.
В таком случае canonical state должен стать таким:
{
canonicalMessages: [],
optimisticMessages: /* whatever local optimistic rows currently exist */,
feedRevision: "<non-null revision>",
nextCursor: null,
hasMore: false,
lastFetchedAt: <success timestamp>,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
}
Важно:
- empty successful feed не оставляет
headHydrated === false - empty successful feed не оставляет
feedRevision === null - иначе команда без history будет вечно выглядеть как "ещё не гидратирована"
Pre-hydration optimistic entry
Если пользователь отправил optimistic message до первого successful head hydration, это допустимое состояние.
В таком случае:
canonicalMessagesостаётся пустымoptimisticMessagesможет быть non-emptyheadHydratedостаётсяfalseдо первого successful canonical head fetchfeedRevision,nextCursor,lastFetchedAtостаются bootstrap/null до первого success
То есть optimistic rows могут существовать поверх bootstrap entry, не превращая его в hydrated canonical state.
TeamMessagesCacheEntry state invariants
Чтобы store не собрал внутренне противоречивое состояние, ниже зафиксированы инварианты:
headHydrated === false=>canonicalMessages.length === 0headHydrated === false=>loadingOlder === falseheadHydrated === false=>feedRevision === nullheadHydrated === false=>nextCursor === nullheadHydrated === false=>lastFetchedAt === nullloadingHead === true && loadingOlder === trueдля одной команды в корректной реализации не допускаетсяhasMore === false=>nextCursor === nullcanonicalMessages.length === 0не означает ошибку само по себе, еслиheadHydrated === false- failed request не имеет права менять
lastFetchedAt - любой settled request обязан снять соответствующий loading flag, даже если response был stale-ignored
Если implementation хочет хранить дополнительный error/debug state, он хранится отдельно от этого entry.
Operational definitions
Чтобы разные исполнители не вкладывали разный смысл в одни и те же слова, ниже фиксированные определения.
visible active team
- команда, для которой прямо сейчас существует видимый team-detail или graph consumer в UI
- hidden mounted tabs через
display: noneсюда не входят только потому, что компонент всё ещё смонтирован - store должен опираться на явный visibility signal, а не на факт mount'а subtree
- одного факта
selectedTeamName === teamNameнедостаточно, чтобы считать командуvisible active team
visibility signal
- renderer держит явный per-team visibility registration, а не выводит visibility косвенно из mount state
- минимум
TeamDetailViewcontainer и graph container обязаны регистрировать и снимать этот сигнал при реальном показе/скрытии - CSS-hidden subtree не считается visible consumer
- fallback polling и event routing consult именно этот explicit signal
- допустим ref-count или set of visible consumers per team, но merged code не должен зависеть от "компонент всё ещё смонтирован, значит команда активна"
active local pending-reply wait state
- у команды есть хотя бы один unresolved
pendingRepliesByMemberentry, который ещё находится в локальном waiting window - это именно renderer-local UX reason держать лёгкий message polling
- это не означает, что команда становится structural-refresh priority
headHydrated
- хотя бы один successful head fetch уже положил canonical head page в store entry
headHydrated === falseозначает "canonical message source для этой команды ещё не инициализирован"- optimistic rows могут существовать и до
headHydrated === true, но не заменяют canonical hydration
compatibility adapter
- временный branch-local helper, который помогает перевести consumer на новый shape без изменения transport contract обратно
- допустим только в renderer migration path
- не допускается как новый shared type alias, новый IPC compatibility contract или новый main-side legacy field
Contract for refreshTeamMessagesHead()
Обычный Promise<boolean> здесь слишком двусмысленный.
Надёжнее сразу зафиксировать semantic result:
interface RefreshTeamMessagesHeadResult {
feedChanged: boolean;
headChanged: boolean;
feedRevision: string | null;
}
Где:
feedChanged- изменился revision всего normalized feed относительно store cacheheadChanged- изменился реально canonical head slice, который подписан в UIfeedRevision- revision после refresh
Инварианты:
headChanged === trueподразумеваетfeedChanged === true- состояние
feedChanged === false && headChanged === trueв корректной реализации невозможно - состояние
feedChanged === false && headChanged === falseозначает, что canonical message inputs для UI не изменились - состояние
feedChanged === true && headChanged === falseдопустимо и означает historical-only/full-feed change без изменения текущего head slice
Почему это важно:
- старые сообщения могут доехать в feed без изменения top page
memberActivityMetaзависит от full feed semantics, а не только от head pageMessagesPanelможет не перерисоваться, но member activity overlay всё равно должен знать, что full feed поменялся
Single-flight request discipline
Даже правильный data split можно испортить, если store начнёт одновременно запускать 5 одинаковых refresh-запросов на burst events.
Правило:
- для каждого
teamNamestore держит single-flight orchestration отдельно для:refreshTeamData()refreshTeamMessagesHead()loadOlderTeamMessages()refreshMemberActivityMeta()
- если такой же запрос уже в полёте, новые триггеры reuse existing promise или ставят один follow-up dirty flag
- store не запускает unbounded parallel head refreshes на каждое событие watcher burst
Дополнительно:
- responses применяются только через team-scoped request guard
- stale response после team switch / newer refresh не должен откатывать store назад
Canonical message mutation serialization
Это отдельное правило поверх single-flight:
- для одного
teamNamecanonical message window не должен одновременно мутироваться изrefreshTeamMessagesHead()иloadOlderTeamMessages() - head refresh и older-page load для одной команды сериализуются через общий canonical-message mutation lane
- если во время
loadingOlder === trueприходит новый head trigger, store помечает team как dirty и выполняет head refresh сразу после завершения текущего canonical mutation - если во время
loadingHead === trueприходитloadOlderTeamMessages(), older load либо reuse'ит уже идущую hydration sequence, либо ждёт её завершения
Причина:
- это сильно упрощает merge correctness
- это убирает лишний класс reorder bugs между head refresh и older-page append
- stale-response guards должны остаться как защита, но не быть основной стратегией нормального control flow
Что значит meta stale
Чтобы здесь не было произвольных трактовок, isMemberActivityMetaStale(teamName) должен означать одно из:
- meta entry для команды отсутствует
memberActivityMeta.feedRevision !== teamMessagesByName[teamName].feedRevision- safety TTL для visible active team истёк после длительного watcher silence
И не должен означать:
- "прошло немного времени, давайте на всякий случай ещё раз всё пересчитаем"
- "head refresh выполнился, значит meta точно stale"
UI selector discipline for activity meta
Это место надо зафиксировать жёстко, иначе churn легко вернётся через selector layer.
Правило:
- UI consumers, которым нужны member facts, не подписываются на whole
TeamMemberActivityMeta - UI readers используют selector уровня facts, например
selectMemberActivityFacts(teamName) - routing / stale detection logic может отдельно читать
selectMemberActivityFeedRevision(teamName)иcomputedAt, если это реально нужно
Причина:
feedRevisionможет измениться без изменения member-facing factscomputedAtпочти никогда не должен быть render-driving полем- подписка на весь wrapper снова создаст лишние re-renders в member list / hover / badges
Почему это важнее, чем просто больше useMemo
Потому что store boundary определяет, что вообще считается "данные изменились".
Если boundary широкая, никакой useMemo потом уже красиво не спасёт.
Дополнительное правило
Store после миграции становится единственной точкой orchestration для:
- head refresh
- older-page loading
- optimistic message merge
- activity meta refresh
- fallback polling
Компоненты после миграции только:
- подписываются на store
- вызывают store actions
- не знают про IPC детали
Selector stability rule for merged messages
Это критично. Иначе можно формально вынести messages из snapshot, но всё равно продолжить churn через новые массивы.
Правило:
selectTeamMessages(teamName)обязан возвращать stable array ref, еслиcanonicalMessagesиoptimisticMessagesref'ы не изменилисьselectMemberMessages(teamName, memberName)обязан строиться как memoized derived selector per pair, а не как новый.filter(...)на каждый store readmergeTeamMessages()не должен вызываться "в лоб" внутри обычного selector body без memoization
Разрешённые варианты:
- memoized selector factory per
teamName - precomputed merged view inside store entry с корректным structural sharing
Недопустимый вариант:
- каждый store read создаёт новый merged messages array даже при отсутствии изменений входов
Иначе MessagesPanel, ActivityTimeline, graph и member tabs снова начнут получать churn уже после правильного split.
Optimistic storage rule
Чтобы не терять optimistic rows во время canonical refresh, store не должен хранить один "голый" messages[].
Правильнее:
canonicalMessages- то, что пришло из main feedoptimisticMessages- локальные optimistic rows, которые ещё не подтверждены canonical feed- selector
selectTeamMessages(teamName)возвращает уже merged view
Это снимает типовую race-проблему:
- user отправил сообщение
- optimistic row показался
- canonical head page ещё не успела включить это сообщение
- новый head refresh не должен "откатить" optimistic row
Cursor and page merge semantics
Эта часть должна быть описана явно, иначе loadOlderTeamMessages() почти гарантированно получит race bugs.
Правила:
loadOlderTeamMessages()не должен пытаться грузить older history, покаheadHydrated === false- если older load запрошен до first head hydration, store сначала делает
refreshTeamMessagesHead()и только потом решает, есть ли что догружать - cursor остаётся compound-format
timestamp|effectiveMessageId - older-page request должен помнить
baseFeedRevision, на котором был выдан егоnextCursor loadOlderTeamMessages()всегда используетnextCursorиз текущего canonical store entry, а не локальное component state- если
hasMore === falseилиnextCursor === null,loadOlderTeamMessages()делает cheap no-op - head refresh не заменяет весь canonical list целиком, если уже были подгружены older pages
- head refresh обновляет верхнюю часть feed и потом merge'ится с уже загруженной historical частью через единый merge helper
- older-page response тоже merge'ится, а не "append blindly"
- dedup и stable ordering должны reuse existing semantics вроде
mergeTeamMessages()/ shared message key contract - canonical merge path не должен изобретать второй merge algorithm рядом с existing
mergeTeamMessages()semantics без отдельной причины и отдельного тестового покрытия
Особый case:
- если older-page response приходит уже после нового head refresh или после другого older-page request
- store должен применить результат только если request guard ещё актуален
- иначе response silently ignored, без отката
nextCursorи без reorder churn
Safety fallback for history rewrite / irreconcilable merge
Нельзя молча предполагать, что история всегда append-only.
В первой реализации должен быть безопасный fallback:
- если после
feedRevisionchange merge не может надёжно склеить fresh head и уже загруженную older history - store обязан сбросить только historical tail и оставить свежий canonical head page как новый baseline
- при этом optimistic rows сохраняются отдельно и не теряются
Триггеры для такого fallback:
- нарушился stable newest-first ordering invariant после merge
- seam между fresh head и retained history не удаётся дедупнуть по effective identity без противоречий
- boundary anchor вокруг
nextCursorстал недостоверным после newer revision - response относится к старому
baseFeedRevision, а в store уже живёт более новый head baseline
Что важно:
- лучше временно потерять локально подгруженный older tail, чем показать смешанное неконсистентное history state
- такой reset допустим только для canonical older window, но не для optimistic messages и не для structural snapshot
selectedTeamData / cache consistency rule
Если в store временно живут и teamDataCacheByName, и selectedTeamData, правило должно быть жёстким:
- сначала обновляется canonical cache entry per team
- потом
selectedTeamDataпросто получает тот же ref, еслиselectedTeamName === teamName - нельзя отдельно пересобирать
selectedTeamData"для удобства UI" - при смене
selectedTeamNameполеselectedTeamData, если оно ещё существует, synchronously repoint'ится наteamDataCacheByName[selectedTeamName] ?? null selectedTeamDataне имеет права продолжать указывать на snapshot предыдущей команды после того, какselectedTeamNameуже сменился
Иначе:
- no-op suppression может сработать для cache, но не сработать для current selection
TeamDetailViewпродолжит видеть churn, хотя формально cache уже исправлен
Team switch response rule
При switch A -> B store обязан вести себя так:
- late async response для
Aможет обновить только cache entry командыA - late async response для
Aне имеет права переустановитьselectedTeamData, еслиselectedTeamName !== A - hydration/open-flow для
Bидёт по обычным правиламvisible active team - если cache для
Bуже существует, UI может сразу reuse'ить этот snapshot ref; если cache дляBещё нет, допускаетсяselectedTeamData === nullдо первого успешного snapshot refresh
Цель:
- не показывать stale snapshot команды
Aпод выбранной командойB - не ломать per-team cache reuse ради selected-team convenience field
Fallback polling policy
Polling остаётся как safety net, но только в store и только по строгим правилам:
- включается для visible active team
- включается для team с active local pending-reply wait state
- не крутится для hidden inactive teams
- не переписывает structural snapshot
- делает только message-head refresh и при необходимости meta refresh
Initial visible-team hydration sequence
Это должно быть описано отдельно, чтобы open-flow не собирался по-разному в разных местах.
Когда команда становится visible active team, store обязан обеспечить такой порядок:
refreshTeamData(teamName)для structural snapshotrefreshTeamMessagesHead(teamName)для canonical head hydrationrefreshMemberActivityMeta(teamName)только после первого head result, если meta отсутствует или stalefetchMemberSpawnStatuses(teamName)как независимый overlay refresh
Допустимо:
- запускать шаги 1 и 2 параллельно
- reuse shared single-flight/feed-cache между шагами 2 и 3
Недопустимо:
- строить open-flow так, что
MemberDetailDialog,ActivityTimelineилиStatusBlockначинают сами триггерить свою собственную первичную hydration logic - считать команду "полностью гидратированной" только потому, что приехал structural snapshot без message head
Hidden-team cache retention rule
Когда команда перестаёт быть visible active team:
- store прекращает background refresh/polling для этой команды, если нет
active local pending-reply wait state - уже гидратированные snapshot/message/meta caches не очищаются только из-за hide transition
- hide transition сам по себе не должен сбрасывать
headHydrated,canonicalMessages,memberActivityMetaByTeam[teamName]илиteamDataCacheByName[teamName]
В этом PR не вводится отдельная eviction policy.
Причина:
- eager clear-on-hide легко превращает reopen в повторный burst hydration path
- cache retention и background refresh ownership - это разные вещи, их нельзя смешивать
Reopen rule after hide
Если команда была скрыта, а потом снова стала visible active team:
- store reuse'ит уже имеющиеся snapshot/message/meta caches как baseline
- open-flow может поверх этого сделать refresh по обычным visible-team правилам
- reopen не должен вести себя как forced cold-start только из-за предыдущего hide transition
Failure semantics for store actions
Это тоже должно быть однозначно:
refreshTeamData()failure не очищает предыдущий structural snapshotrefreshTeamMessagesHead()failure не очищаетcanonicalMessages,nextCursor,feedRevisionloadOlderTeamMessages()failure не откатывает уже загруженную history windowrefreshMemberActivityMeta()failure не очищает предыдущий meta facts record- любой из этих failures обязан снять соответствующий loading flag
Если нужен user-visible signal:
- он должен жить отдельным ephemeral error state / logger path
- но не через destructive reset уже загруженных данных
10.2 refreshTeamData() после split
После split refreshTeamData() должен заниматься только:
- structural snapshot
- task change invalidation
- structural sharing
- no-op suppression
Он не должен:
- догружать messages
- дёргать member activity computations
- быть universal answer на любой
lead-message
10.3 Новый routing событий
Правильнее распределить так:
lead-message
Должен триггерить:
refreshTeamMessagesHead(teamName)refreshMemberActivityMeta(teamName)только еслиfeedChanged === trueили meta stale
Но не full refreshTeamData() по умолчанию.
И только если team реально нужна сейчас:
- видима хотя бы в одном pane
- или у неё есть active local pending-reply wait state
inbox
Тоже:
refreshTeamMessagesHead(teamName)refreshMemberActivityMeta(teamName)только еслиfeedChanged === trueили meta stale
С тем же visibility правилом:
- visible team
- или active local pending-reply wait state
task
Должен триггерить:
refreshTeamData(teamName)
config
Должен триггерить:
refreshTeamData(teamName)
Этого достаточно, потому что:
- roster и
currentTaskIdживут в structural snapshot memberActivityMetaпосле split зависит от message feed, а не от config
process
Должен триггерить:
refreshTeamData(teamName)
member-spawn
Как и сейчас:
fetchMemberSpawnStatuses(teamName)
Но без косвенного втягивания full team detail refresh, если это не требуется.
Fallback polling
Отдельно от event routing store держит лёгкий fallback poll:
- только для visible active team
- или для team с active local pending-reply wait state
- интервал остаётся coarse, а не tight
- poll вызывает только
refreshTeamMessagesHead() refreshMemberActivityMeta()вызывается только вслед заfeedChanged === trueили stale-meta condition
Это нужно на случай:
- пропущенных file/runtime events
- длинных сессий с нестабильным watcher delivery
10.4 Пример роутинга
if (event.type === 'lead-message' || event.type === 'inbox') {
const { feedChanged } = await refreshTeamMessagesHead(event.teamName);
if (feedChanged || isMemberActivityMetaStale(event.teamName)) {
scheduleMemberActivityMetaRefresh(event.teamName);
}
return;
}
if (event.type === 'task' || event.type === 'config' || event.type === 'process') {
scheduleTeamSnapshotRefresh(event.teamName);
}
Это самое большое поведенческое исправление для renderer load pattern.
Event matrix without ambiguity
| Event | Always do | Conditionally do | Must not do by default |
|---|---|---|---|
lead-message |
refreshTeamMessagesHead() |
refreshMemberActivityMeta() when feedChanged or meta stale |
refreshTeamData() |
inbox |
refreshTeamMessagesHead() |
refreshMemberActivityMeta() when feedChanged or meta stale |
refreshTeamData() |
task |
refreshTeamData() |
nothing else unless separate UI needs it | blind message refresh |
config |
refreshTeamData() |
nothing else unless separate UI needs it | blind message refresh |
process |
refreshTeamData() |
nothing else unless separate UI needs it | blind message refresh |
member-spawn |
fetchMemberSpawnStatuses() |
presentation overlay recompute in renderer | implicit full snapshot refresh |
10.5 TeamDetailView должен перестать читать всё из одного blob
Сейчас view примерно концептуально живёт так:
data = selectedTeamDatamessages = data.messagesmembers = data.members
После split правильнее:
const snapshot = useStore(selectTeamSnapshot(teamName));
const messages = useStore(selectTeamMessages(teamName));
const memberActivityFacts = useStore(selectMemberActivityFacts(teamName));
const memberSpawnStatuses = useStore(selectMemberSpawnStatuses(teamName));
А дальше уже в selector / adapter layer собирается view model:
const membersWithActivity = useMemo(
() => mergeMembersWithActivity(snapshot.members, memberActivityFacts, memberSpawnStatuses),
[snapshot.members, memberActivityFacts, memberSpawnStatuses]
);
Это делает invalidation адресным:
- messages change не обязаны ломать tasks/processes/member roster UI
- member meta change не обязана пересоздавать task board
Как именно должен собираться member status
После split итоговый member.status больше не приходит готовым из main snapshot.
Правильная схема:
- meta даёт raw activity facts
- snapshot даёт
currentTaskId - spawn layer даёт runtime/provisioning signals
- renderer helper собирает итоговый display status для UI
Это важный момент, потому что иначе легко снова смешать transport facts и UI semantics.
10.6 MessagesPanel должен работать только от message store
Сейчас он смешивает:
- prop seed messages
- fetched page messages
После split:
MessagesPanelполучаетselectTeamMessages(teamName)- optimistic send updates идут прямо в message store
- начальная head hydration делается через store action, а не через prop fallback
loadOlderMessagesидёт через store action, а не через прямой IPC call из компонента
Это особенно важно
Пока у MessagesPanel есть prop messages, snapshot продолжает быть скрытым transport'ом для messages.
Это надо убрать полностью.
И ещё одно важное правило
MessagesPanel не должен стать вторым orchestration layer.
То есть внутри него не должно остаться:
- отдельного
fetchIdRef - собственного polling lifecycle
- второй логики merge/dedup поверх store ownership
10.7 ActivityTimeline
Это тоже message-heavy consumer, и его нельзя оставлять "подразумеваемым".
После split:
ActivityTimelineчитает store-backed messages selector или отдельный timeline view-model selector, а неselectedTeamData.messages- timeline grouping/filtering не живёт от старого snapshot prop
- компонент не содержит собственного fetch/polling/orchestration path
- hidden mounted tab не должен получать лишний churn только потому, что timeline подписан слишком широко
Если для timeline нужен специальный derived selector, это нормально.
Ненормально - снова фильтровать whole snapshot message blob прямо в render path.
10.8 MemberDetailDialog / MemberMessagesTab / MemberHoverCard
Сейчас dialog получает messages из TeamDetailView.
После split:
- dialog не должен принимать full team messages prop
MemberMessagesTabдолжен брать member-relevant data из message store через team-scoped selector- activity count в header должен приходить из
memberActivityMeta, а не черезbuildInlineActivityEntries(messages.filter(...))на каждый reopen MemberMessagesTabне должен сам дёргатьapi.teams.getMessagesPage(...)MemberHoverCardдолжен читатьmemberActivityFactsили готовый view-model selector, а не whole snapshot wrapper и не whole meta wrapper
Пример
const memberMeta = memberActivityFacts[member.name];
const memberActivityCount = memberMeta?.messageCountExact ?? 0;
Если нужен более богатый recent activity counter, это отдельное future extension, не часть этого PR.
10.9 Agent Graph
Это один из самых опасных edge points.
Сейчас graph adapter сидит на TeamData.messages.
Если просто выкинуть messages из TeamData, graph сломается.
Правильный путь
Graph должен перейти на тот же store-backed source, что и MessagesPanel.
Locked choice:
- store subscription живёт в graph hook / container layer
- pure adapter принимает уже готовые данные
(snapshot, messages, memberActivityFacts, teamName) - fetching и polling не уезжают внутрь graph adapter
Примерно так:
const snapshot = useStore(selectTeamSnapshot(teamName));
const messages = useStore(selectTeamMessages(teamName));
const memberActivityFacts = useStore(selectMemberActivityFacts(teamName));
const graphData = useMemo(
() => TeamGraphAdapter.adapt(snapshot, messages, memberActivityFacts, teamName),
[snapshot, messages, memberActivityFacts, teamName]
);
Почему это важно
Если graph останется на legacy TeamData.messages, вы получите:
- двойную модель
- race conditions
- скрытую потребность сохранять legacy field дольше, чем нужно
11. Structural sharing and no-op suppression
Это надо делать даже после split.
11.1 Зачем
Потому что даже structural snapshot без messages всё равно может пересоздаваться:
- новые массивы задач
- новый
configobject - новый
processesarray - новые
membersarray/object refs при одинаковом содержимом
Если этого не подавить, вы получите меньшую, но всё ещё реальную churn-проблему.
11.2 Принцип
Нужно не просто "compare then skip".
Нужно reuse old references for equal subtrees.
То есть не так:
if (deepEqual(prev, next)) return prev;
return next;
А так:
function structurallyShareTeamSnapshot(
prev: TeamViewSnapshot | null | undefined,
next: TeamViewSnapshot
): TeamViewSnapshot {
if (!prev) return next;
const sharedConfig = areConfigsEqual(prev.config, next.config) ? prev.config : next.config;
const sharedTasks = reuseArrayIfEqual(prev.tasks, next.tasks, areTasksSemanticallyEqual);
const sharedMembers = reuseArrayIfEqual(prev.members, next.members, areMembersSemanticallyEqual);
const sharedProcesses = reuseArrayIfEqual(
prev.processes,
next.processes,
areProcessesSemanticallyEqual
);
const sharedWarnings = reuseOptionalArrayIfEqual(
prev.warnings,
next.warnings,
(left, right) => left === right
);
if (
sharedConfig === prev.config &&
sharedTasks === prev.tasks &&
sharedMembers === prev.members &&
sharedProcesses === prev.processes &&
prev.isAlive === next.isAlive &&
sharedWarnings === prev.warnings
) {
return prev;
}
return {
...next,
config: sharedConfig,
tasks: sharedTasks,
members: sharedMembers,
processes: sharedProcesses,
warnings: sharedWarnings,
};
}
Примечание:
warningsтоже надо пускать через optional-array sharing, а не через голый ref compare- иначе no-op suppression останется частичной и будет зря пересоздавать snapshot wrapper
11.3 Где надо быть особенно осторожным
С semantic equality нельзя бездумно игнорировать поля.
Надо разделять:
- поля, меняющие видимый UI
- поля, не меняющие видимый UI
Пример:
updatedAtу meta - часто можно игнорироватьlastHeartbeatAt- можно игнорировать для member spawn badge equality, если UI его не показываетtask.reviewStateигнорировать уже нельзя
Нужны целенаправленные semantic comparators, а не generic deep-equal.
12. Optimistic updates
Это отдельный опасный блок.
Сейчас sendTeamMessage() оптимистично пушит message в selectedTeamData.messages.
После split надо перенести optimistic update в message store.
Правильнее так
sendTeamMessage: async (teamName, request) => {
const optimistic = buildOptimisticMessage(request, result.messageId);
get().applyOptimisticTeamMessage(teamName, optimistic);
await get().refreshTeamMessagesHead(teamName);
}
Почему здесь не нужен refreshMemberActivityMeta()
Для обычного user -> member send это лишняя работа, потому что:
messageCountExactсчитает authored messages самого memberlastAuthoredMessageAtтоже меняется только когда пишет сам member- pending-reply UX уже покрывается local
pendingRepliesByMember
Значит после user send надо:
- добавить optimistic message в store
- обновить local pending-reply state
- дождаться canonical head refresh
Но не пересчитывать activity meta сразу же.
Send failure rollback semantics
Если sendTeamMessage() завершается ошибкой до canonical confirmation:
- соответствующая optimistic row удаляется из
optimisticMessages - local pending-reply state, поставленный этим send attempt, откатывается
- canonicalMessages не трогаются
refreshMemberActivityMeta()по этому failure не запускается
Если продукт позже захочет отдельный failed-message UX со статусом retry, это уже отдельное расширение. В текущем плане failed optimistic send не должен навсегда оставлять висящую pseudo-message row в merged feed.
Почему нельзя оставить старую логику
Потому что она снова начнёт:
- мутировать snapshot semantics через messages
- держать legacy coupling
Отдельно про pending replies
Local pendingRepliesByMember остаётся в renderer:
- на send отмечаем
sentAtMs - на incoming member reply чистим локальное состояние
- delayed waiting refresh в
TeamDetailViewпосле split должен вызыватьrefreshTeamMessagesHead(teamName), а не fullrefreshTeamData(teamName)
Merge semantics for optimistic rows
Когда canonical feed в итоге содержит сообщение с тем же messageId, store должен:
- убрать соответствующую optimistic row
- оставить canonical row
- не дублировать обе версии в merged selector
13. Что делать с messageCount
Это один из самых важных product semantics вопросов.
Сейчас ResolvedTeamMember.messageCount - это exact count по full history.
В этом плане решение уже принято:
messageCountв v1 split-реализации остаётся exact historical count- значение приходит из
TeamMemberActivityMeta - значение не вычисляется в renderer по head page
Причина:
- это сохраняет текущую семантику UI и тестов
- это убирает скрытое product-изменение из и так большого performance PR
- это совместимо с shared normalized feed cache + meta-by-revision cache
Если позже product решит, что exact count не нужен, это отдельный follow-up с отдельным обсуждением UX semantics, но не часть текущего плана.
14. Edge cases и подводные камни
14.1 Hidden tabs still mounted
Пока PaneContent сохраняет tabs mounted, любое широкое store invalidation продолжает работать против вас.
Следствие:
- даже после split полезно сделать selectors максимально узкими
- не тянуть
messagesв скрытые team tabs, если они не нужны
14.2 Team switch race
Если пользователь быстро переключает команды:
refreshTeamMessagesHead(alpha)может завершиться после перехода наbeta- нельзя обновлять
selectedTeamData-подобный selected-only state без teamName validation
Нужны team-scoped caches и id guards, как уже сделано в ряде мест.
И это же правило относится к:
- older-page responses
- meta refresh responses
- delayed pending-reply refresh timers
14.3 Member removed / renamed
Если member удалён:
- structural snapshot убирает его из active списка
memberActivityMetaможет ещё содержать старую запись
Правильнее:
- не терять meta сразу, если нужен historical dialog
- но UI current member list должен фильтровать по structural roster
14.4 Pending replies semantics
Сейчас pending replies partly derived from messages.
После split нельзя потерять:
- pending reply badges by member
- pending cross-team replies
Здесь важно не перепутать две разные сущности.
Member pending replies
Это остаётся renderer-local state:
- источник истины -
pendingRepliesByMember - состояние ставится optimistically на send
- очищается, когда message feed показывает фактический reply от участника
Это не надо класть в TeamMemberActivityMeta.
Cross-team pending replies
Это остаётся renderer-derived состоянием:
- источник истины - normalized message cache
- TTL считается локально от
Date.now() StatusBlockможет продолжать держать свой 1-second timer, но читать он должен уже из store-backed messages, а не из snapshot prop
Это тоже не надо класть в TeamMemberActivityMeta.
Что тогда делает TeamMemberActivityMeta
Только стабильные message-derived факты:
lastAuthoredMessageAtmessageCountExactlatestAuthoredMessageSignalsTermination
14.5 New message before head hydration finishes
Возможна ситуация:
- открыли team
refreshTeamMessagesHead()ещё в полёте- пользователь отправил optimistic message
- потом приехала server head page
Нужно merge по messageId, не замену массива вслепую.
14.6 Message edits / dedup / source merging
У вас уже есть логика dedup lead_session vs lead_process.
Она должна остаться единственным source of truth на main side.
Renderer не должен заново изобретать dedup semantics.
14.7 lastHeartbeatAt и spawn statuses
Это нельзя снова смешивать с message activity meta.
Нужно разделять:
- spawn liveness
- member conversational activity
Их потом можно поверх объединить в displayStatus, но хранить в одном transport не надо.
14.8 Team provisioning / TEAM_DRAFT / transient errors
refreshTeamData() уже аккуратно обрабатывает provisioning-safe сценарии.
После split надо сохранить тот же принцип для:
refreshTeamMessagesHead()refreshMemberActivityMeta()
То есть:
- transient failures не должны очищать structural snapshot
- отсутствие message meta не должно рушить весь screen
14.9 Graph and TeamDetail open одновременно
Если team tab и graph tab открыты одновременно для одной команды:
- нельзя делать два разных polling loops, читающих одну и ту же head page
Нужно shared store action / shared cache entry per team.
14.10 Tests that currently assert data.messages
Blast radius тестов реальный:
- main service tests
- IPC tests
- renderer store tests
- graph adapter tests
Надо сразу закладывать migration plan:
- заменить ожидания на snapshot + messages/meta assertions
- не держать временный legacy field дольше, чем нужно
14.11 Team list fan-out risk
Это место легко пропустить, а потом получить новый performance regression уже не в detail view, а в overview.
Если TeamListView или похожий multi-team экран:
- делает
getData()для многих команд - и после split начнёт "для полноты" ещё дёргать
getMessagesPage()/getMemberActivityMeta()по каждой строке
то это создаст новый fan-out hot path.
Правило:
- message feed и member activity meta гидратятся только для selected / visible team detail contexts
- list/grid overview остаётся на structural snapshot
- если overview позже понадобится activity badge, для него нужен отдельный lightweight aggregate contract, а не скрытый fan-out тяжёлых вызовов
15. Как именно я бы это реализовывал
15.1 Принцип
Не "фаза 1 как костыль, потом перепишем".
А один coherent branch/PR, внутри которого есть правильный порядок сборки:
- новые типы и IPC surfaces
- новый store shape
- message/meta consumers переводятся на новые selectors
- event routing меняется
- structural sharing включается
- legacy
TeamData.messagesusage выпиливается
То есть rollout последовательный, но не архитектурно компромиссный.
15.2 Пошаговый технический план
Важно:
- шаги ниже задают implementation ownership order, а не обещание, что каждая микрофаза сама по себе уже merge-safe
- merge-safe checkpoints для PR определяются секциями
Suggested commit slices,Mechanical execution checklistиMerge gates - если отдельный шаг временно делает ветку архитектурно неконсистентной, следующий связанный шаг должен приземляться в том же commit slice до локального smoke
- нельзя останавливать работу на половине coupled migration, если в таком состоянии код снова зависит от legacy mixed snapshot
Safe temporary states during migration
Чтобы не собрать ветку в промежуточное состояние, которое уже компилируется, но архитектурно тянет старые баги, ниже разрешённые и запрещённые промежуточные формы.
Разрешено временно:
- держать
ResolvedTeamMemberViewrenderer-only adapter, пока consumer-компоненты по очереди переводятся на новый overlay model - держать branch-local compatibility adapters в renderer containers
- держать
selectedTeamDataкак convenience alias, пока canonical owner ужеteamDataCacheByName
Но:
- каждый compatibility adapter должен иметь одного конкретного remaining consumer owner
- adapter удаляется в том же commit slice, где уходит его последний consumer
- нельзя оставлять "универсальный временный adapter", который начинает жить своей отдельной жизнью
Запрещено даже временно:
- возвращать новый structural snapshot и одновременно ждать, что компоненты всё ещё возьмут из него
messages - перевести store на новый message cache, но оставить direct component fetch/polling "до следующего коммита"
- держать
selectedTeamDataкак independently-built copy после того, как появился canonical cache - держать второй message dedup/merge path в renderer после появления store-owned canonical path
Если промежуточная ветка попадает в запрещённое состояние, её нельзя считать готовой даже для локального smoke.
Step 1 - Ввести новые shared contracts
Сделать:
TeamViewSnapshotTeamMemberSnapshotTeamMemberActivityMeta- расширить existing
MessagesPageполемfeedRevision - перевести request shape
getMessagesPage()cbeforeTimestampнаcursor getMemberActivityMeta()вTeamsAPI
Проверить:
- типы компилируются без renderer migration
- paging contract в shared types уже cursor-based, а не timestamp-based
Step 2 - Разделить main-side services
Сделать:
TeamMemberResolver.resolveStructuralMembers(...)- новый
MemberActivityMetaService TeamDataService.getData()перестаёт возвращатьmessages
Проверить:
getMessagesPage()остаётся источником сообщений- main unit tests покрывают structural snapshot отдельно от messages/meta
Step 3 - Добавить renderer caches
Сделать:
teamMessagesByNamememberActivityMetaByTeamrefreshTeamMessagesHead()refreshMemberActivityMeta()applyOptimisticTeamMessage()- merged selector over
canonicalMessages + optimisticMessages
Проверить:
- message cache корректно merge'ит optimistic + fetched messages
- canonical refresh не откатывает optimistic row до подтверждения feed
Step 4 - Перевести MessagesPanel
Сделать:
- убрать
messagesprop как canonical input - читать message entry из store
Проверить:
- initial load
- polling
- load older
- optimistic send
Step 5 - Перевести MemberDetailDialog / MemberMessagesTab
Сделать:
- dialog больше не получает full messages prop
- count/meta идут из
memberActivityMeta - detail tab читает relevant messages из message store
Проверить:
- open/close dialog
- team switch
- member switch
Step 6 - Перевести TeamDetail selectors
Сделать:
membersWithActivityкак overlay modelStatusBlockперестаёт читать whole messages blob напрямую из snapshot
Проверить:
- pending replies
- active/idle badges
- no visible regression в member list
Step 7 - Перевести graph
Сделать:
- graph adapter читает
snapshot + messages + memberActivityMeta
Проверить:
- graph не сломан
- graph не заставляет держать legacy
TeamData.messages
Step 8 - Включить event routing split
Сделать:
lead-messageиinboxбольше не зовут fullrefreshTeamData()по умолчанию- зовут messages/meta refresh
Проверить:
- burst handling
- dedup
- no stale UI
Step 9 - Включить structural sharing + no-op suppression
Сделать:
structurallyShareTeamSnapshot(prev, next)- no-op return если snapshot semantically equal
- если до этого в ветке существует temporary old-shape guard на mixed
TeamData, на этом шаге он либо удаляется, либо сужается до нового structural snapshot comparator
Проверить:
selectedTeamDataref не меняется на no-op refresh- hidden tabs не получают лишних commits
- в merged target не остаётся comparator, который всё ещё сравнивает legacy
messagesвнутри snapshot
Step 10 - Удалить legacy coupling
Сделать:
- убрать
TeamData.messages - убрать prop plumbing
messages={data.messages} - обновить тесты
15.3 File-by-file execution map
Ниже не "точный diff inventory", а практическая карта, куда идти по шагам, чтобы реализация не расползлась.
Shared contracts and bridges
src/shared/types/team.ts- добавить
TeamViewSnapshot - добавить
TeamMemberSnapshot - добавить
TeamMemberActivityMeta - удалить
messagesиз snapshot contract
- добавить
src/shared/types/api.ts- изменить
getData(): Promise<TeamViewSnapshot> - добавить
getMemberActivityMeta()
- изменить
src/preload/constants/ipcChannels.ts- добавить
TEAM_GET_MEMBER_ACTIVITY_META
- добавить
src/preload/index.ts- прокинуть
getMemberActivityMeta()
- прокинуть
Main process
src/main/ipc/teams.ts- handler для
team:getMemberActivityMeta team:getDataтеперь возвращает structural snapshot
- handler для
src/main/services/team/TeamMessageFeedService.ts- новый shared normalized message feed cache/index
- используется и
getMessagesPage(), иgetMemberActivityMeta()
src/main/services/team/TeamDataService.tsgetTeamData()перестаёт включатьmessages- больше не зовёт old
resolveMembers(..., messages) getMessagesPage()перестаёт делать inline full normalize flow- делегирует page slicing в shared feed service
src/main/services/team/TeamMemberResolver.ts- split на structural-only resolver
src/main/services/team/- новый
MemberActivityMetaService.ts - cache по
feedRevision
- новый
src/main/services/team/TeamDataWorkerClient.ts- расширить worker ops для message feed / activity meta path
- обновить типы ответа
src/main/services/team/teamDataWorkerTypes.ts- добавить request/response ops для messages/meta path
src/main/workers/team-data-worker.ts- синхронизировать worker result types
- завести обработку new feed/meta ops
Renderer store and event routing
src/renderer/store/slices/teamSlice.ts- добавить
teamMessagesByName - добавить
memberActivityMetaByTeam - добавить actions для head refresh / older pages / meta refresh / optimistic merge
- добавить single-flight request guards и stale-response guards
- добавить store-owned fallback polling control
- добавить structural sharing + no-op suppression для snapshot
- добавить
src/renderer/store/index.ts- поменять routing team events
lead-message/inboxперестают звать fullrefreshTeamData()
Renderer consumers
src/renderer/components/team/TeamDetailView.tsx- переключить на snapshot + message store + memberActivityMeta + spawn statuses
- pending reply delayed refresh перевести на message-head refresh
src/renderer/components/team/messages/MessagesPanel.tsx- удалить прямой fetching logic
- читать messages из store
src/renderer/components/team/activity/ActivityTimeline.tsx- читать messages из store-backed selector или timeline view-model selector
- не держать local fetch/polling/orchestration
src/renderer/components/team/messages/StatusBlock.tsx- получать messages из store-backed source, не из snapshot prop
src/renderer/components/team/members/MemberDetailDialog.tsx- убрать
messagesprop
- убрать
src/renderer/components/team/members/MemberMessagesTab.tsx- убрать прямой IPC fetch
- использовать store messages + selectors
src/renderer/components/team/members/MemberList.tsx- читать
hasPendingReplyиз local overlay, не из meta
- читать
src/renderer/components/team/members/MemberHoverCard.tsx- читать facts/view-model selector, а не whole meta wrapper или snapshot messages
src/renderer/components/layout/PaneContent.tsx- не менять в этом PR, только учитывать mounted-hidden behavior
Graph
src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts- pure adapter принимает
(snapshot, messages, memberActivityFacts, teamName)
- pure adapter принимает
- graph-related tests
- заменить legacy
TeamData.messagesassumptions
- заменить legacy
Tests
test/main/services/team/TeamDataService.test.ts- snapshot tests отдельно
- meta tests отдельно
test/main/ipc/teams.test.ts- новый IPC handler
- убрать ожидания
result.data.messages
test/renderer/store/- refresh routing, no-op suppression, optimistic merges
test/renderer/components/team/- messages panel, member dialog, member list, pending replies
test/renderer/features/agent-graph/- graph adapter больше не зависит от snapshot messages
15.4 Merge gates
Это checkpoints, которые должны быть выполнены до merge, иначе PR выглядит "почти доделанным", но архитектурно остаётся дырявым.
Gate 1 - No dual message source
MessagesPanelне читаетmessagesиз propsMemberMessagesTabне читаетmessagesиз team snapshot- graph не читает
TeamData.messages
Gate 2 - Event routing actually split
lead-messageevent тестом подтверждённо не вызывает fullrefreshTeamData()inboxevent тоже не тянет full snapshot refresh по умолчаниюrefreshMemberActivityMeta()не дёргается безfeedChanged === trueили stale-meta condition
Gate 3 - Exact semantics preserved
messageCountостался exactlastActiveAtсчитается по authored messages, как раньше- terminal message semantics не потеряны
- display status в renderer overlay не сломан для "no message yet but has active task"
- existing pending-reply / TTL / activity threshold constants не поменялись скрытно внутри performance refactor
Gate 4 - UI semantics preserved
- optimistic send не моргает
- pending member replies всё ещё очищаются фактическим reply
- cross-team TTL badges всё ещё работают
- canonical head refresh не откатывает optimistic rows до server confirmation
- member UI не подписан на whole
TeamMemberActivityMetawrapper без необходимости
Gate 5 - Legacy field gone
- в shared snapshot contract нет
messages - в merged renderer code нет чтения
selectedTeamData.messages selectedTeamData, если поле сохранено, не является второй independently-built snapshot copy- old mixed
TeamDatasemantic comparator не пережил migration и не остался permanent hot path guard
Gate 6 - Shared feed cache actually used
getMessagesPage()не содержит второго самостоятельного normalize pipelineMessagesPageреально несётfeedRevision, а store использует его в routing/invalidationgetMessagesPage()режет страницы по stable effective message identity, а не по "сырым optional ids"getMemberActivityMeta()не читает raw storage напрямую- оба hot paths сходятся в
TeamMessageFeedService
Gate 7 - Worker boundary preserved
- expensive feed rebuild path не исполняется на main event loop
- worker ops для messages/meta path реально wired и покрыты тестом / smoke check
- packaged runtime не молча сваливается в main-thread hot path из-за пропавшего worker artifact
Gate 8 - Polling ownership preserved
MessagesPanelиMemberMessagesTabне держат собственный polling lifecycle- fallback polling живёт только в store
Gate 8.5 - Single-flight preserved
- burst events не создают пачку параллельных identical refresh requests на одну и ту же команду
- stale async responses не откатывают store после newer refresh
Gate 9 - No overview fan-out
TeamListViewи похожие overview screens не инициируют скрытый fan-outgetMessagesPage()/getMemberActivityMeta()по всем командам- overview остаётся на structural snapshot semantics
15.5 Suggested commit slices
Если делать это не одним бесформенным diff, а нормальными кусками, я бы резал так:
-
refactor(team): introduce structural team snapshot contracts- новые shared types
- новый IPC contract для
getMemberActivityMeta()
-
refactor(team): add shared team message feed cacheTeamMessageFeedServicegetMessagesPage()переводится на shared feed- worker boundary расширяется для messages/meta path
-
refactor(team): split member activity meta from team snapshotMemberActivityMetaServiceTeamMemberResolverстановится structural-onlygetData()перестаёт возвращатьmessages
-
refactor(renderer): move team message orchestration into store- store caches and actions
- event routing split
- store-owned fallback polling
-
refactor(renderer): migrate team detail consumers to snapshot plus message storeTeamDetailViewMessagesPanelActivityTimelineMemberDetailDialogMemberMessagesTabMemberHoverCardStatusBlock- graph adapter
-
test(team): cover snapshot split and message feed ownership- main tests
- store tests
- component tests
- graph tests
Не обязательно коммитить ровно так, но как execution model это сильно снижает хаос.
15.6 Mechanical execution checklist
Это section для прямого исполнения. Идея простая: не переходить к следующему шагу, пока текущий не прошёл свой exit check.
Checklist 0 - Safety prep
- убедиться, что worktree чистый
- прогнать baseline tests, которые покрывают team detail / messages / graph
- зафиксировать baseline perf probes, если уже есть локальный soak scenario
Exit criteria:
- baseline известен
- есть с чем сравнивать после миграции
Checklist 1 - Contracts first
Сделать:
- ввести
TeamViewSnapshot - ввести
TeamMemberSnapshot - ввести
TeamMemberActivityMeta - расширить existing
MessagesPageполемfeedRevision - перевести
getMessagesPage()request contract наcursor - описать новые worker request/response contracts для messages/meta path
Проверить:
- types compile
- пока можно держать локальные compatibility adapters, но не merged aliases
- если
selectedTeamDataсохраняется на этом шаге, он уже должен reuse'ить ref canonical cache entry beforeTimestampбольше не фигурирует как canonical paging API в shared contracts
Exit criteria:
- transport contracts готовы
- дальше можно переписывать main/services без изобретения типов на ходу
Checklist 2 - Shared feed path
Сделать:
- добавить
TeamMessageFeedService - перевести
getMessagesPage()на shared feed - нормализовать stable effective message identity до выдачи page response
- провести expensive rebuild path через worker boundary
Проверить:
- один canonical normalize pipeline
- cursor строится по effective identity
getMessagesPage()больше не повторяет old inline normalize flow
Exit criteria:
- message feed path централизован
getMessagesPage()уже не является скрытой legacy дырой
Checklist 3 - Structural snapshot split
Сделать:
getData()перестаёт возвращатьmessagesTeamMemberResolverстановится structural-onlyMemberActivityMetaServiceстроится от shared feed
Проверить:
- snapshot без messages компилируется
- meta даёт
messageCountExact/lastAuthoredMessageAt
Exit criteria:
- main-side split завершён
- transport границы больше не смешаны
Checklist 4 - Store ownership
Сделать:
- store держит
teamMessagesByName - store держит
memberActivityMetaByTeam - store держит fallback polling
refreshTeamMessagesHead()возвращает semantic result сfeedChanged/headChanged- store делает single-flight/coalesced refresh orchestration per team
- selector возвращает merged canonical + optimistic messages
- selector layer разделяет
memberActivityFactsиmemberActivityFeedRevision
Проверить:
- компоненты ещё могут быть не переведены полностью, но orchestration уже в store
Exit criteria:
- fetching/polling/optimistic merge больше не размазаны по компонентам
Checklist 5 - UI consumers migration
Сделать:
MessagesPanelбез direct fetch/pollingActivityTimelineот store-backed messages или timeline view-model selectorMemberMessagesTabбез direct fetch/pollingMemberDetailDialogбезmessagespropMemberHoverCardот facts/view-model selector, не от whole wrappersStatusBlockот store-backed messagesTeamDetailViewсобирает overlay model- UI consumers переходят на data/view-model selectors, а не на whole wrappers
Проверить:
rgпо renderer не находит directapi.teams.getMessagesPage(внутри этих компонентовselectedTeamData.messagesбольше не читаетсяMemberMessagesTabбольше не фильтрует whole team messages array прямо в render bodyActivityTimelineбольше не строится от snapshot message propMemberHoverCardне подписан на wholeTeamMemberActivityMetawrapper
Exit criteria:
- UI больше не зависит от legacy message transport
Checklist 6 - Graph migration
Сделать:
- graph adapter читает snapshot + messages + memberActivityFacts
Проверить:
- graph open не ломается
- больше нет зависимости от
TeamData.messages
Exit criteria:
- последний крупный consumer legacy messages отрезан
Checklist 7 - Cleanup and hard gates
Сделать:
- убрать compatibility plumbing
- убрать legacy fields / props
- обновить тесты и perf probes
Проверить:
- проходят merge gates
- проходят critical tests
- soak/perf лучше baseline
Exit criteria:
- PR не только компилируется, но и реально дошёл до целевого shape
16. Конкретные code patterns
16.1 Reusable array sharing helper
function reuseArrayIfEqual<T>(
prev: readonly T[],
next: readonly T[],
areEqual: (left: T, right: T) => boolean
): readonly T[] {
if (prev === next) return prev;
if (prev.length !== next.length) return next;
for (let index = 0; index < prev.length; index += 1) {
if (!areEqual(prev[index], next[index])) {
return next;
}
}
return prev;
}
16.2 Narrow selector pattern
const EMPTY_MESSAGES: readonly InboxMessage[] = Object.freeze([]);
const EMPTY_MEMBER_ACTIVITY_FACTS: Readonly<Record<string, MemberActivityMetaEntry>> =
Object.freeze({});
const teamMessagesSelectors = new Map<string, (state: AppState) => readonly InboxMessage[]>();
const memberMessagesSelectors = new Map<string, (state: AppState) => readonly InboxMessage[]>();
export function selectTeamSnapshot(teamName: string) {
return (state: AppState) =>
state.teamDataCacheByName[teamName] ??
(state.selectedTeamName === teamName ? state.selectedTeamData : null);
}
export function selectTeamMessagesEntry(teamName: string) {
return (state: AppState) => state.teamMessagesByName[teamName] ?? null;
}
function getOrCreateTeamMessagesSelector(teamName: string) {
let selector = teamMessagesSelectors.get(teamName);
if (!selector) {
selector = createMemoizedSelector(
(state: AppState) => state.teamMessagesByName[teamName]?.canonicalMessages ?? EMPTY_MESSAGES,
(state: AppState) => state.teamMessagesByName[teamName]?.optimisticMessages ?? EMPTY_MESSAGES,
(canonicalMessages, optimisticMessages) =>
mergeTeamMessages(canonicalMessages, optimisticMessages)
);
teamMessagesSelectors.set(teamName, selector);
}
return selector;
}
export function selectTeamMessages(teamName: string) {
return getOrCreateTeamMessagesSelector(teamName);
}
/** Low-level/internal selector. UI should usually prefer facts/revision selectors below. */
export function selectMemberActivityMeta(teamName: string) {
return (state: AppState) => state.memberActivityMetaByTeam[teamName] ?? null;
}
export function selectMemberActivityFacts(teamName: string) {
return (state: AppState) =>
state.memberActivityMetaByTeam[teamName]?.members ?? EMPTY_MEMBER_ACTIVITY_FACTS;
}
export function selectMemberActivityFeedRevision(teamName: string) {
return (state: AppState) => state.memberActivityMetaByTeam[teamName]?.feedRevision ?? null;
}
function getOrCreateMemberMessagesSelector(teamName: string, memberName: string) {
const key = `${teamName}::${memberName}`;
let selector = memberMessagesSelectors.get(key);
if (!selector) {
selector = createMemoizedSelector(selectTeamMessages(teamName), (messages) =>
messages.filter((message) => message.from === memberName || message.to === memberName)
);
memberMessagesSelectors.set(key, selector);
}
return selector;
}
export function selectMemberMessages(teamName: string, memberName: string) {
return getOrCreateMemberMessagesSelector(teamName, memberName);
}
Важно:
createMemoizedSelectorздесь условное имя для любой стабильной selector factory, которую команда уже использует- важно не конкретное API helper'а, а то, что merged selectors действительно memoized и возвращают stable refs
- fallback на
selectedTeamDataв примере выше нужен только пока поле ещё существует; еслиselectedTeamDataудалён, selector упрощается до чтенияteamDataCacheByName
Selector usage rule
TeamDetailView,MemberList,MemberHoverCard,MemberDetailDialogдолжны читать facts selector, а не whole meta wrapper- routing / polling logic может читать revision selector отдельно
- components, которым нужен только message array, не должны подписываться на whole
TeamMessagesCacheEntry, если им не нужны loading flags MemberMessagesTabпо умолчанию должен читатьselectMemberMessages(teamName, memberName)или аналогичный memoized selector, а не фильтровать whole array прямо в render body- empty selector fallbacks должны возвращать stable frozen references, а не новый
{}/[]на каждый вызов
Practical selector split
Для ясности полезно сразу мыслить селекторы тремя слоями:
- data selectors
selectTeamSnapshot(teamName)selectTeamMessages(teamName)selectMemberActivityFacts(teamName)selectMemberMessages(teamName, memberName)
- control selectors
selectTeamMessagesEntry(teamName)только для loading flags / cursor / hasMoreselectMemberActivityFeedRevision(teamName)только для routing / stale checks
- view-model selectors
selectResolvedTeamMembersView(teamName)selectPendingRepliesView(teamName)
Правило:
- components по умолчанию читают data/view-model selector
- control selectors не должны становиться случайным render dependency для большого UI subtree
16.3 Overlay model builder
function mergeMembersWithActivity(
members: TeamMemberSnapshot[],
activityFacts: Record<string, MemberActivityMetaEntry>,
spawnStatuses: Record<string, MemberSpawnStatusEntry>
): ResolvedTeamMemberView[] {
return members.map((member) => {
const activity = activityFacts[member.name];
const spawn = spawnStatuses[member.name];
return {
...member,
lastActiveAt: activity?.lastAuthoredMessageAt ?? null,
messageCount: activity?.messageCountExact ?? 0,
status: resolveDisplayMemberStatus(member, activity, spawn),
};
});
}
Это важный паттерн
View-model можно смешивать в renderer.
Transport contract смешивать нельзя.
Что добавляется поверх этого отдельно
pendingRepliesByMember overlay надо мержить отдельным путём:
const membersWithActivityAndPending = membersWithActivity.map((member) => ({
...member,
hasPendingReply: Boolean(pendingRepliesByMember[member.name]),
}));
То есть:
- stable facts идут из
memberActivityMeta - ephemeral pending-reply UX идёт из local renderer state
16.4 Display status helper
function resolveDisplayMemberStatus(
member: TeamMemberSnapshot,
activity: MemberActivityMetaEntry | undefined,
spawn: MemberSpawnStatusEntry | undefined,
nowMs = Date.now()
): MemberStatus {
if (member.removedAt) return 'terminated';
if (activity?.latestAuthoredMessageSignalsTermination) return 'terminated';
const lastAuthoredAt = activity?.lastAuthoredMessageAt;
if (!lastAuthoredAt) {
return member.currentTaskId ? 'active' : 'idle';
}
const ts = Date.parse(lastAuthoredAt);
if (!Number.isFinite(ts)) return 'unknown';
return nowMs - ts < 5 * 60 * 1000 ? 'active' : 'idle';
}
Важно:
- helper повторяет старую message-authored semantics
- task presence влияет только на case "сообщений ещё не было"
- spawn/runtime state не переписывает этот base status, а накладывается отдельно в presentation helpers
17. Что обязательно не сломать
Вот места, где проще всего внести регрессию.
17.1 sendTeamMessage() UX
Пользователь не должен видеть:
- пропадающее только что отправленное сообщение
- дубль optimistic + fetched
- откат scroll position
17.2 pendingRepliesByMember
Если сейчас pending reply badge обновляется от локального state и timers, новый split не должен сделать его laggy.
17.3 Scroll and expanded state in MessagesPanel
Сообщения больше не придут как prop re-seed.
Нужно проверить, что:
- scroll memory сохраняется
- expanded item state не сбрасывается на каждый head refresh
17.4 MemberHoverCard
Он сейчас читает selected team member data из snapshot.
После split не надо случайно вернуть message-derived churn в hover path.
17.5 ActivityTimeline
Это один из исходных hot consumers, поэтому его нельзя считать "само как-то переедет".
После split важно проверить:
- timeline derivations не зависят от
selectedTeamData.messages - hidden tab не получает wide invalidation только из-за timeline selectors
- timeline grouping не пересчитывается от whole wrapper change, если message slice фактически не менялся
17.6 TeamListView / global task dialogs
Они не должны внезапно стать зависимыми от message caches.
Особенно важно:
- не тащить
getMessagesPage()/getMemberActivityMeta()в list-row hydration path - не вводить скрытый fan-out по всем видимым командам
- если
StatusBlockили похожий badge показывается в overview context, он использует только уже гидратированный cache или structural fallback и не имеет права сам инициировать hidden team hydration
18. Тестовый план
18.1 Main unit tests
Нужны тесты на:
getData()не возвращает messages- structural members строятся без message history
getMessagesPage()возвращаетfeedRevision, описывающий весь normalized feed- historical-only feed change может обновить
feedRevisionдаже если top page slice тот же - forced rebuild того же normalized feed не меняет
feedRevision - successful empty head fetch возвращает non-null
feedRevisionи корректно инициализирует empty canonical state - каждый message row в page response несёт stable effective identity
getMemberActivityMeta()корректно считаетlastAuthoredMessageAtgetMemberActivityMeta()сохраняет exactmessageCountgetMemberActivityMeta()корректно помечаетlatestAuthoredMessageSignalsTermination- shared message feed cache не пересобирается без изменения feed inputs
- meta cache переиспользуется при том же
feedRevision - expensive rebuild path для messages/meta идёт через worker op, а не мимо worker boundary
18.2 Renderer store tests
Нужны тесты на:
refreshTeamData()no-op suppression сохраняет refselectedTeamDatareuse'ит exact same ref asteamDataCacheByName[selectedTeamName]- при
selectedTeamNameswitchselectedTeamDataне продолжает указывать на snapshot предыдущей команды - отсутствие
teamDataCacheByName[teamName]иmemberActivityMetaByTeam[teamName]до first success не заменяется fake placeholder objects refreshTeamMessagesHead()merge'ит новые head messagesrefreshTeamMessagesHead()различаетfeedChangedиheadChangedrefreshTeamMessagesHead()не возвращает невозможное состояниеfeedChanged === false && headChanged === true- store single-flight coalescing не допускает burst из параллельных head refresh на одну команду
- head refresh и older-page load для одной команды не мутируют canonical window параллельно
loadingHead === true && loadingOlder === trueдля одной команды не возникаетselectTeamMessages(teamName)сохраняет stable ref, если canonical/optimistic inputs не менялись- UI selectors, читающие member activity facts, не re-render'ятся только из-за смены
computedAt/feedRevision - in-flight/dirty/visibility bookkeeping не становится случайным render-driving reactive state без отдельной причины
- failure в
refreshTeamMessagesHead()не очищает уже загруженный canonical window lastFetchedAtостаётсяnullдо первого успешного head/message fetch и не меняется на failed request- failure в
refreshMemberActivityMeta()не очищает предыдущий facts record loadOlderTeamMessages()before head hydration не делает некорректный older-page requestloadOlderTeamMessages()приhasMore === falseделает cheap no-opheadHydrated === falseне сочетается с non-emptycanonicalMessagesили сloadingOlder === trueheadHydrated === falseсочетается только с bootstrapfeedRevision/null,nextCursor/nullиlastFetchedAt/null- optimistic row может жить поверх
headHydrated === falsebootstrap entry до первого successful head fetch - optimistic send + fetched confirmation dedup
- failed optimistic send удаляет optimistic row и откатывает local pending-reply state
- optimistic row survives canonical refresh until matching
messageIdappears - user send сам по себе не триггерит лишний
refreshMemberActivityMeta() lead-messageevent больше не вызываетrefreshTeamData()taskevent по-прежнему вызываетrefreshTeamData()- delayed waiting refresh для pending member reply зовёт
refreshTeamMessagesHead(), а не full snapshot refresh - hidden inactive team не получает message/meta refresh от чужих событий
- одного
selectedTeamNameбез explicit visibility signal недостаточно для запуска visible-team polling/hydration policy - late response для предыдущей команды после switch не переустанавливает
selectedTeamDataпод новую выбранную команду - hide transition не очищает уже гидратированные snapshot/message/meta caches сам по себе
- reopen после hide reuse'ит существующий cache baseline, а не требует forced cold-start reset
refreshMemberActivityMeta()после lead/inbox идёт только приfeedChanged === trueили stale-meta condition- historical-only
feedChanged === trueприheadChanged === falseвсё равно запускает meta refresh - older-page response после newer head refresh не откатывает
nextCursorи не ломает canonical ordering - irreconcilable merge after
feedRevisionchange сбрасывает только canonical older tail и не теряет optimistic rows - fallback polling запускается только для visible active team или local pending-reply wait state
18.3 Component tests
Нужны тесты на:
MessagesPanelinitial hydration from storeActivityTimelineчитает store-backed messages/view-model path, а не snapshot propMemberDetailDialogwithout snapshot messages propMemberHoverCardчитает facts/view-model selector, а не whole meta wrapperStatusBlockотрабатывает member pending replies из local overlay- overview
StatusBlockили аналогичный badge не триггерит hidden team hydration - graph adapter берёт messages не из snapshot
StatusBlockкорректно считает cross-team pending replies из message cache + local TTLMessagesPanelиMemberMessagesTabне содержат собственного polling/fetch orchestration- older-page loading не ломает scroll/order при одновременном head refresh
18.4 Soak / perf validation
Нужны реальные runtime probes:
- count of
refreshTeamDatacalls - count of suppressed no-op snapshot writes
- count of
refreshTeamMessagesHead - count of
refreshMemberActivityMeta - commit count
TeamDetailView - longtask count and max before/after
- IPC payload size before/after for
team:getData
19. Acceptance criteria
Фикс можно считать правильным, если одновременно выполняется всё:
lead-messagestorm больше не вызывает repeatedrefreshTeamData()для visible team- identical structural snapshot не меняет
selectedTeamDataref MessagesPanelживёт безdata.messagesprop- member list/status block не зависят от full messages array inside snapshot
- graph не зависит от
TeamData.messages MessagesPanelиMemberMessagesTabне делают direct IPC fetch из компонента- long tasks на 4-member soak заметно падают
- нет regressions в optimistic send, member dialog, pending replies
- hot path
getMessagesPage()больше не делает raw full rescan на каждый visible refresh - multi-team overview screens не создают hidden fan-out на
getMessagesPage()/getMemberActivityMeta() - burst event storm не порождает параллельную очередь одинаковых head/meta refresh requests
Практический perf target
Хотя бы такой:
- skip-rate no-op structural refreshes высокий в heartbeat windows
team:getDatapayload ощутимо меньше- long tasks больше не накапливаются без видимых изменений UI
19.1 Reviewer checklist
Это короткий список для финальной проверки PR человеком, который не писал реализацию.
Reviewer должен уметь ответить "да" на каждый пункт ниже без догадок:
rgпо merged code не находит чтенияselectedTeamData.messagesgetData()типизирован какTeamViewSnapshot, а не legacy mixed transportgetMessagesPage()в shared API больше не используетbeforeTimestampкак canonical paging contractMessagesPage.feedRevisionвыглядит как content-stable revision, а не timestamp-like tokengetMessagesPage()иgetMemberActivityMeta()сходятся в один shared feed backendMessagesPanelиMemberMessagesTabне содержат прямых IPC fetch/polling путей- UI member list читает facts selector или view-model selector, а не whole
TeamMemberActivityMeta selectedTeamData, если сохранён, reuse'ит тот же ref, что и canonical cache entry- worker path для heavy messages/meta rebuild реально задействован в нормальном runtime
- older-history merge имеет safety fallback, а не assumes append-only forever
- tests покрывают
feedChanged === trueприheadChanged === false
Если хотя бы на один пункт ответ "не уверен", PR ещё слишком двусмысленный и план выполнен не полностью.
20. Нужен ли future split ещё дальше
Эта секция не открывает scope текущего PR.
Правило:
- ничего из списка ниже не является blocker для merge текущего split
- если реализация текущего PR начинает зависеть от одного из этих future ideas, это уже scope creep и его надо отдельно остановить
- acceptance current PR определяется только секциями выше, а не будущими optional split ideas
Возможно, но не обязательно сразу.
Что имеет смысл split'ить позже, если понадобится
- task comments/history, если они станут heavy
- graph-specific activity feed
- process diagnostics/log metadata
Что не надо split'ить сейчас
configtaskskanbanStateprocesses
Они пока выглядят как разумный structural snapshot.
То есть ответ на вопрос "мы в будущем ещё больше разрежем getData?" такой:
- возможно да
- но не надо делать это заранее
- прямо сейчас правильная граница проходит по messages и message-derived member activity
21. Отдельно про Linux task manager и "Electron 12.1 GB"
Это важно понимать правильно.
Если на Linux в системном мониторинге видны отдельные строки:
electronchrome --type=renderernodeclaude-multimodel
то это обычно отдельные OS processes, а не "всё сложено в electron row".
Следствие:
electron 12.1 GBочень похоже на реальный RSS browser/main процесса Electron- spawned Claude/Codex/node subprocesses обычно не должны магически считаться внутрь этой строки, если они уже видны отдельно
Это не доказывает leak само по себе, но и не выглядит как "да это просто все дети туда суммировались".
Что добавить для подтверждения
Нужна отдельная main-side telemetry:
const mem = process.memoryUsage();
const metrics = app.getAppMetrics();
И логировать хотя бы каждые 30s:
rssheapUsedexternal- per-process Electron metrics
Тогда станет видно:
- реально ли main/browser process растёт
- есть ли рост после renderer recovery
- совпадает ли это с observed long stalls
22. Мой итоговый вывод
Если хочется сделать сразу правильно, а не делать цепочку полуфиксов, то целевой дизайн должен быть именно таким:
getData(teamName)-> structural snapshotgetMessagesPage(teamName, { limit, cursor })-> message feedgetMemberActivityMeta(teamName)-> lightweight message-derived overlay- renderer store хранит их раздельно
- event routing тоже раздельный
refreshTeamData()имеет structural sharing + no-op suppression
Самый частый неправильный компромисс здесь:
- "давайте просто сравним новый
TeamDataс предыдущим и всё"
Это хороший emergency mitigation, но не лучший final state.
Самый надёжный final state:
- split boundaries
- убрать message-derived смысл из structural snapshot
- сохранить semantic guard как страховку
Именно это я считаю вариантом, который ближе всего к "сделать один раз и правильно", а не возвращаться потом ещё на два круга переделки.