agent-ecosystem/docs/research/team-detail-snapshot-messages-activity-plan.md

3221 lines
148 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# План: 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 UI
- `getMessagesPage(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
Если исполнитель не хочет читать весь документ линейно, безопасный порядок такой:
1. Сначала новые shared contracts и worker ops.
2. Потом `TeamMessageFeedService` с stable effective identity и `feedRevision`.
3. Потом structural `getData()` и отдельный `MemberActivityMetaService`.
4. Потом store ownership for messages/meta, single-flight и stale-response guards.
5. Потом migration consumers: `MessagesPanel`, `ActivityTimeline`, `MemberDetailDialog`, `MemberMessagesTab`, `MemberHoverCard`, `StatusBlock`, `TeamDetailView`, graph.
6. Потом event routing split.
7. Потом structural sharing + no-op suppression.
8. Только после этого выпиливать legacy fields и compatibility plumbing.
Неправильный порядок, которого надо избегать:
1. Сначала менять UI consumers, пока feed/meta/store contracts ещё не зафиксированы.
2. Сначала удалять `TeamData.messages`, пока graph/dialog/messages consumers ещё на нём сидят.
3. Сначала добавлять 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` больше не часть `TeamViewSnapshot`
- `members` в snapshot больше не считаются от full message history
### 3. Message ownership
- единственный message feed API в этом PR - `getMessagesPage()`
- новый отдельный `getMessagesHead()` в этом PR **не добавляем**
- если понадобится оптимизация, делаем её **внутри** `getMessagesPage()` или store caching, без второго transport contract
- existing `MessagesPage` contract расширяем полем `feedRevision`
- store action `refreshTeamMessagesHead()` должен возвращать semantic result c минимум двумя флагами:
- `feedChanged` - изменился ли revision всего normalized feed
- `headChanged` - изменился ли реально текущий 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 из:
- `lastAuthoredMessageAt`
- `latestAuthoredMessageSignalsTermination`
- `currentTaskId`
- 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 state
- `crossTeamPendingReplies` остаётся renderer-derived состоянием от message cache + local TTL
- `TeamMemberActivityMeta` **не становится** 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` / `idle` member 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-worker` boundary, а не заводим второй отдельный worker
- `getData()`, expensive `getMessagesPage()` 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
- после перехода на `TeamViewSnapshot` final 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
- не делаем `PaneContent` unmount 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
Если любой из пунктов ниже нарушается, значит реализация ушла в неправильную сторону.
1. В merged коде не должно остаться чтения `selectedTeamData.messages`.
2. Exact `messageCount` и `lastActiveAt` не считаются в renderer по `selectTeamMessages(teamName)`.
3. `MessagesPanel` и `MemberMessagesTab` не имеют собственного IPC fetching logic после миграции.
4. Main остаётся единственным местом, где выполняется dedup `lead_session` / `lead_process`.
5. Pending-reply timer logic не переезжает в main process.
6. `lead-message` event не вызывает full `refreshTeamData()` по умолчанию.
7. В merged коде не живут две долгоживущие message models одновременно.
8. Message/meta refresh не крутятся бесконтрольно для hidden inactive teams.
9. `getMessagesPage()` и `getMemberActivityMeta()` не делают независимый полный raw rescan истории на каждый hot refresh.
10. Expensive feed rebuild path не выполняется на Electron main event loop.
11. Store не выводит "full feed changed" только по diff первого page slice; для этого используется `feedRevision`.
12. `TeamListView` и любые multi-team overview screens не гидратят messages/meta для каждой команды по умолчанию.
13. `getMessagesPage()` отдаёт stable effective message identity для каждого message row; store merge/cursor logic не живут на двух разных key semantics.
14. `selectedTeamData`, если сохраняется, reuse'ит ref из `teamDataCacheByName`, а не создаёт вторую independent snapshot copy.
15. `feedRevision` отражает состояние full normalized feed, а не время rebuild или raw invalidation fingerprint.
16. Если older history после revision change нельзя склеить без сомнений, canonical older tail сбрасывается, а не показывается mixed inconsistent state.
## Forbidden Shortcuts
Ниже shortcuts, которые выглядят как "быстро и почти правильно", но в контексте этого плана считаются ошибкой реализации.
1. Оставить `messages` в snapshot "пока временно", а потом забыть убрать.
2. Считать `messageCount` / `lastActiveAt` по head page или по уже загруженным сообщениям в renderer.
3. Перенести fetching в store, но оставить прямые `api.teams.getMessagesPage(...)` в `MessagesPanel` или `MemberMessagesTab`.
4. Сделать `refreshMemberActivityMeta()` зависимым только от head-slice diff без `feedRevision`.
5. Держать два merge paths для messages: один в store, второй в компоненте.
6. Позволить packaged runtime тихо выполнять expensive message rebuild path на main thread при пропавшем worker.
7. Сохранить и `teamDataCacheByName`, и отдельно пересобираемый `selectedTeamData`.
8. Начать греть `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 перестают зависеть от нового `messages` array ref на каждый refresh
- `lead-message` и `inbox` events больше не триггерят 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()` регулярно создаёт новый `selectedTeamData` ref
- `TeamDetailView` подписан на весь `selectedTeamData`
- даже когда по смыслу ничего не изменилось, вниз уходит новый `messages` ref
- `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:getMessagesPage`
- `src/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
- новый `TeamData` ref почти гарантирован даже при пустом visible diff
### 3.4 Где сейчас сцепка особенно сильная
- `src/renderer/store/slices/teamSlice.ts` - `refreshTeamData()` всегда пишет новый `selectedTeamData`
- `src/renderer/components/team/TeamDetailView.tsx` - подписка на весь `selectedTeamData`
- `src/renderer/components/team/messages/MessagesPanel.tsx` - `effectiveMessages = merge(fetchedMessages, propMessages)`
- `src/renderer/components/team/activity/ActivityTimeline.tsx` - filter/group/visible timeline расчёты идут от whole messages array
- `src/renderer/components/team/members/MemberDetailDialog.tsx` - диалог получает `messages` из team snapshot
- `src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts` - graph всё ещё читает `TeamData.messages`
- `src/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.status`
- `ResolvedTeamMember.messageCount`
- `ResolvedTeamMember.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
Нормальная финальная схема должна выглядеть так:
1. `getData(teamName)` возвращает **structural snapshot**
2. `getMessagesPage(teamName, { limit, cursor })` возвращает **сообщения**
3. `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
Должно остаться:
- `teamName`
- `config`
- `tasks`
- `kanbanState`
- `processes`
- `warnings`
- `isAlive`
- structural member description из config/meta
### Важная корректировка по `members`
Сейчас `ResolvedTeamMember` смешивает structural и message-derived поля.
Это надо разрулить.
Есть два пути:
1. Либо ввести новый тип `TeamMemberSnapshot`
2. Либо оставить `ResolvedTeamMember`, но вытащить из него message-derived смысл в отдельный overlay
Для надёжности и понятности лучше путь 1.
### Предлагаемый structural member type
```ts
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:
```ts
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
Рекомендуемый набор типов:
```ts
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
```ts
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`.
Правильная миграция такая:
1. IPC transport перестаёт возвращать `ResolvedTeamMember[]`
2. IPC transport начинает возвращать `TeamMemberSnapshot[]`
3. renderer собирает поверх этого `ResolvedTeamMemberView[]`
4. UI-компоненты постепенно переводятся на `ResolvedTeamMemberView`
### Важное правило
`ResolvedTeamMember` больше не должен означать одновременно:
- и IPC transport type
- и renderer display model
Это две разные ответственности.
### Рекомендуемый тип
```ts
interface ResolvedTeamMemberView extends TeamMemberSnapshot {
status: MemberStatus;
lastActiveAt: string | null;
messageCount: number;
hasPendingReply?: boolean;
}
```
### Locked choice
Для этого PR лучше:
- оставить `ResolvedTeamMemberView` renderer-only type
- не возвращать его из main
- не держать старый `ResolvedTeamMember` как transport alias "для удобства"
## 9. Main-process design
## 9.1 `TeamDataService.getData()` должен стать structural
Сейчас внутри `getTeamData()` messages делают две большие вещи:
- сами попадают в response
- участвуют в `resolveMembers(...)`
Значит надо:
1. перестать возвращать `messages` в snapshot
2. перестать рассчитывать members от full message array
### Новый shape flow
Примерно так:
```ts
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:
```ts
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` станет почти всегда `true`
- `refreshMemberActivityMeta()` начнёт зря крутиться
- 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:
```ts
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`
- но `members` record внутри должен использовать structural sharing для неизменившихся entry
- UI selectors не должны подписываться на `computedAt` как на render-driving поле
Иначе можно случайно вернуть churn в member list уже после правильного split.
### Как должна выглядеть зависимость
```ts
const feed = await teamMessageFeedService.getFeed(teamName);
const meta = await memberActivityMetaService.getMeta(teamName, feed);
```
### Почему это лучший баланс для этого PR
- дорогой raw normalization живёт в одном месте
- `getMessagesPage()` просто режет page из cached normalized feed
- `getMemberActivityMeta()` не трогает raw storage напрямую
- если revision не изменился, meta возвращается без пересчёта
- O(n) meta rebuild по cached normalized feed при текущих observed объёмах сообщений выглядит безопаснее и проще, чем отдельный delta engine
### Как сохраняем старую authored semantics
Meta строится по authored activity:
- `lastAuthoredMessageAt` считается по сообщениям `from === member.name`
- `messageCountExact` - это exact historical count authored messages
- `latestAuthoredMessageSignalsTermination` смотрит на последнее 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:
```ts
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 pages
- `optimisticMessages` - только локальные ещё не подтверждённые rows
- `feedRevision` - revision full normalized feed, на котором построен текущий canonical head state
- `nextCursor` - cursor для **следующей** older page после самого старого canonical message, уже находящегося в `canonicalMessages`
- `hasMore` - есть ли ещё canonical history старше текущего `nextCursor`; до первой successful head hydration это bootstrap flag и не интерпретируется как terminal exhaustion
- `lastFetchedAt` - timestamp последнего **успешного** canonical message fetch/merge для этой команды; до первого success равен `null` и не обновляется на failed attempt
- `loadingHead` - в полёте primary head refresh для canonical window
- `loadingOlder` - в полёте older-page extension текущего canonical window
- `headHydrated` - был ли хотя бы один успешный 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:
```ts
{
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 должен стать таким:
```ts
{
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-empty
- `headHydrated` остаётся `false` до первого successful canonical head fetch
- `feedRevision`, `nextCursor`, `lastFetchedAt` остаются bootstrap/null до первого success
То есть optimistic rows могут существовать поверх bootstrap entry, не превращая его в hydrated canonical state.
### `TeamMessagesCacheEntry` state invariants
Чтобы store не собрал внутренне противоречивое состояние, ниже зафиксированы инварианты:
- `headHydrated === false` => `canonicalMessages.length === 0`
- `headHydrated === false` => `loadingOlder === false`
- `headHydrated === false` => `feedRevision === null`
- `headHydrated === false` => `nextCursor === null`
- `headHydrated === false` => `lastFetchedAt === null`
- `loadingHead === true && loadingOlder === true` для одной команды в корректной реализации не допускается
- `hasMore === false` => `nextCursor === null`
- `canonicalMessages.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
- минимум `TeamDetailView` container и 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 `pendingRepliesByMember` entry, который ещё находится в локальном 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:
```ts
interface RefreshTeamMessagesHeadResult {
feedChanged: boolean;
headChanged: boolean;
feedRevision: string | null;
}
```
Где:
- `feedChanged` - изменился revision всего normalized feed относительно store cache
- `headChanged` - изменился реально canonical head slice, который подписан в UI
- `feedRevision` - 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 page
- `MessagesPanel` может не перерисоваться, но member activity overlay всё равно должен знать, что full feed поменялся
### Single-flight request discipline
Даже правильный data split можно испортить, если store начнёт одновременно запускать 5 одинаковых refresh-запросов на burst events.
Правило:
- для каждого `teamName` store держит 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:
- для одного `teamName` canonical 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 facts
- `computedAt` почти никогда не должен быть 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` и `optimisticMessages` ref'ы не изменились
- `selectMemberMessages(teamName, memberName)` обязан строиться как memoized derived selector per pair, а не как новый `.filter(...)` на каждый store read
- `mergeTeamMessages()` не должен вызываться "в лоб" внутри обычного 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 feed
- `optimisticMessages` - локальные 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:
- если после `feedRevision` change 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 обязан обеспечить такой порядок:
1. `refreshTeamData(teamName)` для structural snapshot
2. `refreshTeamMessagesHead(teamName)` для canonical head hydration
3. `refreshMemberActivityMeta(teamName)` только после первого head result, если meta отсутствует или stale
4. `fetchMemberSpawnStatuses(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 snapshot
- `refreshTeamMessagesHead()` failure не очищает `canonicalMessages`, `nextCursor`, `feedRevision`
- `loadOlderTeamMessages()` failure не откатывает уже загруженную history window
- `refreshMemberActivityMeta()` 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 Пример роутинга
```ts
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 = selectedTeamData`
- `messages = data.messages`
- `members = data.members`
После split правильнее:
```ts
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:
```ts
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
### Пример
```ts
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
Примерно так:
```ts
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 всё равно может пересоздаваться:
- новые массивы задач
- новый `config` object
- новый `processes` array
- новые `members` array/object refs при одинаковом содержимом
Если этого не подавить, вы получите меньшую, но всё ещё реальную churn-проблему.
## 11.2 Принцип
Нужно не просто "compare then skip".
Нужно **reuse old references for equal subtrees**.
То есть не так:
```ts
if (deepEqual(prev, next)) return prev;
return next;
```
А так:
```ts
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.
### Правильнее так
```ts
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 самого member
- `lastAuthoredMessageAt` тоже меняется только когда пишет сам 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)`, а не full `refreshTeamData(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 факты:
- `lastAuthoredMessageAt`
- `messageCountExact`
- `latestAuthoredMessageSignalsTermination`
## 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, внутри которого есть правильный порядок сборки:
1. новые типы и IPC surfaces
2. новый store shape
3. message/meta consumers переводятся на новые selectors
4. event routing меняется
5. structural sharing включается
6. legacy `TeamData.messages` usage выпиливается
То есть 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
Чтобы не собрать ветку в промежуточное состояние, которое уже компилируется, но архитектурно тянет старые баги, ниже разрешённые и запрещённые промежуточные формы.
Разрешено временно:
- держать `ResolvedTeamMemberView` renderer-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
Сделать:
- `TeamViewSnapshot`
- `TeamMemberSnapshot`
- `TeamMemberActivityMeta`
- расширить existing `MessagesPage` полем `feedRevision`
- перевести request shape `getMessagesPage()` c `beforeTimestamp` на `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
Сделать:
- `teamMessagesByName`
- `memberActivityMetaByTeam`
- `refreshTeamMessagesHead()`
- `refreshMemberActivityMeta()`
- `applyOptimisticTeamMessage()`
- merged selector over `canonicalMessages + optimisticMessages`
Проверить:
- message cache корректно merge'ит optimistic + fetched messages
- canonical refresh не откатывает optimistic row до подтверждения feed
### Step 4 - Перевести `MessagesPanel`
Сделать:
- убрать `messages` prop как 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 model
- `StatusBlock` перестаёт читать 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` больше не зовут full `refreshTeamData()` по умолчанию
- зовут 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
Проверить:
- `selectedTeamData` ref не меняется на 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
- `src/main/services/team/TeamMessageFeedService.ts`
- новый shared normalized message feed cache/index
- используется и `getMessagesPage()`, и `getMemberActivityMeta()`
- `src/main/services/team/TeamDataService.ts`
- `getTeamData()` перестаёт включать `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` перестают звать full `refreshTeamData()`
### 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`
- убрать `messages` prop
- `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)`
- graph-related tests
- заменить legacy `TeamData.messages` assumptions
### 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` из props
- `MemberMessagesTab` не читает `messages` из team snapshot
- graph не читает `TeamData.messages`
### Gate 2 - Event routing actually split
- `lead-message` event тестом подтверждённо не вызывает full `refreshTeamData()`
- `inbox` event тоже не тянет full snapshot refresh по умолчанию
- `refreshMemberActivityMeta()` не дёргается без `feedChanged === true` или stale-meta condition
### Gate 3 - Exact semantics preserved
- `messageCount` остался exact
- `lastActiveAt` считается по 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 `TeamMemberActivityMeta` wrapper без необходимости
### Gate 5 - Legacy field gone
- в shared snapshot contract нет `messages`
- в merged renderer code нет чтения `selectedTeamData.messages`
- `selectedTeamData`, если поле сохранено, не является второй independently-built snapshot copy
- old mixed `TeamData` semantic comparator не пережил migration и не остался permanent hot path guard
### Gate 6 - Shared feed cache actually used
- `getMessagesPage()` не содержит второго самостоятельного normalize pipeline
- `MessagesPage` реально несёт `feedRevision`, а store использует его в routing/invalidation
- `getMessagesPage()` режет страницы по 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-out `getMessagesPage()` / `getMemberActivityMeta()` по всем командам
- overview остаётся на structural snapshot semantics
## 15.5 Suggested commit slices
Если делать это не одним бесформенным diff, а нормальными кусками, я бы резал так:
1. `refactor(team): introduce structural team snapshot contracts`
- новые shared types
- новый IPC contract для `getMemberActivityMeta()`
2. `refactor(team): add shared team message feed cache`
- `TeamMessageFeedService`
- `getMessagesPage()` переводится на shared feed
- worker boundary расширяется для messages/meta path
3. `refactor(team): split member activity meta from team snapshot`
- `MemberActivityMetaService`
- `TeamMemberResolver` становится structural-only
- `getData()` перестаёт возвращать `messages`
4. `refactor(renderer): move team message orchestration into store`
- store caches and actions
- event routing split
- store-owned fallback polling
5. `refactor(renderer): migrate team detail consumers to snapshot plus message store`
- `TeamDetailView`
- `MessagesPanel`
- `ActivityTimeline`
- `MemberDetailDialog`
- `MemberMessagesTab`
- `MemberHoverCard`
- `StatusBlock`
- graph adapter
6. `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()` перестаёт возвращать `messages`
- `TeamMemberResolver` становится structural-only
- `MemberActivityMetaService` строится от 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/polling
- `ActivityTimeline` от store-backed messages или timeline view-model selector
- `MemberMessagesTab` без direct fetch/polling
- `MemberDetailDialog` без `messages` prop
- `MemberHoverCard` от facts/view-model selector, не от whole wrappers
- `StatusBlock` от store-backed messages
- `TeamDetailView` собирает overlay model
- UI consumers переходят на data/view-model selectors, а не на whole wrappers
Проверить:
- `rg` по renderer не находит direct `api.teams.getMessagesPage(` внутри этих компонентов
- `selectedTeamData.messages` больше не читается
- `MemberMessagesTab` больше не фильтрует whole team messages array прямо в render body
- `ActivityTimeline` больше не строится от snapshot message prop
- `MemberHoverCard` не подписан на whole `TeamMemberActivityMeta` wrapper
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
```ts
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
```ts
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 / hasMore
- `selectMemberActivityFeedRevision(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
```ts
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 надо мержить отдельным путём:
```ts
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
```ts
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()` корректно считает `lastAuthoredMessageAt`
- `getMemberActivityMeta()` сохраняет exact `messageCount`
- `getMemberActivityMeta()` корректно помечает `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 сохраняет ref
- `selectedTeamData` reuse'ит exact same ref as `teamDataCacheByName[selectedTeamName]`
- при `selectedTeamName` switch `selectedTeamData` не продолжает указывать на snapshot предыдущей команды
- отсутствие `teamDataCacheByName[teamName]` и `memberActivityMetaByTeam[teamName]` до first success не заменяется fake placeholder objects
- `refreshTeamMessagesHead()` merge'ит новые head messages
- `refreshTeamMessagesHead()` различает `feedChanged` и `headChanged`
- `refreshTeamMessagesHead()` не возвращает невозможное состояние `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 request
- `loadOlderTeamMessages()` при `hasMore === false` делает cheap no-op
- `headHydrated === false` не сочетается с non-empty `canonicalMessages` или с `loadingOlder === true`
- `headHydrated === false` сочетается только с bootstrap `feedRevision/null`, `nextCursor/null` и `lastFetchedAt/null`
- optimistic row может жить поверх `headHydrated === false` bootstrap 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 `messageId` appears
- user send сам по себе не триггерит лишний `refreshMemberActivityMeta()`
- `lead-message` event больше не вызывает `refreshTeamData()`
- `task` event по-прежнему вызывает `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 `feedRevision` change сбрасывает только canonical older tail и не теряет optimistic rows
- fallback polling запускается только для visible active team или local pending-reply wait state
## 18.3 Component tests
Нужны тесты на:
- `MessagesPanel` initial hydration from store
- `ActivityTimeline` читает store-backed messages/view-model path, а не snapshot prop
- `MemberDetailDialog` without snapshot messages prop
- `MemberHoverCard` читает facts/view-model selector, а не whole meta wrapper
- `StatusBlock` отрабатывает member pending replies из local overlay
- overview `StatusBlock` или аналогичный badge не триггерит hidden team hydration
- graph adapter берёт messages не из snapshot
- `StatusBlock` корректно считает cross-team pending replies из message cache + local TTL
- `MessagesPanel` и `MemberMessagesTab` не содержат собственного polling/fetch orchestration
- older-page loading не ломает scroll/order при одновременном head refresh
## 18.4 Soak / perf validation
Нужны реальные runtime probes:
- count of `refreshTeamData` calls
- 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
Фикс можно считать правильным, если одновременно выполняется всё:
1. `lead-message` storm больше не вызывает repeated `refreshTeamData()` для visible team
2. identical structural snapshot не меняет `selectedTeamData` ref
3. `MessagesPanel` живёт без `data.messages` prop
4. member list/status block не зависят от full messages array inside snapshot
5. graph не зависит от `TeamData.messages`
6. `MessagesPanel` и `MemberMessagesTab` не делают direct IPC fetch из компонента
7. long tasks на 4-member soak заметно падают
8. нет regressions в optimistic send, member dialog, pending replies
9. hot path `getMessagesPage()` больше не делает raw full rescan на каждый visible refresh
10. multi-team overview screens не создают hidden fan-out на `getMessagesPage()` / `getMemberActivityMeta()`
11. burst event storm не порождает параллельную очередь одинаковых head/meta refresh requests
### Практический perf target
Хотя бы такой:
- skip-rate no-op structural refreshes высокий в heartbeat windows
- `team:getData` payload ощутимо меньше
- long tasks больше не накапливаются без видимых изменений UI
## 19.1 Reviewer checklist
Это короткий список для финальной проверки PR человеком, который не писал реализацию.
Reviewer должен уметь ответить "да" на каждый пункт ниже без догадок:
- `rg` по merged code не находит чтения `selectedTeamData.messages`
- `getData()` типизирован как `TeamViewSnapshot`, а не legacy mixed transport
- `getMessagesPage()` в shared API больше не использует `beforeTimestamp` как canonical paging contract
- `MessagesPage.feedRevision` выглядит как content-stable revision, а не timestamp-like token
- `getMessagesPage()` и `getMemberActivityMeta()` сходятся в один shared feed backend
- `MessagesPanel` и `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'ить сейчас
- `config`
- `tasks`
- `kanbanState`
- `processes`
Они пока выглядят как разумный structural snapshot.
То есть ответ на вопрос "мы в будущем ещё больше разрежем `getData`?" такой:
- возможно да
- но **не надо делать это заранее**
- прямо сейчас правильная граница проходит по messages и message-derived member activity
## 21. Отдельно про Linux task manager и "Electron 12.1 GB"
Это важно понимать правильно.
Если на Linux в системном мониторинге видны отдельные строки:
- `electron`
- `chrome --type=renderer`
- `node`
- `claude-multimodel`
то это обычно **отдельные OS processes**, а не "всё сложено в electron row".
Следствие:
- `electron 12.1 GB` очень похоже на реальный RSS browser/main процесса Electron
- spawned Claude/Codex/node subprocesses обычно не должны магически считаться внутрь этой строки, если они уже видны отдельно
Это не доказывает leak само по себе, но и не выглядит как "да это просто все дети туда суммировались".
### Что добавить для подтверждения
Нужна отдельная main-side telemetry:
```ts
const mem = process.memoryUsage();
const metrics = app.getAppMetrics();
```
И логировать хотя бы каждые 30s:
- `rss`
- `heapUsed`
- `external`
- per-process Electron metrics
Тогда станет видно:
- реально ли main/browser process растёт
- есть ли рост после renderer recovery
- совпадает ли это с observed long stalls
## 22. Мой итоговый вывод
Если хочется сделать **сразу правильно**, а не делать цепочку полуфиксов, то целевой дизайн должен быть именно таким:
- `getData(teamName)` -> structural snapshot
- `getMessagesPage(teamName, { limit, cursor })` -> message feed
- `getMemberActivityMeta(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 как страховку
Именно это я считаю вариантом, который ближе всего к "сделать один раз и правильно", а не возвращаться потом ещё на два круга переделки.