diff --git a/docs/research/team-detail-snapshot-messages-activity-plan.md b/docs/research/team-detail-snapshot-messages-activity-plan.md new file mode 100644 index 00000000..67f39d77 --- /dev/null +++ b/docs/research/team-detail-snapshot-messages-activity-plan.md @@ -0,0 +1,3221 @@ +# План: 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; + /** + * 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; + 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; + getMessagesPage: ( + teamName: string, + options?: { cursor?: string | null; limit?: number } + ) => Promise; + getMemberActivityMeta: (teamName: string) => Promise; +} +``` + +### 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; + + teamMessagesByName: Record; + memberActivityMetaByTeam: Record; + + refreshTeamData: (teamName: string, opts?: RefreshTeamDataOptions) => Promise; + refreshTeamMessagesHead: (teamName: string) => Promise; + loadOlderTeamMessages: (teamName: string) => Promise; + refreshMemberActivityMeta: (teamName: string) => Promise; + 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: "", + nextCursor: null, + hasMore: false, + lastFetchedAt: , + 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` здесь слишком двусмысленный. + +Надёжнее сразу зафиксировать 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` + - добавить `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( + 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> = + Object.freeze({}); +const teamMessagesSelectors = new Map readonly InboxMessage[]>(); +const memberMessagesSelectors = new Map 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, + spawnStatuses: Record +): 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 как страховку + +Именно это я считаю вариантом, который ближе всего к "сделать один раз и правильно", а не возвращаться потом ещё на два круга переделки. diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 9586f4d0..57abe5de 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -1,5 +1,5 @@ /** - * TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort. + * TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort. * * This adapter owns the graph projection from team runtime state into the * reusable package port model. Renderer hooks may still read store state, but @@ -55,12 +55,18 @@ import type { LeadActivityState, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - TeamData, + ResolvedTeamMember, TeamProcess, TeamProvisioningProgress, + TeamViewSnapshot, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; +export interface TeamGraphData extends TeamViewSnapshot { + members: ResolvedTeamMember[]; + messageFeed: InboxMessage[]; +} + export class TeamGraphAdapter { // ─── ES #private fields ────────────────────────────────────────────────── #lastTeamName = ''; @@ -87,7 +93,7 @@ export class TeamGraphAdapter { * Adapt team data into a GraphDataPort snapshot. */ adapt( - teamData: TeamData | null, + teamData: TeamGraphData | null, teamName: string, spawnStatuses?: Record, leadActivity?: LeadActivityState, @@ -179,7 +185,7 @@ export class TeamGraphAdapter { this.#buildMessageParticles( particles, nodes, - teamData.messages, + teamData.messageFeed, teamName, leadId, leadName, @@ -222,11 +228,11 @@ export class TeamGraphAdapter { // ─── Private: node builders ────────────────────────────────────────────── - static #getLeadMemberName(data: TeamData, teamName: string): string { + static #getLeadMemberName(data: TeamGraphData, teamName: string): string { return getGraphLeadMemberName(data, teamName); } - static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map { + static #buildMemberNodeIdByName(data: TeamGraphData, teamName: string): Map { return new Map( data.members .filter((member) => !isLeadMember(member)) @@ -235,7 +241,7 @@ export class TeamGraphAdapter { } static #buildLayoutPort( - data: TeamData, + data: TeamGraphData, teamName: string, slotAssignments?: Record ): GraphLayoutPort { @@ -252,7 +258,7 @@ export class TeamGraphAdapter { (data.config.members ?? []).map((member) => getGraphStableOwnerId(member)) ); - const pushMember = (member: TeamData['members'][number] | undefined): void => { + const pushMember = (member: TeamGraphData['members'][number] | undefined): void => { if (!member) { return; } @@ -322,7 +328,7 @@ export class TeamGraphAdapter { } static #collectDuplicateStableOwnerIds( - members: readonly TeamData['members'][number][] + members: readonly TeamGraphData['members'][number][] ): string[] { const counts = new Map(); for (const member of members) { @@ -344,9 +350,9 @@ export class TeamGraphAdapter { } static #getRuntimeLabel( - providerId: TeamData['members'][number]['providerId'], - model: TeamData['members'][number]['model'], - effort: TeamData['members'][number]['effort'] + providerId: ResolvedTeamMember['providerId'], + model: ResolvedTeamMember['model'], + effort: ResolvedTeamMember['effort'] ): string | undefined { return formatTeamRuntimeSummary(providerId, model, effort); } @@ -367,7 +373,7 @@ export class TeamGraphAdapter { #buildLeadNode( nodes: GraphNode[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, leadName: string, pendingApprovalAgents?: Set, @@ -462,7 +468,7 @@ export class TeamGraphAdapter { nodes: GraphNode[], edges: GraphEdge[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByName: ReadonlyMap, spawnStatuses?: Record, @@ -565,12 +571,12 @@ export class TeamGraphAdapter { #buildTaskNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, commentReadState?: Record, memberNodeIdByName?: ReadonlyMap ): void { - const taskStateById = new Map>(); + const taskStateById = new Map>(); const taskDisplayIds = new Map(); const memberColorByName = new Map(); @@ -750,7 +756,7 @@ export class TeamGraphAdapter { #buildProcessNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByName?: ReadonlyMap ): void { @@ -828,7 +834,7 @@ export class TeamGraphAdapter { #attachActivityFeeds( nodes: GraphNode[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string @@ -845,7 +851,10 @@ export class TeamGraphAdapter { } const entriesByOwnerNodeId = buildInlineActivityEntries({ - data, + data: { + ...data, + messages: data.messageFeed, + }, teamName, leadId, leadName, @@ -1006,7 +1015,7 @@ export class TeamGraphAdapter { #buildCommentParticles( particles: GraphParticle[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string, @@ -1099,8 +1108,8 @@ export class TeamGraphAdapter { } static #buildMemberException( - runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'], - providerId: TeamData['members'][number]['providerId'], + runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'], + providerId: ResolvedTeamMember['providerId'], spawn: MemberSpawnStatusEntry | undefined, pendingApproval: boolean ): Pick | undefined { diff --git a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts index ae50b51d..c633062c 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts @@ -1,17 +1,34 @@ import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, + selectTeamMessages, +} from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; -import type { TeamData, TeamSummary } from '@shared/types/team'; +import type { TeamSummary } from '@shared/types/team'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; export function useGraphActivityContext(teamName: string): { - teamData: TeamData | null; + teamData: TeamGraphData | null; teams: TeamSummary[]; } { return useStore( - useShallow((state) => ({ - teamData: selectTeamDataForName(state, teamName), - teams: state.teams, - })) + useShallow((state) => { + const snapshot = selectTeamDataForName(state, teamName); + const members = selectResolvedMembersForTeamName(state, teamName); + const messages = selectTeamMessages(state, teamName); + + return { + teamData: snapshot + ? { + ...snapshot, + members, + messageFeed: messages, + } + : null, + teams: state.teams, + }; + }) ); } diff --git a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx index 18b4e414..3daa41d8 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx +++ b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx @@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog'; import { useStore } from '@renderer/store'; -import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + isTeamProvisioningActive, + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import type { TaskRef } from '@shared/types'; @@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi }); const [submitting, setSubmitting] = useState(false); - const { teamData, createTeamTask, isTeamProvisioning } = useStore( + const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore( useShallow((state) => ({ teamData: selectTeamDataForName(state, teamName), + activeMembers: selectResolvedMembersForTeamName(state, teamName).filter( + (member) => !member.removedAt + ), createTeamTask: state.createTeamTask, isTeamProvisioning: isTeamProvisioningActive(state, teamName), })) ); - const activeMembers = useMemo( - () => (teamData?.members ?? []).filter((member) => !member.removedAt), - [teamData?.members] - ); - const openCreateTaskDialog = useCallback((owner = ''): void => { setDialogState({ open: true, diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts index 6ac0fdad..92dcf194 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts @@ -1,19 +1,34 @@ import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, + selectResolvedMembersForTeamName, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; + export function useGraphMemberPopoverContext(teamName: string, memberName: string) { return useStore( - useShallow((state) => ({ - teamData: teamName ? selectTeamDataForName(state, teamName) : null, - spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, - leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, - progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, - memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, - memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, - })) + useShallow((state) => { + const snapshot = teamName ? selectTeamDataForName(state, teamName) : null; + const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : []; + + return { + teamData: snapshot + ? { + ...snapshot, + members: teamMembers, + messageFeed: [], + } + : null, + teamMembers, + spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, + leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, + progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, + memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, + memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, + }; + }) ); } diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 88e2127e..214a61c1 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -9,19 +9,24 @@ import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, + selectResolvedMembersForTeamName, selectTeamDataForName, + selectTeamMessages, } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; import type { GraphDataPort } from '@claude-teams/agent-graph'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); const { - teamData, + teamSnapshot, + members, + messages, spawnStatuses, leadActivity, leadContext, @@ -35,7 +40,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ - teamData: selectTeamDataForName(s, teamName), + teamSnapshot: selectTeamDataForName(s, teamName), + members: selectResolvedMembersForTeamName(s, teamName), + messages: selectTeamMessages(s, teamName), spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, @@ -60,6 +67,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { return agents; }, [pendingApprovals, teamName]); + const teamData = useMemo(() => { + if (!teamSnapshot) { + return null; + } + return { + ...teamSnapshot, + members, + messageFeed: messages, + }; + }, [members, messages, teamSnapshot]); + const commentReadState = useSyncExternalStore(subscribe, getSnapshot); useEffect(() => { diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index b157fb78..61211dbf 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -71,6 +71,9 @@ export const GraphActivityHud = ({ const connectorPathRefs = useRef(new Map()); const [expandedItem, setExpandedItem] = useState(null); const { teamData, teams } = useGraphActivityContext(teamName); + const teamSnapshot = teamData; + const members = teamData?.members ?? []; + const messages = teamData?.messageFeed ?? []; const ownerNodes = useMemo( () => @@ -81,21 +84,27 @@ export const GraphActivityHud = ({ [nodes] ); const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`; - const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`; + const leadName = teamSnapshot + ? getGraphLeadMemberName({ members }, teamName) + : `${teamName}-lead`; const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]); const entryMapByOwnerNodeId = useMemo(() => { - if (!teamData) { + if (!teamSnapshot) { return new Map(); } return buildInlineActivityEntries({ - data: teamData, + data: { + members, + tasks: teamSnapshot.tasks, + messages, + }, teamName, leadId: leadNodeId, leadName, ownerNodeIds, }); - }, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]); - const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + }, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]); + const messageContext = useMemo(() => buildMessageContext(members), [members]); const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); const { readSet } = useTeamMessagesRead(teamName); @@ -383,7 +392,7 @@ export const GraphActivityHud = ({ }; }, [enabled, forwardWheelToGraph, visibleLanes]); - if (!enabled || !teamData || visibleLanes.length === 0) { + if (!enabled || !teamSnapshot || visibleLanes.length === 0) { return null; } @@ -493,7 +502,7 @@ export const GraphActivityHud = ({ } }} teamName={teamName} - members={teamData.members} + members={members} onMemberClick={handleMemberClick} onTaskIdClick={onOpenTaskDetail} teamNames={teamNames} diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index afc454ac..f8cf303f 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -292,14 +292,21 @@ const MemberPopoverContent = ({ ? node.domainRef.teamName : ''; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); - const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } = - useGraphMemberPopoverContext(teamName, memberName); - const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null; + const { + teamData, + teamMembers, + spawnEntry, + leadActivity, + progress, + memberSpawnSnapshot, + memberSpawnStatuses, + } = useGraphMemberPopoverContext(teamName, memberName); + const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null; const provisioningPresentation = teamData && teamName ? buildTeamProvisioningPresentation({ progress, - members: teamData.members, + members: teamMembers, memberSpawnStatuses, memberSpawnSnapshot, }) diff --git a/src/main/index.ts b/src/main/index.ts index b8c79c35..695584d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -562,6 +562,13 @@ function wireFileWatcherEvents(context: ServiceContext): void { const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; + if ( + teamDataService && + (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') + ) { + teamDataService.invalidateMessageFeed(teamName); + } + // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { if (reconcileScheduler) { @@ -900,6 +907,12 @@ async function initializeServices(): Promise { }); const forwardTeamChange = (event: TeamChangeEvent): void => { + if ( + teamDataService && + (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') + ) { + teamDataService.invalidateMessageFeed(event.teamName); + } safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); }; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f35e2628..69faa29e 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -22,6 +22,7 @@ import { TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_MEMBER_ACTIVITY_META, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, @@ -92,7 +93,7 @@ import { parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; import crypto from 'crypto'; -import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; +import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; @@ -170,15 +171,16 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, TeamUpdateConfigRequest, ToolApprovalFileContent, ToolApprovalSettings, @@ -196,6 +198,17 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; +function ensureHeavyTeamDataWorkerFallbackAllowed(operation: string): void { + if (!app.isPackaged) { + return; + } + + logger.error( + `[${operation}] team-data-worker unavailable in packaged runtime; refusing main-thread fallback for heavy message/activity path` + ); + throw new Error('TEAM_DATA_WORKER_UNAVAILABLE'); +} + async function getDurableLeadTeammateRoster( teamName: string, leadName: string @@ -385,6 +398,19 @@ function checkApiErrorMessages( } } +function scanTeamMessageNotifications( + messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], + teamName: string, + teamDisplayName: string, + projectPath?: string +): void { + if (messages.length === 0) { + return; + } + checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath); + checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath); +} + let teamDataService: TeamDataService | null = null; let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; @@ -463,6 +489,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage); + ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta); ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); @@ -535,6 +562,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); ipcMain.removeHandler(TEAM_SEND_MESSAGE); ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE); + ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META); ipcMain.removeHandler(TEAM_CREATE_TASK); ipcMain.removeHandler(TEAM_REQUEST_REVIEW); ipcMain.removeHandler(TEAM_UPDATE_KANBAN); @@ -702,14 +730,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { +): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } const tn = validated.value!; const startedAt = Date.now(); - let data: TeamData; + let data: TeamViewSnapshot; setCurrentMainOp('team:getData'); try { // Prefer worker thread to keep main event loop responsive @@ -721,9 +749,11 @@ async function handleGetData( logger.warn( `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` ); + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } else { + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } catch (error) { @@ -762,92 +792,9 @@ async function handleGetData( const displayName = data.config.name || tn; const projectPath = data.config.projectPath; - const live = provisioning.getLiveLeadProcessMessages(tn); - if (live.length === 0) { - checkRateLimitMessages(data.messages, tn, displayName, projectPath); - checkApiErrorMessages(data.messages, tn, displayName, projectPath); - return { success: true, data: { ...data, isAlive } }; - } - - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean => - !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); - const getLeadThoughtFingerprint = (msg: { - from: string; - text: string; - leadSessionId?: string; - }): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`; - - // Collect fingerprints only for thought-like lead messages. Include leadSessionId so a - // repeated thought in a new session does not get collapsed into an old session's history. - const existingTextFingerprints = new Set(); - for (const msg of data.messages) { - if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; - if (!isLeadThoughtLike(msg)) continue; - existingTextFingerprints.add(getLeadThoughtFingerprint(msg)); - } - - const keyFor = (m: { - messageId?: string; - timestamp: string; - from: string; - text: string; - }): string => { - if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { - return m.messageId; - } - return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; - }; - - // Text-based fingerprints for live lead thoughts to catch duplicates with different - // messageIds inside the same session (e.g. lead-turn-* re-emits). - const leadProcessTextFingerprints = new Set(); - - // Content-based dedup for SendMessage captures: Claude Code CLI and our - // persistInboxMessage both write to inboxes/{member}.json, producing two entries - // with identical content but different messageIds. Track content fingerprints - // (from+to+text) with timestamps to collapse them within a 5-second window. - const contentSeen = new Map(); // fingerprint → timestamp ms - - const merged: typeof data.messages = []; - const seen = new Set(); - for (const msg of [...data.messages, ...live]) { - if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) { - const fp = getLeadThoughtFingerprint(msg); - // Skip if the same thought already exists in persisted history for the same session. - if (existingTextFingerprints.has(fp)) { - continue; - } - // Dedup live lead_process thoughts with the same text in the same session. - if (leadProcessTextFingerprints.has(fp)) { - continue; - } - leadProcessTextFingerprints.add(fp); - } - - // Content dedup for directed messages (SendMessage captures): - // same from+to+text within 5 seconds = duplicate from CLI + our persist. - if (typeof msg.to === 'string' && msg.to.trim().length > 0) { - const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`; - const msgMs = Date.parse(msg.timestamp); - const existingMs = contentSeen.get(contentFp); - if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) { - continue; // duplicate within 5s window — skip - } - contentSeen.set(contentFp, msgMs); - } - - const key = keyFor(msg); - if (seen.has(key)) continue; - seen.add(key); - merged.push(msg); - } - merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - - checkRateLimitMessages(merged, tn, displayName, projectPath); - checkApiErrorMessages(merged, tn, displayName, projectPath); - return { success: true, data: { ...data, isAlive, messages: merged } }; + scanTeamMessageNotifications(live, tn, displayName, projectPath); + return { success: true, data: { ...data, isAlive } }; } async function handleGetTaskChangePresence( @@ -1698,16 +1645,71 @@ async function handleGetMessagesPage( return { success: false, error: vTeam.error ?? 'Invalid teamName' }; } const opts = (options && typeof options === 'object' ? options : {}) as { - beforeTimestamp?: string; + cursor?: string | null; limit?: number; }; const limit = Math.min(Math.max(1, opts.limit ?? 50), 200); - const beforeTimestamp = - typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined; + const cursor = + typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined; return wrapTeamHandler('getMessagesPage', async () => { - const service = getTeamDataService(); - return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit }); + let page: MessagesPage; + const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!); + const worker = getTeamDataWorkerClient(); + if (worker.isAvailable()) { + try { + page = await worker.getMessagesPage(vTeam.value!, { cursor, limit }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + return page; + } catch (workerErr) { + logger.warn( + `[teams:getMessagesPage] worker failed, falling back: ${ + workerErr instanceof Error ? workerErr.message : workerErr + }` + ); + } + } + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMessagesPage'); + page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + return page; + }); +} + +async function handleGetMemberActivityMeta( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + + return wrapTeamHandler('getMemberActivityMeta', async () => { + const worker = getTeamDataWorkerClient(); + if (worker.isAvailable()) { + try { + return await worker.getMemberActivityMeta(vTeam.value!); + } catch (workerErr) { + logger.warn( + `[teams:getMemberActivityMeta] worker failed, falling back: ${ + workerErr instanceof Error ? workerErr.message : workerErr + }` + ); + } + } + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMemberActivityMeta'); + return getTeamDataService().getMemberActivityMeta(vTeam.value!); }); } diff --git a/src/main/services/team/MemberActivityMetaService.ts b/src/main/services/team/MemberActivityMetaService.ts new file mode 100644 index 00000000..3a91dc29 --- /dev/null +++ b/src/main/services/team/MemberActivityMetaService.ts @@ -0,0 +1,128 @@ +import type { TeamMessageFeedService } from './TeamMessageFeedService'; +import type { InboxMessage, MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types'; + +interface MemberActivityMetaCacheEntry { + feedRevision: string; + meta: TeamMemberActivityMeta; +} + +function messageSignalsTermination(message: InboxMessage | null | undefined): boolean { + if (!message) return false; + try { + const parsed = JSON.parse(message.text) as { + type?: string; + approve?: boolean; + approved?: boolean; + }; + return ( + (parsed.type === 'shutdown_response' && + (parsed.approve === true || parsed.approved === true)) || + parsed.type === 'shutdown_approved' + ); + } catch { + return false; + } +} + +function areMemberActivityEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +function structurallyShareMemberFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + +export class MemberActivityMetaService { + private readonly cacheByTeam = new Map(); + + constructor(private readonly feedService: TeamMessageFeedService) {} + + invalidate(teamName: string): void { + this.cacheByTeam.delete(teamName); + } + + async getMeta(teamName: string): Promise { + const feed = await this.feedService.getFeed(teamName); + const cached = this.cacheByTeam.get(teamName); + if (cached?.feedRevision === feed.feedRevision) { + return cached.meta; + } + + const latestByMember = new Map(); + const countsByMember = new Map(); + + for (const message of feed.messages) { + const memberName = typeof message.from === 'string' ? message.from.trim() : ''; + if (!memberName || memberName === 'user' || memberName === 'system') { + continue; + } + + countsByMember.set(memberName, (countsByMember.get(memberName) ?? 0) + 1); + if (!latestByMember.has(memberName)) { + latestByMember.set(memberName, message); + } + } + + const nextMembers = Object.fromEntries( + Array.from(new Set([...countsByMember.keys(), ...latestByMember.keys()])) + .sort((left, right) => left.localeCompare(right)) + .map((memberName) => { + const latestMessage = latestByMember.get(memberName) ?? null; + return [ + memberName, + { + memberName, + lastAuthoredMessageAt: latestMessage?.timestamp ?? null, + messageCountExact: countsByMember.get(memberName) ?? 0, + latestAuthoredMessageSignalsTermination: messageSignalsTermination(latestMessage), + }, + ] as const; + }) + ); + const members = structurallyShareMemberFacts(cached?.meta.members, nextMembers); + + const meta: TeamMemberActivityMeta = { + teamName, + computedAt: new Date().toISOString(), + members, + feedRevision: feed.feedRevision, + }; + + this.cacheByTeam.set(teamName, { feedRevision: feed.feedRevision, meta }); + return meta; + } +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 307cfa67..a8f25489 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -39,6 +39,7 @@ import { } from './cache/LeadSessionParseCache'; import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; +import { MemberActivityMetaService } from './MemberActivityMetaService'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; @@ -46,6 +47,7 @@ import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; +import { TeamMessageFeedService } from './TeamMessageFeedService'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; @@ -65,7 +67,6 @@ import type { KanbanColumnId, KanbanState, MessagesPage, - ResolvedTeamMember, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -74,13 +75,14 @@ import type { TaskRef, TeamConfig, TeamCreateConfigRequest, - TeamData, + TeamMemberActivityMeta, TeamMember, TeamProcess, TeamSummary, TeamTask, TeamTaskStatus, TeamTaskWithKanban, + TeamViewSnapshot, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; @@ -98,6 +100,14 @@ const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; +function requireCanonicalMessageId(message: InboxMessage): string { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + return messageId; + } + throw new Error('Canonical team message is missing effective messageId'); +} + interface EligibleTaskCommentNotification { key: string; messageId: string; @@ -162,6 +172,8 @@ export class TeamDataService { private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; private teamLogSourceTracker: TeamLogSourceTracker | null = null; private fileWatchReconcileDiagnostics = new Map(); + private readonly messageFeedService: TeamMessageFeedService; + private readonly memberActivityMetaService: MemberActivityMetaService; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -183,7 +195,15 @@ export class TeamDataService { private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache() - ) {} + ) { + this.messageFeedService = new TeamMessageFeedService({ + getConfig: (teamName) => this.configReader.getConfig(teamName), + getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName), + getLeadSessionMessages: (config) => this.extractLeadSessionTexts(config), + getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName), + }); + this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService); + } private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); @@ -622,7 +642,7 @@ export class TeamDataService { await fs.promises.rm(tasksDir, { recursive: true, force: true }); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string): Promise { const startedAt = Date.now(); const marks: Record = {}; const mark = (label: string): void => { @@ -726,12 +746,6 @@ export class TeamDataService { warningText: 'Inboxes failed to load', load: () => this.inboxReader.listInboxNames(teamName), }); - const sentMessagesStep = startReadStep({ - label: 'sentMessages', - createFallback: () => [], - warningText: 'Sent messages failed to load', - load: () => this.sentMessagesStore.readMessages(teamName), - }); const metaMembersStep = startReadStep({ label: 'metaMembers', createFallback: () => [], @@ -756,40 +770,8 @@ export class TeamDataService { load: () => this.taskReader.getTasks(teamName), }) ); - const messagesStep = runWithConcurrencyLimit(() => - startReadStep({ - label: 'messages', - createFallback: () => [], - warningText: 'Messages failed to load', - load: () => this.inboxReader.getMessages(teamName), - }) - ); - const leadTextsStep = runWithConcurrencyLimit(() => - startReadStep({ - label: 'leadTexts', - createFallback: () => [], - warningText: 'Lead session texts failed to load', - load: () => this.extractLeadSessionTexts(config), - }) - ); - - const [ - tasksStepResult, - inboxNamesStepResult, - messagesStepResult, - leadTextsStepResult, - sentMessagesStepResult, - metaMembersStepResult, - kanbanStateStepResult, - ] = await Promise.all([ - tasksStep, - inboxNamesStep, - messagesStep, - leadTextsStep, - sentMessagesStep, - metaMembersStep, - kanbanStateStep, - ]); + const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] = + await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]); // After parallelizing the top read phase, these marks no longer represent // serial stage boundaries. They now capture the actual completion time for @@ -797,178 +779,18 @@ export class TeamDataService { // diagnostics useful without mutating marks from concurrent branches. marks.tasks = tasksStepResult.completedAt; marks.inboxNames = inboxNamesStepResult.completedAt; - marks.messages = messagesStepResult.completedAt; - marks.leadTexts = leadTextsStepResult.completedAt; - marks.sentMessages = sentMessagesStepResult.completedAt; marks.metaMembers = metaMembersStepResult.completedAt; marks.kanbanState = kanbanStateStepResult.completedAt; if (tasksStepResult.warning) warnings.push(tasksStepResult.warning); if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning); - if (messagesStepResult.warning) warnings.push(messagesStepResult.warning); - if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning); - if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning); if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); const tasks: TeamTask[] = tasksStepResult.value; const inboxNames: string[] = inboxNamesStepResult.value; - let messages: InboxMessage[] = messagesStepResult.value; - const leadTexts: InboxMessage[] = leadTextsStepResult.value; - const sentMessages: InboxMessage[] = sentMessagesStepResult.value; mark('postStart'); - if (leadTexts.length > 0) { - messages = [...messages, ...leadTexts]; - } - if (sentMessages.length > 0) { - messages = [...messages, ...sentMessages]; - } - mark('mergeMessages'); - - // Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. - // This avoids double-rendering when we persist lead process messages and later load the lead JSONL. - // Exception: lead_process messages with `to` field are captured SendMessage — never dedup those. - if (leadTexts.length > 0) { - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const getLeadThoughtFingerprint = ( - msg: Pick - ) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; - const leadSessionFingerprints = new Set(); - for (const msg of leadTexts) { - if (msg.source !== 'lead_session') continue; - leadSessionFingerprints.add(getLeadThoughtFingerprint(msg)); - } - messages = messages.filter((m) => { - if (m.source !== 'lead_process') return true; - // Captured SendMessage messages (with recipient) are real messages — never dedup - if (m.to) return true; - const fp = getLeadThoughtFingerprint(m); - return !leadSessionFingerprints.has(fp); - }); - } - mark('dedupLeadTexts'); - - // Dedup exact message copies that can appear as both live lead_process rows and - // their persisted inbox/sent-message counterpart. If the messageId is identical, - // keep a single row so the UI does not show the same SendMessage twice - // (for example "LIVE" plus the stored copy). - const duplicateMessageIds = new Set(); - const messageIdCounts = new Map(); - for (const msg of messages) { - const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; - if (!id) continue; - const nextCount = (messageIdCounts.get(id) ?? 0) + 1; - messageIdCounts.set(id, nextCount); - if (nextCount > 1) duplicateMessageIds.add(id); - } - if (duplicateMessageIds.size > 0) { - const choosePreferredMessage = ( - current: InboxMessage, - candidate: InboxMessage - ): InboxMessage => { - const score = (msg: InboxMessage): number => { - let value = 0; - if (msg.source !== 'lead_process') value += 4; - if (msg.read === false) value += 2; - if (msg.relayOfMessageId) value += 1; - if (msg.summary) value += 1; - if (msg.to) value += 1; - return value; - }; - const currentScore = score(current); - const candidateScore = score(candidate); - if (candidateScore !== currentScore) { - return candidateScore > currentScore ? candidate : current; - } - const currentTs = Date.parse(current.timestamp); - const candidateTs = Date.parse(candidate.timestamp); - if ( - Number.isFinite(currentTs) && - Number.isFinite(candidateTs) && - candidateTs !== currentTs - ) { - return candidateTs > currentTs ? candidate : current; - } - return current; - }; - - const dedupedById = new Map(); - const dedupedWithoutId: InboxMessage[] = []; - for (const msg of messages) { - const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; - if (!id) { - dedupedWithoutId.push(msg); - continue; - } - const existing = dedupedById.get(id); - if (!existing) { - dedupedById.set(id, msg); - continue; - } - dedupedById.set(id, choosePreferredMessage(existing, msg)); - } - messages = [...dedupedWithoutId, ...dedupedById.values()]; - } - mark('dedupMessageIds'); - - messages = this.linkPassiveUserReplySummaries(messages); - mark('linkPassiveUserReplySummaries'); - - // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's - // session ID (by timestamp). This avoids the old forward-only propagation bug. - if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - - const anchors: { index: number; time: number; sessionId: string }[] = []; - for (let i = 0; i < messages.length; i++) { - if (messages[i].leadSessionId) { - anchors.push({ - index: i, - time: Date.parse(messages[i].timestamp), - sessionId: messages[i].leadSessionId!, - }); - } - } - - if (anchors.length > 0) { - let anchorIdx = 0; - for (let i = 0; i < messages.length; i++) { - if (messages[i].leadSessionId) { - while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) { - anchorIdx++; - } - continue; - } - - const msgTime = Date.parse(messages[i].timestamp); - let bestAnchor = anchors[0]; - let bestDist = Math.abs(msgTime - bestAnchor.time); - for (const anchor of anchors) { - const dist = Math.abs(msgTime - anchor.time); - if (dist < bestDist) { - bestDist = dist; - bestAnchor = anchor; - } else if (dist > bestDist && anchor.time > msgTime) { - break; - } - } - messages[i].leadSessionId = bestAnchor.sessionId; - } - } else if (config.leadSessionId) { - for (const msg of messages) { - msg.leadSessionId = config.leadSessionId; - } - } - } - mark('attachLeadSessionIds'); - - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - this.annotateSlashCommandResponses(messages); - - messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - mark('normalizeMessages'); - const metaMembers: TeamConfig['members'] = metaMembersStepResult.value; const kanbanState: KanbanState = kanbanStateStepResult.value; @@ -1000,8 +822,7 @@ export class TeamDataService { config, metaMembers, inboxNames, - tasksWithKanban, - messages + tasksWithKanban ); mark('resolveMembers'); @@ -1036,30 +857,13 @@ export class TeamDataService { const totalMs = Date.now() - startedAt; if (totalMs >= 1500) { - const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`; + const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`; logger.warn( `getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( 'inboxNames' - )} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince( - 'sentMessages' )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( 'kanbanGc' - )} post=${msBetween( - 'postStart', - 'mergeMessages' - )}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween( - 'dedupLeadTexts', - 'dedupMessageIds' - )}/attachLeadSession=${msBetween( - 'dedupMessageIds', - 'attachLeadSessionIds' - )}/normalizeMessages=${msBetween( - 'attachLeadSessionIds', - 'normalizeMessages' - )}/attachKanban=${msBetween( - 'normalizeMessages', - 'attachKanban' - )}/loadPresenceIndex=${msBetween( + )} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween( 'attachKanban', 'loadPresenceIndex' )}/changePresence=${msBetween( @@ -1088,21 +892,14 @@ export class TeamDataService { this.processHealthTeams.delete(teamName); } - // Cap messages to keep IPC payloads small. Full history is available - // via the paginated getMessagesPage() API. We still include a small - // batch here for backward compatibility (notifications, dedup, etc.). - const MAX_RETURN_MESSAGES = 50; - const cappedMessages = - messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages; - return { teamName, config, tasks: tasksWithKanban, members, - messages: cappedMessages, kanbanState, processes, + isAlive: hasAlive, warnings: warnings.length > 0 ? warnings : undefined, }; } @@ -1113,106 +910,45 @@ export class TeamDataService { */ async getMessagesPage( teamName: string, - options: { beforeTimestamp?: string; limit: number } + options: { cursor?: string | null; limit: number } ): Promise { - const config = await this.configReader.getConfig(teamName); - if (!config) { - return { messages: [], nextCursor: null, hasMore: false }; - } + const feed = await this.messageFeedService.getFeed(teamName); + let messages = feed.messages; - // Collect all messages from the same sources as getTeamData - let messages: InboxMessage[] = []; - - const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ - this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]), - this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]), - this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]), - ]); - - messages = [...inboxMessages, ...leadTexts, ...sentMessages]; - - // Dedup lead_session vs lead_process (same logic as getTeamData) - if (leadTexts.length > 0) { - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const getFingerprint = (msg: Pick) => - `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; - const leadSessionFingerprints = new Set(); - for (const msg of leadTexts) { - if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg)); - } - messages = messages.filter((m) => { - if (m.source !== 'lead_process') return true; - if (m.to) return true; - return !leadSessionFingerprints.has(getFingerprint(m)); - }); - } - - // Enrich: propagate leadSessionId to messages missing it (same as getTeamData) - if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - const anchors: { time: number; sessionId: string }[] = []; - for (const msg of messages) { - if (msg.leadSessionId) { - anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId }); - } - } - if (anchors.length > 0) { - for (const msg of messages) { - if (msg.leadSessionId) continue; - const msgTime = Date.parse(msg.timestamp); - let best = anchors[0]; - let bestDist = Math.abs(msgTime - best.time); - for (const a of anchors) { - const dist = Math.abs(msgTime - a.time); - if (dist < bestDist) { - bestDist = dist; - best = a; - } else if (dist > bestDist && a.time > msgTime) { - break; - } - } - msg.leadSessionId = best.sessionId; - } - } else if (config.leadSessionId) { - for (const msg of messages) { - msg.leadSessionId = config.leadSessionId; - } - } - } - - // Enrich: annotate slash command responses - this.annotateSlashCommandResponses(messages); - - // Sort newest-first, with stable tie-breaker by messageId - messages.sort((a, b) => { - const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp); - if (diff !== 0) return diff; - return (a.messageId ?? '').localeCompare(b.messageId ?? ''); - }); - - // Apply cursor filter. Cursor format: "timestamp|messageId" (compound) - // to handle multiple messages sharing the same timestamp. - if (options.beforeTimestamp) { - const [cursorTs, cursorId] = options.beforeTimestamp.split('|'); + if (options.cursor) { + const [cursorTs, cursorId] = options.cursor.split('|'); const cursorMs = Date.parse(cursorTs); messages = messages.filter((m) => { const ms = Date.parse(m.timestamp); if (ms < cursorMs) return true; if (ms > cursorMs) return false; - // Same timestamp — use messageId tie-breaker if (!cursorId) return false; - return (m.messageId ?? '').localeCompare(cursorId) > 0; + return requireCanonicalMessageId(m).localeCompare(cursorId) > 0; }); } - // Paginate const hasMore = messages.length > options.limit; const page = messages.slice(0, options.limit); const lastMsg = page[page.length - 1]; const nextCursor = - hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null; + hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null; - return { messages: page, nextCursor, hasMore }; + return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision }; + } + + async getMessageFeed( + teamName: string + ): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> { + return this.messageFeedService.getFeed(teamName); + } + + async getMemberActivityMeta(teamName: string): Promise { + return this.memberActivityMetaService.getMeta(teamName); + } + + invalidateMessageFeed(teamName: string): void { + this.messageFeedService.invalidate(teamName); + this.memberActivityMetaService.invalidate(teamName); } /** @@ -1220,7 +956,7 @@ export class TeamDataService { * Mutates members in-place for efficiency (called right after resolveMembers). */ private async enrichMemberBranches( - members: ResolvedTeamMember[], + members: TeamViewSnapshot['members'], config: TeamConfig ): Promise { const leadEntry = config.members?.find((member) => isLeadMember(member)); @@ -1892,7 +1628,7 @@ export class TeamDataService { slashCommand: slashCommandMeta, }; } - return this.getController(teamName).messages.sendMessage({ + const result = this.getController(teamName).messages.sendMessage({ member: enrichedRequest.member, from: enrichedRequest.from, text: enrichedRequest.text, @@ -1913,6 +1649,8 @@ export class TeamDataService { leadSessionId: enrichedRequest.leadSessionId, attachments: enrichedRequest.attachments, }) as SendMessageResult; + this.invalidateMessageFeed(teamName); + return result; } private resolveLeadNameFromConfig(config: TeamConfig | null): string { @@ -2469,6 +2207,23 @@ export class TeamDataService { } } + async getTeamNotificationContext(teamName: string): Promise<{ + displayName: string; + projectPath?: string; + }> { + try { + const config = await this.configReader.getConfig(teamName); + const displayName = config?.name?.trim() || teamName; + const projectPath = + typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0 + ? config.projectPath + : undefined; + return { displayName, projectPath }; + } catch { + return { displayName: teamName }; + } + } + async requestReview(teamName: string, taskId: string): Promise { const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); this.getController(teamName).review.requestReview(taskId, { diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 89d60c73..9b69cc90 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads'; import { createLogger } from '@shared/utils/logger'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; -import type { MemberLogSummary, TeamData } from '@shared/types'; +import type { + MemberLogSummary, + MessagesPage, + TeamMemberActivityMeta, + TeamViewSnapshot, +} from '@shared/types'; const logger = createLogger('Service:TeamDataWorkerClient'); const WORKER_CALL_TIMEOUT_MS = 30_000; @@ -25,16 +30,20 @@ function makeId(): string { return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; } -function resolveWorkerPath(): string | null { +function getWorkerPathCandidates(): string[] { const baseDir = typeof __dirname === 'string' && __dirname.length > 0 ? __dirname : path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ + return [ path.join(baseDir, 'team-data-worker.cjs'), path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'), ]; +} + +function resolveWorkerPath(): string | null { + const candidates = getWorkerPathCandidates(); for (const candidate of candidates) { try { @@ -75,7 +84,9 @@ export class TeamDataWorkerClient { isAvailable(): boolean { if (!this.workerPath && !this.warnedUnavailable) { this.warnedUnavailable = true; - logger.debug('team-data-worker not found; falling back to main-thread execution'); + logger.warn( + `team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}` + ); } return this.workerPath !== null; } @@ -144,9 +155,22 @@ export class TeamDataWorkerClient { }); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); - return this.call('getTeamData', { teamName }) as Promise; + return this.call('getTeamData', { teamName }) as Promise; + } + + async getMessagesPage( + teamName: string, + options: { cursor?: string | null; limit: number } + ): Promise { + if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); + return this.call('getMessagesPage', { teamName, options }) as Promise; + } + + async getMemberActivityMeta(teamName: string): Promise { + if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); + return this.call('getMemberActivityMeta', { teamName }) as Promise; } async findLogsForTask( diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 0cafac54..3035d479 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -3,15 +3,9 @@ import { createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { getMemberColorByName } from '@shared/constants/memberColors'; -import type { - InboxMessage, - MemberStatus, - ResolvedTeamMember, - TeamConfig, - TeamMember, - TeamTaskWithKanban, -} from '@shared/types'; +import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types'; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ @@ -63,9 +57,8 @@ export class TeamMemberResolver { config: TeamConfig, metaMembers: TeamConfig['members'], inboxNames: string[], - tasks: TeamTaskWithKanban[], - messages: InboxMessage[] - ): ResolvedTeamMember[] { + tasks: TeamTaskWithKanban[] + ): TeamMemberSnapshot[] { const names = new Set(); const explicitNames = new Set(); const seenNames = new Set(); @@ -216,7 +209,7 @@ export class TeamMemberResolver { } } - const members: ResolvedTeamMember[] = []; + const members: TeamMemberSnapshot[] = []; for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); const currentTask = @@ -226,21 +219,15 @@ export class TeamMemberResolver { task.reviewState !== 'approved' && task.kanbanColumn !== 'approved' ) ?? null; - const memberMessages = messages.filter((message) => message.from === name); - const latestMessage = memberMessages[0] ?? null; - const status = this.resolveStatus(latestMessage, currentTask !== null); const configMember = configMemberMap.get(name); const metaMember = metaMemberMap.get(name); const agentId = configMember?.agentId ?? metaMember?.agentId; members.push({ name, agentId, - status, currentTaskId: currentTask?.id ?? null, taskCount: ownedTasks.length, - messageCount: memberMessages.length, - lastActiveAt: latestMessage?.timestamp ?? null, - color: latestMessage?.color ?? configMember?.color ?? metaMember?.color, + color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name), agentType: configMember?.agentType ?? metaMember?.agentType, role: configMember?.role ?? metaMember?.role, workflow: configMember?.workflow ?? metaMember?.workflow, @@ -277,45 +264,4 @@ export class TeamMemberResolver { }); return members; } - - private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus { - if (!message) { - // Member exists in config but has no messages yet — - // if they own an in_progress task they're clearly active, otherwise idle - return hasActiveTask ? 'active' : 'idle'; - } - - const structured = this.parseStructuredMessage(message.text); - if (structured) { - const typed = structured as { type?: string; approve?: boolean; approved?: boolean }; - if ( - (typed.type === 'shutdown_response' && - (typed.approve === true || typed.approved === true)) || - typed.type === 'shutdown_approved' - ) { - return 'terminated'; - } - } - - const ageMs = Date.now() - Date.parse(message.timestamp); - if (Number.isNaN(ageMs)) { - return 'unknown'; - } - if (ageMs < 5 * 60 * 1000) { - return 'active'; - } - return 'idle'; - } - - private parseStructuredMessage(text: string): Record | null { - try { - const parsed = JSON.parse(text) as unknown; - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch { - // Ignore plain text. - } - return null; - } } diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts new file mode 100644 index 00000000..f961e867 --- /dev/null +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -0,0 +1,409 @@ +import { createHash } from 'crypto'; + +import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; +import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; + +import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; + +import type { InboxMessage, TeamConfig } from '@shared/types'; + +const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; + +interface TeamMessageFeedDeps { + getConfig: (teamName: string) => Promise; + getInboxMessages: (teamName: string) => Promise; + getLeadSessionMessages: (config: TeamConfig) => Promise; + getSentMessages: (teamName: string) => Promise; +} + +interface TeamMessageFeedCacheEntry { + feedRevision: string; + messages: InboxMessage[]; +} + +export interface TeamNormalizedMessageFeed { + teamName: string; + feedRevision: string; + messages: InboxMessage[]; +} + +function requireCanonicalMessageId(message: InboxMessage): string { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + return messageId; + } + throw new Error('Normalized team message is missing effective messageId'); +} + +function normalizePassiveUserReplyLinkText(value: string | undefined): string { + if (typeof value !== 'string') return ''; + return value + .trim() + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/[.!?…]+$/g, '') + .trim(); +} + +function extractPassiveUserPeerSummaryBody(text: string): string | null { + const classified = classifyIdleNotificationText(text); + if (classified?.primaryKind !== 'heartbeat' || !classified.peerSummary) { + return null; + } + + const match = /^\[to\s+user\]\s*(.*)$/i.exec(classified.peerSummary); + if (!match) { + return null; + } + + const body = match[1]?.trim() ?? ''; + return body.length > 0 ? body : null; +} + +function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean { + if (typeof message.to === 'string' && message.to.trim().length > 0) return false; + if (message.from === 'system') return false; + return message.source === 'lead_session' || message.source === 'lead_process'; +} + +function annotateSlashCommandResponses(messages: InboxMessage[]): void { + let pendingSlash = null as InboxMessage['slashCommand'] | null; + + for (const message of messages) { + const slashCommand = + message.source === 'user_sent' + ? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text)) + : null; + + if (slashCommand) { + pendingSlash = slashCommand; + continue; + } + + if (!pendingSlash) { + continue; + } + + if (message.messageKind === 'slash_command_result') { + continue; + } + + if (isLeadThoughtCandidateForSlashResult(message)) { + message.messageKind = 'slash_command_result'; + message.commandOutput = { + stream: 'stdout', + commandLabel: pendingSlash.command, + }; + continue; + } + + pendingSlash = null; + } +} + +function linkPassiveUserReplySummaries(messages: InboxMessage[]): InboxMessage[] { + const canonicalReplies = messages + .map((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!messageId || message.to !== 'user') { + return null; + } + if (classifyIdleNotificationText(message.text)) { + return null; + } + + const time = Date.parse(message.timestamp); + if (!Number.isFinite(time)) { + return null; + } + + return { + messageId, + from: message.from, + time, + normalizedSummary: normalizePassiveUserReplyLinkText(message.summary), + normalizedText: normalizePassiveUserReplyLinkText(message.text), + }; + }) + .filter((value): value is NonNullable => value !== null); + + if (canonicalReplies.length === 0) { + return messages; + } + + let didLink = false; + const linkedMessages = messages.map((message) => { + if ( + typeof message.relayOfMessageId === 'string' && + message.relayOfMessageId.trim().length > 0 + ) { + return message; + } + + const body = extractPassiveUserPeerSummaryBody(message.text); + if (!body) { + return message; + } + + const passiveTime = Date.parse(message.timestamp); + if (!Number.isFinite(passiveTime)) { + return message; + } + + const normalizedBody = normalizePassiveUserReplyLinkText(body); + if (!normalizedBody) { + return message; + } + + const matches = canonicalReplies.filter((candidate) => { + if (candidate.from !== message.from) { + return false; + } + const deltaMs = passiveTime - candidate.time; + if (deltaMs < 0 || deltaMs > PASSIVE_USER_REPLY_LINK_WINDOW_MS) { + return false; + } + if (candidate.normalizedSummary === normalizedBody) { + return true; + } + return normalizedBody.length >= 6 && candidate.normalizedText.includes(normalizedBody); + }); + + if (matches.length !== 1) { + return message; + } + + didLink = true; + return { + ...message, + relayOfMessageId: matches[0].messageId, + }; + }); + + return didLink ? linkedMessages : messages; +} + +function dedupeLeadProcessCopies( + messages: InboxMessage[], + leadTexts: readonly InboxMessage[] +): InboxMessage[] { + if (leadTexts.length === 0) { + return messages; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const getFingerprint = (msg: Pick) => + `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; + + const leadSessionFingerprints = new Set(); + for (const msg of leadTexts) { + if (msg.source === 'lead_session') { + leadSessionFingerprints.add(getFingerprint(msg)); + } + } + + return messages.filter((message) => { + if (message.source !== 'lead_process') return true; + if (message.to) return true; + return !leadSessionFingerprints.has(getFingerprint(message)); + }); +} + +function choosePreferredMessage(current: InboxMessage, candidate: InboxMessage): InboxMessage { + const score = (msg: InboxMessage): number => { + let value = 0; + if (msg.source !== 'lead_process') value += 4; + if (msg.read === false) value += 2; + if (msg.relayOfMessageId) value += 1; + if (msg.summary) value += 1; + if (msg.to) value += 1; + return value; + }; + + const currentScore = score(current); + const candidateScore = score(candidate); + if (candidateScore !== currentScore) { + return candidateScore > currentScore ? candidate : current; + } + + const currentTs = Date.parse(current.timestamp); + const candidateTs = Date.parse(candidate.timestamp); + if (Number.isFinite(currentTs) && Number.isFinite(candidateTs) && candidateTs !== currentTs) { + return candidateTs > currentTs ? candidate : current; + } + + return current; +} + +function dedupeByMessageId(messages: InboxMessage[]): InboxMessage[] { + const dedupedById = new Map(); + const dedupedWithoutId: InboxMessage[] = []; + + for (const message of messages) { + const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!id) { + dedupedWithoutId.push(message); + continue; + } + const existing = dedupedById.get(id); + if (!existing) { + dedupedById.set(id, message); + continue; + } + dedupedById.set(id, choosePreferredMessage(existing, message)); + } + + return [...dedupedWithoutId, ...dedupedById.values()]; +} + +function ensureEffectiveMessageIds(messages: InboxMessage[]): InboxMessage[] { + let changed = false; + const normalized = messages.map((message) => { + const effectiveMessageId = getEffectiveInboxMessageId(message); + if (!effectiveMessageId || effectiveMessageId === message.messageId) { + return message; + } + changed = true; + return { + ...message, + messageId: effectiveMessageId, + }; + }); + + return changed ? normalized : messages; +} + +function attachLeadSessionIds(config: TeamConfig, messages: InboxMessage[]): void { + if (!config.leadSessionId && !messages.some((message) => message.leadSessionId)) { + return; + } + + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + const anchors: { time: number; sessionId: string }[] = []; + for (const message of messages) { + if (message.leadSessionId) { + anchors.push({ time: Date.parse(message.timestamp), sessionId: message.leadSessionId }); + } + } + + if (anchors.length > 0) { + for (const message of messages) { + if (message.leadSessionId) continue; + const messageTime = Date.parse(message.timestamp); + let best = anchors[0]; + let bestDistance = Math.abs(messageTime - best.time); + for (const anchor of anchors) { + const distance = Math.abs(messageTime - anchor.time); + if (distance < bestDistance) { + bestDistance = distance; + best = anchor; + } else if (distance > bestDistance && anchor.time > messageTime) { + break; + } + } + message.leadSessionId = best.sessionId; + } + return; + } + + if (!config.leadSessionId) { + return; + } + + for (const message of messages) { + message.leadSessionId = config.leadSessionId; + } +} + +function toFeedRevision(messages: readonly InboxMessage[]): string { + const stableMessages = messages.map((message) => ({ + messageId: message.messageId ?? null, + relayOfMessageId: message.relayOfMessageId ?? null, + from: message.from, + to: message.to ?? null, + text: message.text, + timestamp: message.timestamp, + read: message.read, + summary: message.summary ?? null, + color: message.color ?? null, + source: message.source ?? null, + attachments: message.attachments ?? null, + leadSessionId: message.leadSessionId ?? null, + conversationId: message.conversationId ?? null, + replyToConversationId: message.replyToConversationId ?? null, + toolSummary: message.toolSummary ?? null, + toolCalls: message.toolCalls ?? null, + messageKind: message.messageKind ?? null, + slashCommand: message.slashCommand ?? null, + commandOutput: message.commandOutput ?? null, + })); + + return createHash('sha256').update(JSON.stringify(stableMessages)).digest('hex').slice(0, 24); +} + +export class TeamMessageFeedService { + private readonly cacheByTeam = new Map(); + private readonly dirtyTeams = new Set(); + + constructor(private readonly deps: TeamMessageFeedDeps) {} + + invalidate(teamName: string): void { + this.dirtyTeams.add(teamName); + } + + async getFeed(teamName: string): Promise { + const cached = this.cacheByTeam.get(teamName); + if (cached && !this.dirtyTeams.has(teamName)) { + return { + teamName, + feedRevision: cached.feedRevision, + messages: cached.messages, + }; + } + + const config = await this.deps.getConfig(teamName); + if (!config) { + const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] }; + this.cacheByTeam.set(teamName, emptyEntry); + this.dirtyTeams.delete(teamName); + return { teamName, ...emptyEntry }; + } + + const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ + this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]), + this.deps.getLeadSessionMessages(config).catch(() => [] as InboxMessage[]), + this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), + ]); + + let messages = [...inboxMessages, ...leadTexts, ...sentMessages]; + messages = dedupeLeadProcessCopies(messages, leadTexts); + messages = ensureEffectiveMessageIds(messages); + messages = dedupeByMessageId(messages); + messages = linkPassiveUserReplySummaries(messages); + attachLeadSessionIds(config, messages); + annotateSlashCommandResponses(messages); + + messages.sort((left, right) => { + const diff = Date.parse(right.timestamp) - Date.parse(left.timestamp); + if (diff !== 0) return diff; + return requireCanonicalMessageId(left).localeCompare(requireCanonicalMessageId(right)); + }); + + const feedRevision = toFeedRevision(messages); + const nextEntry = + cached && cached.feedRevision === feedRevision + ? cached + : { + feedRevision, + messages, + }; + + this.cacheByTeam.set(teamName, nextEntry); + this.dirtyTeams.delete(teamName); + return { + teamName, + feedRevision: nextEntry.feedRevision, + messages: nextEntry.messages, + }; + } +} diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index 329e369f..ca798cc3 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -2,7 +2,12 @@ * Shared request/response types for the team-data-worker thread. */ -import type { MemberLogSummary, TeamData } from '@shared/types'; +import type { + MemberLogSummary, + MessagesPage, + TeamMemberActivityMeta, + TeamViewSnapshot, +} from '@shared/types'; // ── Payloads ── @@ -10,6 +15,18 @@ export interface GetTeamDataPayload { teamName: string; } +export interface GetMessagesPagePayload { + teamName: string; + options: { + cursor?: string | null; + limit: number; + }; +} + +export interface GetMemberActivityMetaPayload { + teamName: string; +} + export interface FindLogsForTaskPayload { teamName: string; taskId: string; @@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload { export type TeamDataWorkerRequest = | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } + | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } + | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }; export type TeamDataWorkerResponse = - | { id: string; ok: true; result: TeamData | MemberLogSummary[] } + | { + id: string; + ok: true; + result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[]; + } | { id: string; ok: false; error: string }; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 99d1a0dd..871eba11 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { respond({ id: msg.id, ok: true, result }); break; } + case 'getMessagesPage': { + const result = await teamDataService.getMessagesPage( + msg.payload.teamName, + msg.payload.options + ); + respond({ id: msg.id, ok: true, result }); + break; + } + case 'getMemberActivityMeta': { + const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName); + respond({ id: msg.id, ok: true, result }); + break; + } case 'findLogsForTask': { const { teamName, taskId, options } = msg.payload; const intervalsKey = options?.intervals diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index b584e5b1..008e9b7c 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -234,6 +234,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage'; /** Paginated messages for timeline/messages panel */ export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage'; +/** Lightweight message-derived member activity facts */ +export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta'; + /** Request review for task */ export const TEAM_REQUEST_REVIEW = 'team:requestReview'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 015f2c8d..84a7121f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -126,6 +126,7 @@ import { TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_MEMBER_ACTIVITY_META, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, @@ -263,6 +264,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamMemberActivityMeta, MemberSpawnStatusesSnapshot, MessagesPage, NotificationTrigger, @@ -292,10 +294,10 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, + TeamViewSnapshot, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, @@ -822,7 +824,7 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(TEAM_LIST); }, getData: async (teamName: string) => { - return invokeIpcWithResult(TEAM_GET_DATA, teamName); + return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, getTaskChangePresence: async (teamName: string) => { return invokeIpcWithResult>( @@ -883,10 +885,13 @@ const electronAPI: ElectronAPI = { }, getMessagesPage: async ( teamName: string, - options?: { beforeTimestamp?: string; limit?: number } + options?: { cursor?: string | null; limit?: number } ) => { return invokeIpcWithResult(TEAM_GET_MESSAGES_PAGE, teamName, options); }, + getMemberActivityMeta: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_MEMBER_ACTIVITY_META, teamName); + }, createTask: async (teamName: string, request: CreateTaskRequest) => { return invokeIpcWithResult(TEAM_CREATE_TASK, teamName, request); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 5f2df4aa..d5afc4a0 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -54,25 +54,26 @@ import type { SshLastConnection, SubagentDetail, TeamChangeEvent, + UpdateSchedulePatch, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamsAPI, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, TmuxAPI, TmuxStatus, TriggerTestResult, UpdateKanbanPatch, UpdaterAPI, - UpdateSchedulePatch, WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; @@ -677,7 +678,7 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] teams API is not available in browser mode'); return []; }, - getData: async (_teamName: string): Promise => { + getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, getTaskChangePresence: async (): Promise< @@ -740,7 +741,15 @@ export class HttpAPIClient implements ElectronAPI { throw new Error('Team messaging is not available in browser mode'); }, getMessagesPage: async () => { - return { messages: [], nextCursor: null, hasMore: false }; + return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' }; + }, + getMemberActivityMeta: async (_teamName: string): Promise => { + return { + teamName: _teamName, + computedAt: new Date(0).toISOString(), + members: {}, + feedRevision: 'empty', + }; }, createTask: async (_teamName: string, _request: CreateTaskRequest): Promise => { throw new Error('Team task creation is not available in browser mode'); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 7cd94409..4baeff2b 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; @@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. // Get team members for @mention highlighting and team names for @team linkification const { members, teams } = useStore( useShallow((s) => ({ - members: s.selectedTeamData?.members, + members: selectResolvedMembersForTeamName(s, s.selectedTeamName), teams: s.teams, })) ); diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 81ab8195..bd5bc904 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -10,6 +10,7 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC = ({ const { isLight } = useTheme(); // Get team members for @mention highlighting - const members = useStore(useShallow((s) => s.selectedTeamData?.members)); + const members = useStore( + useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) + ); const memberColorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), [members] diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index 4e5501f4..97471ad5 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -70,14 +71,16 @@ export const TaskTooltip = ({ children, side = 'top', }: TaskTooltipProps): React.JSX.Element => { - const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore( - useShallow((s) => ({ - selectedTeamName: s.selectedTeamName, - selectedTeamData: s.selectedTeamData, - globalTasks: s.globalTasks, - teamByName: s.teamByName, - })) - ); + const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } = + useStore( + useShallow((s) => ({ + selectedTeamName: s.selectedTeamName, + selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), + globalTasks: s.globalTasks, + teamByName: s.teamByName, + })) + ); const task = useMemo(() => { if (teamName && selectedTeamName === teamName) { @@ -105,13 +108,13 @@ export const TaskTooltip = ({ const members = useMemo(() => { if (teamName && selectedTeamName === teamName) { - return selectedTeamData?.members ?? []; + return selectedTeamMembers; } if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) { - return selectedTeamData?.members ?? []; + return selectedTeamMembers; } return []; - }, [selectedTeamData, selectedTeamName, teamName, task]); + }, [selectedTeamMembers, selectedTeamName, teamName, task]); const colorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index c53f03c4..9d6d0f71 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -23,6 +23,9 @@ import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, isTeamProvisioningActive, + selectResolvedMemberForTeamName, + selectResolvedMembersForTeamName, + selectTeamMemberSnapshotsForName, } from '@renderer/store/slices/teamSlice'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; @@ -740,16 +743,18 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( }: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null { const { leadActivity, + liveMember, progress, - members: launchMembers, + launchMembers, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry, } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], + liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null, progress: getCurrentProvisioningProgressForTeam(s, teamName), - members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], + launchMembers: selectTeamMemberSnapshotsForName(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, @@ -772,7 +777,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( (null); const wasProvisioningRef = useRef(false); - const pendingReplyRefreshTimerRef = useRef(null); const handleOpenGraphTab = useCallback(() => { const state = useStore.getState(); const displayName = state.teamByName[teamName]?.displayName ?? teamName; @@ -898,7 +902,7 @@ export const TeamDetailView = ({ initialActivityFilter, } = (e as CustomEvent).detail ?? {}; if (tn !== teamName || !data) return; - const member = data.members.find((m: { name: string }) => m.name === memberName); + const member = members.find((m: { name: string }) => m.name === memberName); if (member) { setSelectedMember(member); setSelectedMemberView({ @@ -1059,6 +1063,7 @@ export const TeamDetailView = ({ const { data, + members, loading, error, projects, @@ -1088,6 +1093,7 @@ export const TeamDetailView = ({ clearProvisioningError, isTeamProvisioning, refreshTeamData, + syncTeamPendingReplyRefresh, kanbanFilterQuery, clearKanbanFilter, softDeleteTask, @@ -1133,9 +1139,11 @@ export const TeamDetailView = ({ clearProvisioningError: s.clearProvisioningError, isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, error: s.selectedTeamName === teamName ? s.selectedTeamError : null, refreshTeamData: s.refreshTeamData, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, softDeleteTask: s.softDeleteTask, @@ -1169,13 +1177,12 @@ export const TeamDetailView = ({ diagnostic.count += 1; const commitMs = performance.now() - renderStartedAtRef.current; - const messagesCount = data?.messages.length ?? 0; const tasksCount = data?.tasks.length ?? 0; - const membersCount = data?.members.length ?? 0; + const membersCount = members.length; const processesCount = data?.processes.length ?? 0; const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS; const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT; - const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80; + const shouldWarnLarge = tasksCount >= 80; if ( (shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) && @@ -1187,7 +1194,7 @@ export const TeamDetailView = ({ now - diagnostic.windowStartedAt } activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${ loading ? 'yes' : 'no' - } messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` + } tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` ); } }); @@ -1307,30 +1314,20 @@ export const TeamDetailView = ({ ); // Keep team message state fresh while we are explicitly waiting for a reply. - // Use a delayed single-shot refresh instead of a tight polling loop so we - // don't keep rewriting the whole team snapshot every 2 seconds. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. useEffect(() => { - if (pendingReplyRefreshTimerRef.current != null) { - window.clearTimeout(pendingReplyRefreshTimerRef.current); - pendingReplyRefreshTimerRef.current = null; - } - - if (!isThisTabActive) return; - if (!data?.isAlive) return; - if (Object.keys(pendingRepliesByMember).length === 0) return; - - pendingReplyRefreshTimerRef.current = window.setTimeout(() => { - pendingReplyRefreshTimerRef.current = null; - void refreshTeamData(teamName, { withDedup: true }); - }, TEAM_PENDING_REPLY_REFRESH_DELAY_MS); + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS + ); return () => { - if (pendingReplyRefreshTimerRef.current != null) { - window.clearTimeout(pendingReplyRefreshTimerRef.current); - pendingReplyRefreshTimerRef.current = null; - } + syncTeamPendingReplyRefresh(teamName, false); }; - }, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]); + }, [data?.isAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]); useEffect(() => { if (!projectId) return; @@ -1364,9 +1361,9 @@ export const TeamDetailView = ({ // Live git branch tracking for the lead project and member worktrees const teamProjectPath = data?.config.projectPath?.trim() ?? null; const leadProjectPath = useMemo(() => { - const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim(); + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [data?.members, teamProjectPath]); + }, [members, teamProjectPath]); const branchSyncPaths = useMemo(() => { const uniquePaths = new Map(); const addPath = (candidate: string | null | undefined): void => { @@ -1378,12 +1375,12 @@ export const TeamDetailView = ({ }; addPath(leadProjectPath); - for (const member of data?.members ?? []) { + for (const member of members) { addPath(member.cwd); } return Array.from(uniquePaths.values()); - }, [data?.members, leadProjectPath]); + }, [members, leadProjectPath]); useBranchSync(branchSyncPaths, { live: true }); const trackedBranches = useStore( useShallow((s) => @@ -1401,7 +1398,7 @@ export const TeamDetailView = ({ const membersWithLiveBranches = useMemo(() => { if (!data) return []; - return data.members.map((member) => { + return members.map((member) => { const memberPath = member.cwd?.trim(); const nextGitBranch = memberPath && !isLeadMember(member) && leadBranch !== null @@ -1423,7 +1420,7 @@ export const TeamDetailView = ({ } return nextMember; }); - }, [data, leadBranch, trackedBranches]); + }, [leadBranch, members, trackedBranches]); // Filter sessions to team-only using sessionHistory + leadSessionId const teamSessionIds = useMemo(() => { @@ -1787,7 +1784,6 @@ export const TeamDetailView = ({ mountPoint: messagesPanelMountPoint, members: activeMembers, tasks: data?.tasks ?? [], - messages: data?.messages ?? [], isTeamAlive: data?.isAlive, timeWindow, teamSessionIds, @@ -1805,7 +1801,6 @@ export const TeamDetailView = ({ activeMembers, data?.config.leadSessionId, data?.isAlive, - data?.messages, data?.tasks, handleCreateTaskFromMessage, handleOpenTask, @@ -2482,7 +2477,7 @@ export const TeamDetailView = ({ open={requestChangesTaskId !== null} teamName={teamName} taskId={requestChangesTaskId} - members={data?.members ?? []} + members={members} onCancel={() => setRequestChangesTaskId(null)} onSubmit={(comment, taskRefs) => { if (!requestChangesTaskId) { @@ -2509,7 +2504,6 @@ export const TeamDetailView = ({ teamName={teamName} members={membersWithLiveBranches} tasks={data.tasks} - messages={data.messages} initialTab={selectedMemberView?.initialTab} initialActivityFilter={selectedMemberView?.initialActivityFilter} isTeamAlive={data.isAlive} @@ -2858,7 +2852,7 @@ export const TeamDetailView = ({ if (task) setSelectedTask(task); }} onOpenMemberProfile={(memberName, options) => { - const member = data.members.find((m) => m.name === memberName); + const member = members.find((m) => m.name === memberName); if (member) { setSelectedMember(member); setSelectedMemberView({ diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index d9ea2670..f9d5e556 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -56,6 +56,7 @@ import { import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; import type { + TeamMemberSnapshot, ResolvedTeamMember, TeamCreateRequest, TeamLaunchRequest, @@ -94,6 +95,17 @@ function folderName(fullPath: string): string { return getBaseName(fullPath) || fullPath; } +function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] { + return members.map((member) => { + return { + ...member, + status: member.currentTaskId ? 'active' : 'idle', + messageCount: 0, + lastActiveAt: null, + }; + }); +} + function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element { const teamColorMap = buildMemberColorMap(members); return ( @@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => { try { const data = await api.teams.getData(teamName); setLaunchDialogTeamName(teamName); - setLaunchDialogMembers(data.members ?? []); + setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? [])); setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath); setLaunchDialogOpen(true); } catch (err) { diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 5666e93d..337792af 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; @@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => { teams, selectedTeamName, selectedTeamData, + selectedTeamMembers, } = useStore( useShallow((s) => ({ pendingApprovals: s.pendingApprovals, @@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => { teams: s.teams, selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), })) ); const { isLight } = useTheme(); @@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => { // Resolve teammate color for MemberBadge (when source !== 'lead') const sourceColor = useMemo(() => { if (!current || current.source === 'lead') return undefined; - const member = selectedTeamData?.members?.find((m) => m.name === current.source); + const member = selectedTeamMembers.find((m) => m.name === current.source); return member?.color; - }, [current, selectedTeamData?.members]); + }, [current, selectedTeamMembers]); if (!current) return null; diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx index 2445a6c6..c24bf8c7 100644 --- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; import { ExternalLink } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -24,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { closeGlobalTaskDetail, selectedTeamName, selectedTeamData, + selectedTeamMembers, selectedTeamLoading, selectedTeamError, selectTeam, @@ -36,6 +38,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { closeGlobalTaskDetail: s.closeGlobalTaskDetail, selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), selectedTeamLoading: s.selectedTeamLoading, selectedTeamError: s.selectedTeamError, selectTeam: s.selectTeam, @@ -94,8 +97,8 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { }, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]); const activeMembers = useMemo( - () => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []), - [isFullTeamLoaded, selectedTeamData] + () => (isFullTeamLoaded ? selectedTeamMembers.filter((m) => !m.removedAt) : []), + [isFullTeamLoaded, selectedTeamMembers] ); const handleOpenTeam = useCallback((): void => { diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index dcca38bb..d3016479 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { + isTeamProvisioningActive, + selectResolvedMembersForTeamName, +} from '@renderer/store/slices/teamSlice'; import { isGeminiUiFrozen, normalizeCreateLaunchProviderForUi, @@ -281,7 +284,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [prepareWarnings, setPrepareWarnings] = useState([]); const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); - const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); + const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)); const previousLaunchParams = useStore((s) => effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined ); diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 258a2357..495da40a 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,9 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { - buildGraphMemberNodeIdForMember, - buildInlineActivityEntries, -} from '@features/agent-graph/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; @@ -20,7 +16,6 @@ import { MemberStatsTab } from './MemberStatsTab'; import { MemberTasksTab } from './MemberTasksTab'; import type { - InboxMessage, LeadActivityState, MemberSpawnStatusEntry, ResolvedTeamMember, @@ -33,7 +28,6 @@ interface MemberDetailDialogProps { teamName: string; members: ResolvedTeamMember[]; tasks: TeamTaskWithKanban[]; - messages: InboxMessage[]; initialTab?: MemberDetailTab; initialActivityFilter?: MemberActivityFilter; isTeamAlive?: boolean; @@ -57,7 +51,6 @@ export const MemberDetailDialog = ({ teamName, members, tasks, - messages, initialTab = 'tasks', initialActivityFilter = 'all', isTeamAlive, @@ -78,34 +71,7 @@ export const MemberDetailDialog = ({ () => (member ? tasks.filter((t) => t.owner === member.name) : []), [tasks, member] ); - - const seedMemberMessages = useMemo( - () => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []), - [messages, member] - ); - const memberMessages = seedMemberMessages; - const memberActivityCount = useMemo(() => { - if (!member) { - return 0; - } - const leadId = `lead:${teamName}`; - const leadName = - members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; - const ownerNodeId = - member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member); - const entries = buildInlineActivityEntries({ - data: { - members, - tasks, - messages: memberMessages, - }, - teamName, - leadId, - leadName, - ownerNodeIds: new Set([leadId, ownerNodeId]), - }); - return (entries.get(ownerNodeId) ?? []).length; - }, [member, memberMessages, members, tasks, teamName]); + const memberActivityCount = member?.messageCount ?? 0; const inProgressTasks = useMemo( () => memberTasks.filter((t) => t.status === 'in_progress').length, @@ -206,7 +172,6 @@ export const MemberDetailDialog = ({ { - const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName); - const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null; - return { - member: selectedTeamData?.members.find((m) => m.name === name) ?? null, - members: selectedTeamData?.members ?? [], - isTeamAlive: selectedTeamData?.isAlive, + } = useStore( + useShallow((s) => ({ + member: effectiveTeamName + ? selectResolvedMemberForTeamName(s, effectiveTeamName, name) + : null, + teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [], + tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [], + isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined, progress: effectiveTeamName ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName) : null, @@ -80,21 +89,16 @@ export const MemberHoverCard = ({ ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] : undefined, leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined, - }; - }); - const openMemberProfile = useStore((s) => s.openMemberProfile); - const tasks = useStore((s) => - effectiveTeamName && s.selectedTeamName === effectiveTeamName - ? s.selectedTeamData?.tasks - : undefined + })) ); + const openMemberProfile = useStore((s) => s.openMemberProfile); if (!member) { return <>{children}; } const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({ - members, + members: teamMembers, memberSpawnStatuses, memberSpawnSnapshot, }); @@ -117,10 +121,9 @@ export const MemberHoverCard = ({ const presenceLabel = launchPresentation.presenceLabel; const dotClass = launchPresentation.dotClass; const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const currentTask: TeamTaskWithKanban | null = - member.currentTaskId && tasks - ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) - : null; + const currentTask: TeamTaskWithKanban | null = member.currentTaskId + ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) + : null; const reviewTask: TeamTaskWithKanban | null = tasks ? (tasks.find( (task) => diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 3fc87f1c..75984c1a 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; -import { api } from '@renderer/api'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -10,17 +9,18 @@ import { import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { Button } from '@renderer/components/ui/button'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; -import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { useStore } from '@renderer/store'; +import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { useShallow } from 'zustand/react/shallow'; import type { MemberActivityFilter } from './memberDetailTypes'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; -import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface MemberMessagesTabProps { - messages: InboxMessage[]; teamName: string; memberName: string; members: ResolvedTeamMember[]; @@ -31,7 +31,6 @@ interface MemberMessagesTabProps { } const MAX_MESSAGES = 100; -const MEMBER_MESSAGES_PAGE_SIZE = 50; const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'messages', label: 'Messages' }, @@ -39,7 +38,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] ]; export const MemberMessagesTab = ({ - messages, teamName, memberName, members, @@ -48,12 +46,15 @@ export const MemberMessagesTab = ({ onCreateTask, onTaskClick, }: MemberMessagesTabProps): React.JSX.Element => { - const [pagedMessages, setPagedMessages] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(false); const [activityFilter, setActivityFilter] = useState(initialFilter); const [expandedItem, setExpandedItem] = useState(null); + const { messages, messagesState, loadOlderTeamMessages } = useStore( + useShallow((s) => ({ + messages: selectMemberMessagesForTeamMember(s, teamName, memberName), + messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, + loadOlderTeamMessages: s.loadOlderTeamMessages, + })) + ); const { readSet } = useTeamMessagesRead(teamName); const leadId = `lead:${teamName}`; const leadName = useMemo( @@ -69,75 +70,24 @@ export const MemberMessagesTab = ({ setActivityFilter(initialFilter); }, [initialFilter, memberName, teamName]); - useEffect(() => { - let cancelled = false; - setPagedMessages([]); - setNextCursor(null); - setHasMore(false); - setLoading(true); - - void (async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { - limit: MEMBER_MESSAGES_PAGE_SIZE, - }); - if (cancelled) return; - const memberPageMessages = page.messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - setPagedMessages(memberPageMessages); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - if (!cancelled) { - setPagedMessages([]); - setNextCursor(null); - setHasMore(false); - } - } finally { - if (!cancelled) setLoading(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [teamName, memberName]); - const loadOlderMessages = useCallback(async () => { - if (!nextCursor || loading) return; - setLoading(true); - try { - const page = await api.teams.getMessagesPage(teamName, { - beforeTimestamp: nextCursor, - limit: MEMBER_MESSAGES_PAGE_SIZE, - }); - const memberPageMessages = page.messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages)); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // best-effort - } finally { - setLoading(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [teamName, memberName, nextCursor, loading]); + await loadOlderTeamMessages(teamName); + }, [loadOlderTeamMessages, messagesState, teamName]); - const effectiveMessages = useMemo( - () => mergeTeamMessages(messages, pagedMessages), - [messages, pagedMessages] - ); + const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const hasMore = messagesState?.hasMore ?? false; const filteredMessages = useMemo( () => - filterTeamMessages(effectiveMessages, { + filterTeamMessages(messages, { timeWindow: null, filter: { from: new Set(), to: new Set(), showNoise: true }, searchQuery: '', }), - [effectiveMessages] + [messages] ); const activityEntries = useMemo(() => { diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 5638726c..791a8513 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,7 +1,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Sheet, type SheetRef } from 'react-modal-sheet'; -import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -9,7 +8,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; -import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { selectTeamMessages } from '@renderer/store/slices/teamSlice'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; @@ -70,8 +69,6 @@ interface MessagesPanelProps { members: ResolvedTeamMember[]; /** All team tasks. */ tasks: TeamTaskWithKanban[]; - /** All raw messages from team data. */ - messages: InboxMessage[]; /** Whether the team is alive. */ isTeamAlive?: boolean; /** Live lead activity status for the current team. */ @@ -109,7 +106,6 @@ export const MessagesPanel = memo(function MessagesPanel({ mountPoint, members, tasks, - messages, isTeamAlive, leadActivity, leadContextUpdatedAt, @@ -133,6 +129,9 @@ export const MessagesPanel = memo(function MessagesPanel({ lastSendMessageResult, teams, openTeamTab, + messages, + messagesState, + loadOlderTeamMessages, } = useStore( useShallow((s) => ({ sendTeamMessage: s.sendTeamMessage, @@ -142,79 +141,23 @@ export const MessagesPanel = memo(function MessagesPanel({ lastSendMessageResult: s.lastSendMessageResult, teams: s.teams, openTeamTab: s.openTeamTab, + messages: selectTeamMessages(s, teamName), + messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, + loadOlderTeamMessages: s.loadOlderTeamMessages, })) ); - // ── Paginated message fetching ── - // Messages are now fetched via getMessagesPage API instead of coming - // from getTeamData. The `messages` prop is used as initial seed if non-empty. - const PAGE_SIZE = 50; - const [fetchedMessages, setFetchedMessages] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [messagesLoading, setMessagesLoading] = useState(false); - const fetchIdRef = useRef(0); - - // Initial fetch on mount or team change - useEffect(() => { - const id = ++fetchIdRef.current; - setMessagesLoading(true); - void (async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); - if (fetchIdRef.current !== id) return; - setFetchedMessages(page.messages); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // Fallback: use prop messages if API fails - if (fetchIdRef.current === id && messages.length > 0) { - setFetchedMessages(messages); - } - } finally { - if (fetchIdRef.current === id) setMessagesLoading(false); - } - })(); - }, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change - - // Auto-refresh: poll for NEW messages only (prepend to head). - // Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow. - useEffect(() => { - if (!isTeamAlive && leadActivity !== 'active') return; - const interval = setInterval(async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); - setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); - } catch { - // best-effort - } - }, 5000); - return () => clearInterval(interval); - }, [teamName, isTeamAlive, leadActivity]); - const loadOlderMessages = useCallback(async () => { - if (!nextCursor || messagesLoading) return; - setMessagesLoading(true); - try { - const page = await api.teams.getMessagesPage(teamName, { - beforeTimestamp: nextCursor, - limit: PAGE_SIZE, - }); - setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // best-effort - } finally { - setMessagesLoading(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [teamName, nextCursor, messagesLoading]); + await loadOlderTeamMessages(teamName); + }, [loadOlderTeamMessages, messagesState, teamName]); - // Use fetched messages, fall back to prop messages during initial load - const effectiveMessages = useMemo(() => { - if (fetchedMessages.length === 0) return messages; - return mergeTeamMessages(fetchedMessages, messages); - }, [fetchedMessages, messages]); + const messagesLoading = + (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const hasMore = messagesState?.hasMore ?? false; + const effectiveMessages = messages; const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index bb5eb924..92d7df18 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -3,10 +3,14 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - ResolvedTeamMember, TeamProvisioningProgress, } from '@shared/types'; +interface LaunchJoinMemberLike { + name: string; + removedAt?: number; +} + /** Display steps for the provisioning stepper (0-indexed). */ export const DISPLAY_STEPS = [ { key: 'starting', label: 'Starting' }, @@ -52,7 +56,7 @@ export function getLaunchJoinMilestonesFromMembers({ memberSpawnStatuses, memberSpawnSnapshot, }: { - members: readonly ResolvedTeamMember[]; + members: readonly LaunchJoinMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshot?: Pick; }): LaunchJoinMilestones { diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts index 3eac3f72..0cdf27ec 100644 --- a/src/renderer/components/team/useTeamProvisioningPresentation.ts +++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, - selectTeamDataForName, + selectTeamMemberSnapshotsForName, } from '@renderer/store/slices/teamSlice'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { useShallow } from 'zustand/react/shallow'; @@ -20,7 +20,7 @@ export function useTeamProvisioningPresentation(teamName: string): { useShallow((s) => ({ progress: getCurrentProvisioningProgressForTeam(s, teamName), cancelProvisioning: s.cancelProvisioning, - teamMembers: selectTeamDataForName(s, teamName)?.members ?? [], + teamMembers: selectTeamMemberSnapshotsForName(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], })) diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index 0a6f3218..892942a1 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -1,6 +1,10 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { useShallow } from 'zustand/react/shallow'; @@ -57,11 +61,13 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean { } export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { - const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore( + const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore( useShallow((s) => ({ globalTasks: s.globalTasks, - selectedTeamName: s.selectedTeamName, - selectedTeamData: s.selectedTeamData, + currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null, + currentTeamMembers: currentTeamName + ? selectResolvedMembersForTeamName(s, currentTeamName) + : [], teamByName: s.teamByName, })) ); @@ -73,14 +79,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge if (currentTeamName) { const currentTeamSummary = teamByName[currentTeamName]; const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName; - const currentTeamMembers = - selectedTeamName === currentTeamName && selectedTeamData - ? selectedTeamData.members - : (currentTeamSummary?.members ?? []); const currentTeamTasks = - selectedTeamName === currentTeamName && selectedTeamData - ? selectedTeamData.tasks - : globalTasks.filter((task) => task.teamName === currentTeamName); + currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName); + const currentTeamMemberColors = + currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []); for (const task of currentTeamTasks) { if (!isVisibleTask(task)) continue; @@ -91,7 +93,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge teamDisplayName: currentTeamDisplayName, teamColor: currentTeamSummary?.color, isCurrentTeamTask: true, - ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color, + ownerColor: currentTeamMemberColors.find((member) => member.name === task.owner)?.color, }); } } @@ -123,7 +125,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge }); return tasks.map(buildTaskSuggestion); - }, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]); + }, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]); return { suggestions }; } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 82c5d7e6..a2ffff64 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -35,7 +35,9 @@ import { createTabSlice } from './slices/tabSlice'; import { createTabUISlice } from './slices/tabUISlice'; import { createTeamSlice, + getActiveTeamPendingReplyWaits, getLastResolvedTeamDataRefreshAt, + hasActiveTeamPendingReplyWait, isTeamDataRefreshPending, selectTeamDataForName, } from './slices/teamSlice'; @@ -65,6 +67,7 @@ const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8; const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000; const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000; const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000; +const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000; const CURRENT_APP_VERSION = typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0'; const logger = createLogger('Store:index'); @@ -237,10 +240,12 @@ export function initializeNotificationListeners(): () => void { const teamLastRelevantActivityAt = new Map(); const teamLastIdleWatchdogRefreshAt = new Map(); let teamRefreshTimers = new Map>(); + let teamMessageRefreshTimers = new Map>(); let teamPresenceRefreshTimers = new Map>(); let memberSpawnRefreshTimers = new Map>(); let toolActivityTimers = new Map>(); let inProgressChangePresencePollInFlight = false; + let teamMessageFallbackPollInFlight = false; const inProgressChangePresenceCursorByTeam = new Map(); let teamListRefreshTimer: ReturnType | null = null; @@ -252,6 +257,23 @@ export function initializeNotificationListeners(): () => void { const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; + const refreshTrackedTeamMessages = async (teamName: string): Promise => { + if (!teamName || !shouldRefreshTeamMessages(teamName)) { + return; + } + + const current = useStore.getState(); + try { + const headResult = await current.refreshTeamMessagesHead(teamName); + const latest = useStore.getState(); + const meta = latest.memberActivityMetaByTeam[teamName]; + if (headResult.feedChanged || !meta || meta.feedRevision !== headResult.feedRevision) { + await latest.refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh for message-driven events and fallback polling only. + } + }; const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; @@ -265,6 +287,19 @@ export function initializeNotificationListeners(): () => void { }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); memberSpawnRefreshTimers.set(teamName, timer); }; + const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => { + if (!teamName || !shouldRefreshTeamMessages(teamName)) { + return; + } + if (teamMessageRefreshTimers.has(teamName)) { + return; + } + const timer = setTimeout(() => { + teamMessageRefreshTimers.delete(teamName); + void refreshTrackedTeamMessages(teamName); + }, TEAM_REFRESH_THROTTLE_MS); + teamMessageRefreshTimers.set(teamName, timer); + }; const buildToolActivityTimerKey = ( teamName: string, memberName: string, @@ -587,6 +622,18 @@ export function initializeNotificationListeners(): () => void { return getVisibleTeamNamesInAnyPane().has(teamName); }; + const shouldRefreshTeamMessages = (teamName: string): boolean => { + return isTeamVisibleInAnyPane(teamName) || hasActiveTeamPendingReplyWait(teamName); + }; + + const getTrackedTeamMessageRefreshTeams = (): Set => { + const tracked = getVisibleTeamNamesInAnyPane(); + for (const teamName of getActiveTeamPendingReplyWaits()) { + tracked.add(teamName); + } + return tracked; + }; + const getTrackedChangePresenceTeams = (): Set => { const state = useStore.getState(); const tracked = new Set(); @@ -627,6 +674,26 @@ export function initializeNotificationListeners(): () => void { return activeTab.teamName; }; + const pollTrackedTeamMessageFallback = async (): Promise => { + if (teamMessageFallbackPollInFlight) { + return; + } + + const teamNames = getTrackedTeamMessageRefreshTeams(); + if (teamNames.size === 0) { + return; + } + + teamMessageFallbackPollInFlight = true; + try { + await Promise.allSettled( + Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName)) + ); + } finally { + teamMessageFallbackPollInFlight = false; + } + }; + const pollFocusedVisibleTeamIdleWatchdog = async (): Promise => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { return; @@ -863,11 +930,18 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { clearInterval(teamIdleWatchdogTimer); }); + const teamMessageFallbackPollTimer = setInterval(() => { + void pollTrackedTeamMessageFallback(); + }, TEAM_MESSAGE_FALLBACK_POLL_MS); + cleanupFns.push(() => { + clearInterval(teamMessageFallbackPollTimer); + }); if (api.teams?.onTeamChange) { const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => { - const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName); - noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam); + const messageRefreshRelevant = + Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName); + noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant); const isIgnoredRuntimeRun = (() => { if (!event.runId) return false; @@ -924,24 +998,26 @@ export function initializeNotificationListeners(): () => void { }, }; - const cachedTeamData = prev.teamDataCacheByName[event.teamName]; - if (cachedTeamData) { + const baseTeamData = + prev.teamDataCacheByName[event.teamName] ?? + (prev.selectedTeamName === event.teamName ? prev.selectedTeamData : null); + const nextTeamData = + baseTeamData && baseTeamData.isAlive !== (nextActivity !== 'offline') + ? { + ...baseTeamData, + isAlive: nextActivity !== 'offline', + } + : baseTeamData; + + if (nextTeamData) { nextState.teamDataCacheByName = { ...prev.teamDataCacheByName, - [event.teamName]: { - ...cachedTeamData, - isAlive: nextActivity !== 'offline', - }, + [event.teamName]: nextTeamData, }; } - // Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive, - // which isn't refreshed for lead-activity events. - if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) { - nextState.selectedTeamData = { - ...prev.selectedTeamData, - isAlive: nextActivity !== 'offline', - }; + if (prev.selectedTeamName === event.teamName && nextTeamData) { + nextState.selectedTeamData = nextTeamData; } // Clear context data when lead goes offline @@ -1122,29 +1198,19 @@ export function initializeNotificationListeners(): () => void { return; } - if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') { - scheduleMemberSpawnStatusesRefresh(event.teamName); + if (event.type === 'inbox') { + scheduleTrackedTeamMessageRefresh(event.teamName); + return; } - // Live lead-message events: only refresh the visible team detail, not team/task lists. - // This keeps the refresh lightweight and prevents one noisy team from starving another. + // Live lead-message events refresh only the tracked message feed surface + // (visible team or local pending-reply wait), not the structural snapshot. if (event.type === 'lead-message') { if (isStaleRuntimeEvent) { return; } seedCurrentRunIdIfMissing(); - if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { - return; - } - if (teamRefreshTimers.has(event.teamName)) { - return; - } - const timer = setTimeout(() => { - teamRefreshTimers.delete(event.teamName); - const current = useStore.getState(); - void current.refreshTeamData(event.teamName, { withDedup: true }); - }, TEAM_REFRESH_THROTTLE_MS); - teamRefreshTimers.set(event.teamName, timer); + scheduleTrackedTeamMessageRefresh(event.teamName); return; } @@ -1205,6 +1271,8 @@ export function initializeNotificationListeners(): () => void { cleanup(); for (const t of teamRefreshTimers.values()) clearTimeout(t); teamRefreshTimers = new Map(); + for (const t of teamMessageRefreshTimers.values()) clearTimeout(t); + teamMessageRefreshTimers = new Map(); for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t); teamPresenceRefreshTimers = new Map(); for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index e1d10700..0e8a4475 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -7,7 +7,9 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { createLogger } from '@shared/utils/logger'; @@ -33,21 +35,25 @@ import type { KanbanColumnId, LeadActivityState, LeadContextUsage, + MemberActivityMetaEntry, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, PersistedTeamLaunchSummary, + ResolvedTeamMember, SendMessageRequest, SendMessageResult, TaskChangePresenceState, TaskComment, TeamCreateRequest, - TeamData, TeamLaunchRequest, + TeamMemberActivityMeta, + TeamMemberSnapshot, TeamProviderId, TeamProvisioningProgress, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, ToolApprovalRequest, ToolApprovalSettings, UpdateKanbanPatch, @@ -69,9 +75,20 @@ const TEAM_REFRESH_BURST_WINDOW_MS = 4_000; const TEAM_REFRESH_BURST_WARN_COUNT = 5; const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000; const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000; -const inFlightTeamDataRequests = new Map>(); +const inFlightTeamDataRequests = new Map>(); const inFlightRefreshTeamDataCalls = new Set(); const pendingFreshTeamDataRefreshes = new Set(); +const inFlightTeamMessagesHeadRequests = new Map>(); +const inFlightTeamMessagesOlderRequests = new Map>(); +const queuedTeamMessagesHeadRefreshesAfterOlder = new Map< + string, + Promise +>(); +const pendingFreshTeamMessagesHeadRefreshes = new Set(); +const inFlightTeamMemberActivityMetaRequests = new Map>(); +const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); +const pendingTeamPendingReplyRefreshTimers = new Map>(); +const activeTeamPendingReplyWaits = new Set(); const lastResolvedTeamDataRefreshAtByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; @@ -99,14 +116,37 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und return lastResolvedTeamDataRefreshAtByTeam.get(teamName); } +export function hasActiveTeamPendingReplyWait(teamName: string): boolean { + return activeTeamPendingReplyWaits.has(teamName); +} + +export function getActiveTeamPendingReplyWaits(): Set { + return new Set(activeTeamPendingReplyWaits); +} + export function __resetTeamSliceModuleStateForTests(): void { inFlightTeamDataRequests.clear(); inFlightRefreshTeamDataCalls.clear(); pendingFreshTeamDataRefreshes.clear(); + inFlightTeamMessagesHeadRequests.clear(); + inFlightTeamMessagesOlderRequests.clear(); + queuedTeamMessagesHeadRefreshesAfterOlder.clear(); + pendingFreshTeamMessagesHeadRefreshes.clear(); + inFlightTeamMemberActivityMetaRequests.clear(); + pendingFreshTeamMemberActivityMetaRefreshes.clear(); + for (const timer of pendingTeamPendingReplyRefreshTimers.values()) { + clearTimeout(timer); + } + pendingTeamPendingReplyRefreshTimers.clear(); + activeTeamPendingReplyWaits.clear(); lastResolvedTeamDataRefreshAtByTeam.clear(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); + resolvedMembersSelectorCache.clear(); + resolvedMemberSelectorCache.clear(); + mergedMessagesSelectorCache.clear(); + memberMessagesSelectorCache.clear(); } function nowIso(): string { @@ -117,6 +157,66 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isPlainObject(value: unknown): value is Record { + if (value == null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function structurallySharePlainValue(previous: T, next: T): T { + if (Object.is(previous, next)) { + return previous; + } + + if (Array.isArray(previous) && Array.isArray(next)) { + let changed = previous.length !== next.length; + const result = next.map((nextItem, index) => { + const sharedItem = structurallySharePlainValue(previous[index], nextItem); + if (!Object.is(sharedItem, previous[index])) { + changed = true; + } + return sharedItem; + }); + return changed ? (result as T) : previous; + } + + if (isPlainObject(previous) && isPlainObject(next)) { + const previousRecord = previous as Record; + const nextRecord = next as Record; + const previousKeys = Object.keys(previousRecord); + const nextKeys = Object.keys(nextRecord); + let changed = previousKeys.length !== nextKeys.length; + const result: Record = {}; + + for (const key of nextKeys) { + if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { + changed = true; + } + const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); + if (!Object.is(sharedValue, previousRecord[key])) { + changed = true; + } + result[key] = sharedValue; + } + + return changed ? (result as T) : previous; + } + + return next; +} + +function structurallyShareTeamSnapshot( + previous: TeamViewSnapshot | null | undefined, + next: TeamViewSnapshot +): TeamViewSnapshot { + if (!previous) { + return next; + } + return structurallySharePlainValue(previous, next); +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -148,7 +248,7 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } -function fetchTeamDataDeduped(teamName: string): Promise { +function fetchTeamDataDeduped(teamName: string): Promise { const existing = inFlightTeamDataRequests.get(teamName); if (existing) { return existing; @@ -168,7 +268,7 @@ function fetchTeamDataDeduped(teamName: string): Promise { return request; } -function fetchTeamDataFresh(teamName: string): Promise { +function fetchTeamDataFresh(teamName: string): Promise { return withTimeout( unwrapIpc('team:getData', () => api.teams.getData(teamName)), TEAM_GET_DATA_TIMEOUT_MS, @@ -176,19 +276,17 @@ function fetchTeamDataFresh(teamName: string): Promise { ); } -function summarizeTeamDataCounts(data: TeamData | null | undefined): { - messages: number; +function summarizeTeamDataCounts(data: TeamViewSnapshot | null | undefined): { tasks: number; members: number; activeMembers: number; processes: number; } { if (!data) { - return { messages: 0, tasks: 0, members: 0, activeMembers: 0, processes: 0 }; + return { tasks: 0, members: 0, activeMembers: 0, processes: 0 }; } return { - messages: data.messages.length, tasks: data.tasks.length, members: data.members.length, activeMembers: data.members.filter((member) => !member.removedAt).length, @@ -196,20 +294,11 @@ function summarizeTeamDataCounts(data: TeamData | null | undefined): { }; } -function estimateTeamPayloadWeight(data: TeamData): { - messageTextChars: number; - messageAttachments: number; +function estimateTeamPayloadWeight(data: TeamViewSnapshot): { taskComments: number; taskHistoryEvents: number; taskDescriptionChars: number; } { - let messageTextChars = 0; - let messageAttachments = 0; - for (const message of data.messages) { - messageTextChars += (message.text?.length ?? 0) + (message.summary?.length ?? 0); - messageAttachments += message.attachments?.length ?? 0; - } - let taskComments = 0; let taskHistoryEvents = 0; let taskDescriptionChars = 0; @@ -220,8 +309,6 @@ function estimateTeamPayloadWeight(data: TeamData): { } return { - messageTextChars, - messageAttachments, taskComments, taskHistoryEvents, taskDescriptionChars, @@ -266,8 +353,8 @@ function maybeLogTeamDataPerf(params: { setMs: number; postMs: number; totalMs: number; - previousData: TeamData | null | undefined; - nextData: TeamData; + previousData: TeamViewSnapshot | null | undefined; + nextData: TeamViewSnapshot; deduped: boolean; reusedInFlightRequest: boolean; burstCount?: number; @@ -288,8 +375,7 @@ function maybeLogTeamDataPerf(params: { const nextCounts = summarizeTeamDataCounts(nextData); const previousCounts = summarizeTeamDataCounts(previousData); - const largePayload = - nextCounts.messages >= TEAM_DATA_LARGE_MESSAGES || nextCounts.tasks >= TEAM_DATA_LARGE_TASKS; + const largePayload = nextCounts.tasks >= TEAM_DATA_LARGE_TASKS; const slow = ipcMs >= TEAM_DATA_IPC_WARN_MS || setMs >= TEAM_DATA_SET_WARN_MS || @@ -305,15 +391,13 @@ function maybeLogTeamDataPerf(params: { 1 )}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${ reusedInFlightRequest ? 'yes' : 'no' - } burst=${burstCount ?? 1} counts=messages:${previousCounts.messages}->${nextCounts.messages},tasks:${ - previousCounts.tasks - }->${nextCounts.tasks},members:${previousCounts.members}->${nextCounts.members},activeMembers:${ + } burst=${burstCount ?? 1} counts=tasks:${previousCounts.tasks}->${nextCounts.tasks},members:${ + previousCounts.members + }->${nextCounts.members},activeMembers:${ previousCounts.activeMembers }->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${ - payloadWeight.messageTextChars + payloadWeight.taskDescriptionChars - },attachments=${payloadWeight.messageAttachments},taskComments=${ - payloadWeight.taskComments - },historyEvents=${payloadWeight.taskHistoryEvents}` + payloadWeight.taskDescriptionChars + },taskComments=${payloadWeight.taskComments},historyEvents=${payloadWeight.taskHistoryEvents}` ); } @@ -431,32 +515,195 @@ function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): numb return aId.localeCompare(bId); } -function upsertLocalSentMessage(data: TeamData, message: InboxMessage): TeamData { - const nextMessages = [...data.messages]; +export interface TeamMessagesCacheEntry { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; +} + +export interface RefreshTeamMessagesHeadResult { + feedChanged: boolean; + headChanged: boolean; + feedRevision: string | null; +} + +const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, +}; + +function createEmptyTeamMessagesCacheEntry(): TeamMessagesCacheEntry { + return { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, + }; +} + +function getTeamMessagesCacheEntry( + state: Pick, + teamName: string +): TeamMessagesCacheEntry { + return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; +} + +function upsertOptimisticTeamMessage( + entry: TeamMessagesCacheEntry, + message: InboxMessage +): TeamMessagesCacheEntry { + const nextOptimistic = [...entry.optimisticMessages]; const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; if (messageId.length > 0) { - const existingIndex = nextMessages.findIndex( + const existingIndex = nextOptimistic.findIndex( (candidate) => typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId ); if (existingIndex >= 0) { - nextMessages[existingIndex] = { - ...nextMessages[existingIndex], + nextOptimistic[existingIndex] = { + ...nextOptimistic[existingIndex], ...message, }; } else { - nextMessages.push(message); + nextOptimistic.push(message); } } else { - nextMessages.push(message); + nextOptimistic.push(message); } - nextMessages.sort(compareInboxMessagesByTimestamp); + nextOptimistic.sort(compareInboxMessagesByTimestamp); return { - ...data, - messages: nextMessages, + ...entry, + optimisticMessages: nextOptimistic, }; } +function areInboxMessageArraysEquivalent( + left: readonly InboxMessage[], + right: readonly InboxMessage[] +): boolean { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index]; + const rightItem = right[index]; + if ( + leftItem.messageId !== rightItem.messageId || + leftItem.timestamp !== rightItem.timestamp || + leftItem.from !== rightItem.from || + leftItem.to !== rightItem.to || + leftItem.text !== rightItem.text || + leftItem.summary !== rightItem.summary || + leftItem.read !== rightItem.read || + leftItem.relayOfMessageId !== rightItem.relayOfMessageId || + leftItem.source !== rightItem.source || + leftItem.leadSessionId !== rightItem.leadSessionId || + leftItem.messageKind !== rightItem.messageKind + ) { + return false; + } + } + return true; +} + +function pruneOptimisticMessages( + optimistic: readonly InboxMessage[], + canonical: readonly InboxMessage[] +): InboxMessage[] { + if (optimistic.length === 0) { + return []; + } + + const canonicalIds = new Set( + canonical + .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) + .filter((messageId) => messageId.length > 0) + ); + + return optimistic.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return !messageId || !canonicalIds.has(messageId); + }); +} + +function clearPendingReplyRefreshTimer(teamName: string): void { + const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName); + if (existingTimer == null) { + return; + } + clearTimeout(existingTimer); + pendingTeamPendingReplyRefreshTimers.delete(teamName); +} + +function setPendingReplyRefreshEnabled(teamName: string, enabled: boolean): void { + if (enabled) { + activeTeamPendingReplyWaits.add(teamName); + return; + } + activeTeamPendingReplyWaits.delete(teamName); +} + +function getCanonicalHeadSlice( + canonicalMessages: readonly InboxMessage[], + headLength: number +): readonly InboxMessage[] { + if (headLength <= 0) { + return []; + } + return canonicalMessages.slice(0, headLength); +} + +function extractRetainedCanonicalOlderTail( + canonicalMessages: readonly InboxMessage[], + freshHeadMessages: readonly InboxMessage[] +): InboxMessage[] | null { + if (canonicalMessages.length === 0) { + return []; + } + if (freshHeadMessages.length === 0) { + return null; + } + + const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); + let hasMessagesOutsideFreshHead = false; + for (const message of canonicalMessages) { + if (!freshHeadKeys.has(toMessageKey(message))) { + hasMessagesOutsideFreshHead = true; + break; + } + } + if (!hasMessagesOutsideFreshHead) { + return []; + } + + const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); + const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); + if (anchorIndex < 0) { + return null; + } + + return canonicalMessages + .slice(anchorIndex + 1) + .filter((message) => !freshHeadKeys.has(toMessageKey(message))); +} + async function refreshTaskChangePresenceForUpdatedTask( getState: () => AppState, teamName: string, @@ -824,8 +1071,8 @@ function fireAllTasksCompletedNotification( function collectTaskChangeInvalidationState( teamName: string, - prevTasks: TeamData['tasks'], - nextTasks: TeamData['tasks'] + prevTasks: TeamViewSnapshot['tasks'], + nextTasks: TeamViewSnapshot['tasks'] ): { cacheKeys: string[]; taskIds: string[] } { const nextKeys = new Set( nextTasks.map((task) => @@ -853,9 +1100,9 @@ function collectTaskChangeInvalidationState( function preserveKnownTaskChangePresence( teamName: string, - prevTasks: TeamData['tasks'] | null | undefined, - nextTasks: TeamData['tasks'] -): TeamData['tasks'] { + prevTasks: TeamViewSnapshot['tasks'] | null | undefined, + nextTasks: TeamViewSnapshot['tasks'] +): TeamViewSnapshot['tasks'] { if (!Array.isArray(prevTasks) || prevTasks.length === 0 || nextTasks.length === 0) { return nextTasks; } @@ -928,10 +1175,127 @@ export interface TeamLaunchParams { limitContext?: boolean; } +const resolvedMembersSelectorCache = new Map< + string, + { + snapshotRef: TeamViewSnapshot['members']; + metaMembersRef: TeamMemberActivityMeta['members'] | undefined; + result: ResolvedTeamMember[]; + } +>(); +const resolvedMemberSelectorCache = new Map< + string, + { + snapshotMemberRef: TeamMemberSnapshot | undefined; + metaEntryRef: MemberActivityMetaEntry | undefined; + result: ResolvedTeamMember | null; + } +>(); +const mergedMessagesSelectorCache = new Map< + string, + { + canonicalRef: InboxMessage[]; + optimisticRef: InboxMessage[]; + result: InboxMessage[]; + } +>(); +const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = []; +const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; +const memberMessagesSelectorCache = new Map< + string, + { + messagesRef: InboxMessage[]; + result: InboxMessage[]; + } +>(); + +function resolveMemberStatus( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember['status'] { + if (activity?.latestAuthoredMessageSignalsTermination) { + return 'terminated'; + } + + if (!activity?.lastAuthoredMessageAt) { + return snapshot.currentTaskId ? 'active' : 'idle'; + } + + const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt); + if (Number.isNaN(ageMs)) { + return 'unknown'; + } + if (ageMs < 5 * 60 * 1000) { + return 'active'; + } + return 'idle'; +} + +function buildResolvedMembers( + snapshots: readonly TeamMemberSnapshot[], + meta: TeamMemberActivityMeta | undefined +): ResolvedTeamMember[] { + return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name])); +} + +function buildResolvedMember( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember { + return { + ...snapshot, + status: resolveMemberStatus(snapshot, activity), + messageCount: activity?.messageCountExact ?? 0, + lastActiveAt: activity?.lastAuthoredMessageAt ?? null, + }; +} + +function areMemberActivityMetaEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +function structurallyShareMemberActivityFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + export function selectTeamDataForName( state: Pick, teamName: string | null | undefined -): TeamData | null { +): TeamViewSnapshot | null { if (!teamName) { return null; } @@ -943,7 +1307,7 @@ export function selectTeamDataForName( function migrateStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments | undefined, - members: readonly Pick[] + members: readonly Pick[] ): { assignments: TeamGraphSlotAssignments; changed: boolean } { const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; let changed = false; @@ -970,6 +1334,157 @@ function migrateStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed }; } +export function selectResolvedMembersForTeamName( + state: Pick< + TeamSlice, + 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' + >, + teamName: string | null | undefined +): ResolvedTeamMember[] { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName) { + return []; + } + + const meta = state.memberActivityMetaByTeam[teamName]; + const metaMembers = meta?.members; + const cached = resolvedMembersSelectorCache.get(teamName); + if (cached && cached.snapshotRef === snapshot.members && cached.metaMembersRef === metaMembers) { + return cached.result; + } + + const result = buildResolvedMembers(snapshot.members, meta); + resolvedMembersSelectorCache.set(teamName, { + snapshotRef: snapshot.members, + metaMembersRef: metaMembers, + result, + }); + return result; +} + +export function selectResolvedMemberForTeamName( + state: Pick< + TeamSlice, + 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' + >, + teamName: string | null | undefined, + memberName: string | null | undefined +): ResolvedTeamMember | null { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName || !memberName) { + return null; + } + + const snapshotMember = snapshot.members.find((member) => member.name === memberName); + if (!snapshotMember) { + return null; + } + + const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName]; + const cacheKey = `${teamName}:${memberName}`; + const cached = resolvedMemberSelectorCache.get(cacheKey); + if (cached && cached.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) { + return cached.result; + } + + const result = buildResolvedMember(snapshotMember, metaEntry); + resolvedMemberSelectorCache.set(cacheKey, { + snapshotMemberRef: snapshotMember, + metaEntryRef: metaEntry, + result, + }); + return result; +} + +export function selectTeamMemberSnapshotsForName( + state: Pick, + teamName: string | null | undefined +): TeamViewSnapshot['members'] { + return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; +} + +export function selectTeamTasksForName( + state: Pick, + teamName: string | null | undefined +): TeamViewSnapshot['tasks'] { + return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; +} + +export function selectTeamIsAliveForName( + state: Pick, + teamName: string | null | undefined +): boolean | undefined { + return selectTeamDataForName(state, teamName)?.isAlive; +} + +export function selectTeamMessages( + state: Pick, + teamName: string | null | undefined +): InboxMessage[] { + if (!teamName) { + return []; + } + + const entry = getTeamMessagesCacheEntry(state, teamName); + const cached = mergedMessagesSelectorCache.get(teamName); + if ( + cached && + cached.canonicalRef === entry.canonicalMessages && + cached.optimisticRef === entry.optimisticMessages + ) { + return cached.result; + } + + const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); + mergedMessagesSelectorCache.set(teamName, { + canonicalRef: entry.canonicalMessages, + optimisticRef: entry.optimisticMessages, + result, + }); + return result; +} + +export function selectMemberMessagesForTeamMember( + state: Pick, + teamName: string | null | undefined, + memberName: string | null | undefined +): InboxMessage[] { + if (!teamName || !memberName) { + return []; + } + + const messages = selectTeamMessages(state, teamName); + const cacheKey = `${teamName}:${memberName}`; + const cached = memberMessagesSelectorCache.get(cacheKey); + if (cached && cached.messagesRef === messages) { + return cached.result; + } + + const result = messages.filter( + (message) => message.from === memberName || message.to === memberName + ); + memberMessagesSelectorCache.set(cacheKey, { + messagesRef: messages, + result, + }); + return result; +} + +function isMemberActivityMetaStale( + state: Pick, + teamName: string +): boolean { + const meta = state.memberActivityMetaByTeam[teamName]; + const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (!meta) { + return true; + } + if (!feedRevision) { + return false; + } + return meta.feedRevision !== feedRevision; +} + function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -1028,11 +1543,13 @@ export interface TeamSlice { req: { taskId: string; filePath?: string; requestOptions: TaskChangeRequestOptions } | null ) => void; selectedTeamName: string | null; - selectedTeamData: TeamData | null; + selectedTeamData: TeamViewSnapshot | null; /** Team-scoped detailed cache used by multi-pane views like agent graph. */ - teamDataCacheByName: Record; + teamDataCacheByName: Record; slotLayoutVersion: string; slotAssignmentsByTeam: Record; + teamMessagesByName: Record; + memberActivityMetaByTeam: Record; selectedTeamLoading: boolean; selectedTeamLoadNonce: number; selectedTeamError: string | null; @@ -1077,7 +1594,7 @@ export interface TeamSlice { clearKanbanFilter: () => void; ensureTeamGraphSlotAssignments: ( teamName: string, - members: readonly Pick[] + members: readonly Pick[] ) => void; setTeamGraphOwnerSlotAssignment: ( teamName: string, @@ -1109,6 +1626,10 @@ export interface TeamSlice { opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } ) => Promise; refreshTeamData: (teamName: string, opts?: RefreshTeamDataOptions) => Promise; + refreshTeamMessagesHead: (teamName: string) => Promise; + loadOlderTeamMessages: (teamName: string) => Promise; + refreshMemberActivityMeta: (teamName: string) => Promise; + syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs?: number) => void; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; crossTeamTargets: { teamName: string; @@ -1350,6 +1871,8 @@ export const createTeamSlice: StateCreator = (set, teamDataCacheByName: {}, slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: {}, + teamMessagesByName: {}, + memberActivityMetaByTeam: {}, selectedTeamLoading: false, selectedTeamLoadNonce: 0, selectedTeamError: null, @@ -2079,11 +2602,11 @@ export const createTeamSlice: StateCreator = (set, const requestNonce = get().selectedTeamLoadNonce + 1; const previousData = selectTeamDataForName(get(), teamName); - // Stale-while-revalidate: keep previous data visible while loading new team. - // Skeleton only shows on first load (when data is null). - // Data is atomically replaced when the new team's data arrives. + // Repoint selection synchronously to the new team's cached snapshot when available. + // Never keep the previous team's snapshot attached to a newly selected team. set({ selectedTeamName: teamName, + selectedTeamData: previousData, selectedTeamLoading: true, selectedTeamLoadNonce: requestNonce, selectedTeamError: null, @@ -2119,23 +2642,31 @@ export const createTeamSlice: StateCreator = (set, set({ teamByName: { ...prevByName, [teamName]: patched } }); } - const nextTeamData = previousData + const projectedTeamData = previousData ? { ...data, tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), } : data; + const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); const setStartedAt = performance.now(); - set((state) => ({ - selectedTeamName: teamName, - selectedTeamData: nextTeamData, - teamDataCacheByName: { - ...state.teamDataCacheByName, - [teamName]: nextTeamData, - }, - selectedTeamLoading: false, - selectedTeamError: null, - })); + set((state) => { + const nextCache = + state.teamDataCacheByName[teamName] === nextTeamData + ? state.teamDataCacheByName + : { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }; + + return { + selectedTeamName: teamName, + selectedTeamData: nextTeamData, + teamDataCacheByName: nextCache, + selectedTeamLoading: false, + selectedTeamError: null, + }; + }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -2157,7 +2688,7 @@ export const createTeamSlice: StateCreator = (set, postMs, totalMs: performance.now() - startedAt, previousData, - nextData: data, + nextData: nextTeamData, deduped: true, reusedInFlightRequest: false, }); @@ -2174,6 +2705,11 @@ export const createTeamSlice: StateCreator = (set, } } + const messagesHeadResult = await get().refreshTeamMessagesHead(teamName); + if (messagesHeadResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) { + await get().refreshMemberActivityMeta(teamName); + } + if (opts?.skipProjectAutoSelect) { return; } @@ -2275,25 +2811,44 @@ export const createTeamSlice: StateCreator = (set, ? await fetchTeamDataDeduped(teamName) : await fetchTeamDataFresh(teamName); const ipcMs = performance.now() - startedAt; - const nextTeamData = previousData + const projectedTeamData = previousData ? { ...data, tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), } : data; + const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); const setStartedAt = performance.now(); - set((state) => ({ - teamDataCacheByName: { - ...state.teamDataCacheByName, - [teamName]: nextTeamData, - }, - ...(state.selectedTeamName === teamName - ? { - selectedTeamData: nextTeamData, - selectedTeamError: null, - } - : {}), - })); + set((state) => { + const nextCache = + state.teamDataCacheByName[teamName] === nextTeamData + ? state.teamDataCacheByName + : { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }; + + const selectedState = + state.selectedTeamName === teamName + ? { + selectedTeamData: nextTeamData, + selectedTeamError: null, + } + : {}; + + if ( + nextCache === state.teamDataCacheByName && + (state.selectedTeamName !== teamName || + (state.selectedTeamData === nextTeamData && state.selectedTeamError == null)) + ) { + return {}; + } + + return { + teamDataCacheByName: nextCache, + ...selectedState, + }; + }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -2315,7 +2870,7 @@ export const createTeamSlice: StateCreator = (set, postMs, totalMs: performance.now() - startedAt, previousData, - nextData: data, + nextData: nextTeamData, deduped: opts?.withDedup === true, reusedInFlightRequest, burstCount, @@ -2386,6 +2941,315 @@ export const createTeamSlice: StateCreator = (set, } }, + refreshTeamMessagesHead: async (teamName: string) => { + const existingRequest = inFlightTeamMessagesHeadRequests.get(teamName); + if (existingRequest) { + pendingFreshTeamMessagesHeadRefreshes.add(teamName); + return existingRequest; + } + const queuedAfterOlder = queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName); + if (queuedAfterOlder) { + return queuedAfterOlder; + } + + const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName); + if (existingOlderRequest) { + let queuedRequest: Promise; + queuedRequest = existingOlderRequest + .then(() => { + if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) { + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + } + return get().refreshTeamMessagesHead(teamName); + }) + .finally(() => { + if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) { + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + } + }); + queuedTeamMessagesHeadRefreshesAfterOlder.set(teamName, queuedRequest); + return queuedRequest; + } + + const request = (async (): Promise => { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingHead: true, + }, + }, + })); + + try { + const page = await unwrapIpc('team:getMessagesPage', () => + api.teams.getMessagesPage(teamName, { limit: 50 }) + ); + + const previousEntry = getTeamMessagesCacheEntry(get(), teamName); + const feedChanged = + !previousEntry.headHydrated || previousEntry.feedRevision !== page.feedRevision; + const previousHeadSlice = getCanonicalHeadSlice( + previousEntry.canonicalMessages, + page.messages.length + ); + const headChanged = !areInboxMessageArraysEquivalent(previousHeadSlice, page.messages); + + set((state) => { + const current = getTeamMessagesCacheEntry(state, teamName); + const retainedOlderTail = extractRetainedCanonicalOlderTail( + current.canonicalMessages, + page.messages + ); + const preserveLoadedOlderTail = + Array.isArray(retainedOlderTail) && retainedOlderTail.length > 0; + const nextCanonical = headChanged + ? preserveLoadedOlderTail + ? mergeTeamMessages(retainedOlderTail, page.messages) + : page.messages + : current.canonicalMessages; + const nextOptimistic = pruneOptimisticMessages(current.optimisticMessages, nextCanonical); + const nextEntry: TeamMessagesCacheEntry = { + ...current, + canonicalMessages: nextCanonical, + optimisticMessages: nextOptimistic, + feedRevision: page.feedRevision, + nextCursor: preserveLoadedOlderTail ? current.nextCursor : page.nextCursor, + hasMore: preserveLoadedOlderTail ? current.hasMore : page.hasMore, + lastFetchedAt: Date.now(), + loadingHead: false, + headHydrated: true, + }; + return { + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: nextEntry, + }, + }; + }); + + return { + feedChanged, + headChanged, + feedRevision: page.feedRevision, + }; + } catch (error) { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingHead: false, + }, + }, + })); + throw error; + } finally { + inFlightTeamMessagesHeadRequests.delete(teamName); + if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) { + void get().refreshTeamMessagesHead(teamName); + } + } + })(); + + inFlightTeamMessagesHeadRequests.set(teamName, request); + return request; + }, + + loadOlderTeamMessages: async (teamName: string) => { + const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName); + if (existingRequest) { + return existingRequest; + } + + const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName); + if (existingHeadRequest) { + await existingHeadRequest; + } + + let entry = getTeamMessagesCacheEntry(get(), teamName); + if (!entry.headHydrated) { + await get().refreshTeamMessagesHead(teamName); + entry = getTeamMessagesCacheEntry(get(), teamName); + } + + if (!entry.headHydrated || !entry.nextCursor || entry.loadingOlder || entry.loadingHead) { + return; + } + + const request = (async (): Promise => { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: true, + }, + }, + })); + + try { + const baseFeedRevision = entry.feedRevision; + const page = await unwrapIpc('team:getMessagesPage', () => + api.teams.getMessagesPage(teamName, { + cursor: entry.nextCursor, + limit: 50, + }) + ); + + const current = getTeamMessagesCacheEntry(get(), teamName); + if (current.feedRevision !== baseFeedRevision) { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: false, + }, + }, + })); + await get().refreshTeamMessagesHead(teamName); + return; + } + + if (current.feedRevision && current.feedRevision !== page.feedRevision) { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: false, + }, + }, + })); + await get().refreshTeamMessagesHead(teamName); + return; + } + + set((state) => { + const liveEntry = getTeamMessagesCacheEntry(state, teamName); + const mergedCanonical = mergeTeamMessages(liveEntry.canonicalMessages, page.messages); + return { + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...liveEntry, + canonicalMessages: mergedCanonical, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + feedRevision: page.feedRevision, + loadingOlder: false, + }, + }, + }; + }); + } catch { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: false, + }, + }, + })); + } finally { + inFlightTeamMessagesOlderRequests.delete(teamName); + } + })(); + + inFlightTeamMessagesOlderRequests.set(teamName, request); + return request; + }, + + refreshMemberActivityMeta: async (teamName: string) => { + const entry = getTeamMessagesCacheEntry(get(), teamName); + if (!entry.headHydrated) { + return; + } + + const existingRequest = inFlightTeamMemberActivityMetaRequests.get(teamName); + if (existingRequest) { + pendingFreshTeamMemberActivityMetaRefreshes.add(teamName); + return existingRequest; + } + + const request = (async (): Promise => { + try { + const meta = await unwrapIpc('team:getMemberActivityMeta', () => + api.teams.getMemberActivityMeta(teamName) + ); + + set((state) => { + const currentFeedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (currentFeedRevision && meta.feedRevision !== currentFeedRevision) { + return {}; + } + const existing = state.memberActivityMetaByTeam[teamName]; + if (existing && existing.feedRevision === meta.feedRevision) { + return {}; + } + const sharedMembers = structurallyShareMemberActivityFacts( + existing?.members, + meta.members + ); + const nextMeta = + existing && + existing.members === sharedMembers && + existing.feedRevision === meta.feedRevision && + existing.computedAt === meta.computedAt + ? existing + : { + ...meta, + members: sharedMembers, + }; + return { + memberActivityMetaByTeam: { + ...state.memberActivityMetaByTeam, + [teamName]: nextMeta, + }, + }; + }); + } finally { + inFlightTeamMemberActivityMetaRequests.delete(teamName); + if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) { + void get().refreshMemberActivityMeta(teamName); + } + } + })(); + + inFlightTeamMemberActivityMetaRequests.set(teamName, request); + return request; + }, + + syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs = 10_000) => { + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, enabled); + if (!enabled) { + return; + } + + const timer = setTimeout(() => { + if (pendingTeamPendingReplyRefreshTimers.get(teamName) !== timer) { + return; + } + pendingTeamPendingReplyRefreshTimers.delete(teamName); + void (async () => { + try { + const headResult = await get().refreshTeamMessagesHead(teamName); + if (headResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) { + await get().refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort delayed refresh while waiting for replies. + } + })(); + }, delayMs); + + pendingTeamPendingReplyRefreshTimers.set(teamName, timer); + }, + updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => { try { set({ reviewActionError: null }); @@ -2442,24 +3306,15 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: false, sendMessageError: null, lastSendMessageResult: result, - ...(selectTeamDataForName(state, teamName) - ? { - teamDataCacheByName: { - ...state.teamDataCacheByName, - [teamName]: upsertLocalSentMessage( - selectTeamDataForName(state, teamName)!, - optimisticMessage - ), - }, - } - : {}), - ...(state.selectedTeamName === teamName && state.selectedTeamData - ? { - selectedTeamData: upsertLocalSentMessage(state.selectedTeamData, optimisticMessage), - } - : {}), + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: upsertOptimisticTeamMessage( + getTeamMessagesCacheEntry(state, teamName), + optimisticMessage + ), + }, })); - await get().refreshTeamData(teamName); + await get().refreshTeamMessagesHead(teamName); } catch (error) { set({ sendingMessage: false, @@ -2493,7 +3348,7 @@ export const createTeamSlice: StateCreator = (set, deduplicated: result.deduplicated, }, }); - await get().refreshTeamData(request.fromTeam); + await get().refreshTeamMessagesHead(request.fromTeam); } catch (error) { set({ sendingMessage: false, @@ -2668,13 +3523,27 @@ export const createTeamSlice: StateCreator = (set, deleteTeam: async (teamName: string) => { await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, false); set((state) => { const nextCache = state.teamDataCacheByName[teamName] ? { ...state.teamDataCacheByName } : null; + const nextMessageCache = state.teamMessagesByName[teamName] + ? { ...state.teamMessagesByName } + : null; + const nextActivityMeta = state.memberActivityMetaByTeam[teamName] + ? { ...state.memberActivityMetaByTeam } + : null; if (nextCache) { delete nextCache[teamName]; } + if (nextMessageCache) { + delete nextMessageCache[teamName]; + } + if (nextActivityMeta) { + delete nextActivityMeta[teamName]; + } if (state.selectedTeamName === teamName) { return { selectedTeamName: null, @@ -2682,9 +3551,15 @@ export const createTeamSlice: StateCreator = (set, selectedTeamLoading: false, selectedTeamError: null, ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + ...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}), + ...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}), }; } - return nextCache ? { teamDataCacheByName: nextCache } : {}; + return { + ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + ...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}), + ...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}), + }; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -2692,15 +3567,32 @@ export const createTeamSlice: StateCreator = (set, restoreTeam: async (teamName: string) => { await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, false); set((state) => { - if (!state.teamDataCacheByName[teamName]) { + const hasSnapshot = Boolean(state.teamDataCacheByName[teamName]); + const hasMessages = Boolean(state.teamMessagesByName[teamName]); + const hasMeta = Boolean(state.memberActivityMetaByTeam[teamName]); + if (!hasSnapshot && !hasMessages && !hasMeta) { return {}; } - const nextCache = { ...state.teamDataCacheByName }; - delete nextCache[teamName]; - return { - teamDataCacheByName: nextCache, - }; + const nextState: Partial = {}; + if (hasSnapshot) { + const nextCache = { ...state.teamDataCacheByName }; + delete nextCache[teamName]; + nextState.teamDataCacheByName = nextCache; + } + if (hasMessages) { + const nextMessages = { ...state.teamMessagesByName }; + delete nextMessages[teamName]; + nextState.teamMessagesByName = nextMessages; + } + if (hasMeta) { + const nextMeta = { ...state.memberActivityMetaByTeam }; + delete nextMeta[teamName]; + nextState.memberActivityMetaByTeam = nextMeta; + } + return nextState; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -2708,19 +3600,34 @@ export const createTeamSlice: StateCreator = (set, permanentlyDeleteTeam: async (teamName: string) => { await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, false); const state = get(); const nextCache = { ...state.teamDataCacheByName }; + const nextMessages = { ...state.teamMessagesByName }; + const nextMeta = { ...state.memberActivityMetaByTeam }; delete nextCache[teamName]; + delete nextMessages[teamName]; + delete nextMeta[teamName]; if (state.selectedTeamName === teamName) { set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null, teamDataCacheByName: nextCache, + teamMessagesByName: nextMessages, + memberActivityMetaByTeam: nextMeta, }); } else if (state.teamDataCacheByName[teamName]) { set({ teamDataCacheByName: nextCache, + teamMessagesByName: nextMessages, + memberActivityMetaByTeam: nextMeta, + }); + } else if (state.teamMessagesByName[teamName] || state.memberActivityMetaByTeam[teamName]) { + set({ + teamMessagesByName: nextMessages, + memberActivityMetaByTeam: nextMeta, }); } await get().fetchTeams(); diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 2253c177..38b5cc32 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -8,7 +8,6 @@ import { import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - ResolvedTeamMember, TeamProvisioningProgress, } from '@shared/types'; @@ -17,6 +16,11 @@ type MemberSpawnStatusCollection = | Map | undefined; +interface ProvisioningMemberLike { + name: string; + removedAt?: number; +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -66,7 +70,7 @@ export function buildTeamProvisioningPresentation({ memberSpawnSnapshot, }: { progress: TeamProvisioningProgress | null | undefined; - members: readonly ResolvedTeamMember[]; + members: readonly ProvisioningMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshot?: Pick; }): TeamProvisioningPresentation | null { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index afc2aae7..3f42ec1c 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -53,6 +53,7 @@ import type { KanbanColumnId, LeadActivitySnapshot, LeadContextUsageSnapshot, + TeamMemberActivityMeta, MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, @@ -71,10 +72,10 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, + TeamViewSnapshot, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, @@ -425,7 +426,7 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; - getData: (teamName: string) => Promise; + getData: (teamName: string) => Promise; getTaskChangePresence: (teamName: string) => Promise>; setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; setToolActivityTracking: (teamName: string, enabled: boolean) => Promise; @@ -446,8 +447,9 @@ export interface TeamsAPI { sendMessage: (teamName: string, request: SendMessageRequest) => Promise; getMessagesPage: ( teamName: string, - options?: { beforeTimestamp?: string; limit?: number } + options?: { cursor?: string | null; limit?: number } ) => Promise; + getMemberActivityMeta: (teamName: string) => Promise; createTask: (teamName: string, request: CreateTaskRequest) => Promise; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index dcc8dfdf..41562918 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -602,6 +602,11 @@ export interface MessagesPage { /** Opaque cursor string for fetching older messages. Null when no more pages. */ nextCursor: string | null; hasMore: boolean; + /** + * Content-stable revision of the full normalized feed that produced this page. + * Changes only when the semantic message feed changes. + */ + feedRevision: string; } export type AgentActionMode = 'do' | 'ask' | 'delegate'; @@ -729,12 +734,44 @@ export interface TeamProcess { stoppedAt?: string; } -export interface TeamData { +export interface TeamMemberSnapshot { + name: string; + agentId?: string; + currentTaskId: string | null; + taskCount: number; + color?: string; + agentType?: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + cwd?: string; + /** Set only when member's git branch differs from the lead's branch. */ + 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; + feedRevision: string; +} + +export interface TeamViewSnapshot { teamName: string; config: TeamConfig; tasks: TeamTaskWithKanban[]; - members: ResolvedTeamMember[]; - messages: InboxMessage[]; + members: TeamMemberSnapshot[]; kanbanState: KanbanState; processes: TeamProcess[]; warnings?: string[]; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 24412b24..4a2ef3a7 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -12,7 +12,7 @@ import type { } from '@shared/types/team'; vi.mock('electron', () => ({ - app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') }, + app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false }, Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }), BrowserWindow: { getAllWindows: vi.fn(() => []) }, })); @@ -34,6 +34,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ mockTeamDataWorkerClient: { isAvailable: vi.fn(), getTeamData: vi.fn(), + getMessagesPage: vi.fn(), + getMemberActivityMeta: vi.fn(), findLogsForTask: vi.fn(), }, })); @@ -62,6 +64,8 @@ import { TEAM_CREATE_TASK, TEAM_DELETE_TEAM, TEAM_GET_DATA, + TEAM_GET_MEMBER_ACTIVITY_META, + TEAM_GET_MESSAGES_PAGE, TEAM_LAUNCH, TEAM_LIST, TEAM_PREPARE_PROVISIONING, @@ -135,13 +139,33 @@ describe('ipc teams handlers', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [] as InboxMessage[], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], })), + getMessageFeed: vi.fn(async () => ({ + teamName: 'my-team', + feedRevision: 'rev-1', + messages: [] as InboxMessage[], + })), + getMessagesPage: vi.fn(async () => ({ + messages: [] as InboxMessage[], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + })), + getMemberActivityMeta: vi.fn(async () => ({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: {}, + feedRevision: 'rev-1', + })), getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })), reconcileTeamArtifacts: vi.fn(async () => undefined), setTaskChangePresenceTracking: vi.fn(() => undefined), + getTeamNotificationContext: vi.fn(async () => ({ + displayName: 'My Team', + projectPath: '/tmp/project', + })), deleteTeam: vi.fn(async () => undefined), getLeadMemberName: vi.fn(async () => 'team-lead'), getTeamDisplayName: vi.fn(async () => 'My Team'), @@ -231,6 +255,8 @@ describe('ipc teams handlers', () => { mockGetMembersMeta.mockResolvedValue([]); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); mockTeamDataWorkerClient.getTeamData.mockReset(); + mockTeamDataWorkerClient.getMessagesPage.mockReset(); + mockTeamDataWorkerClient.getMemberActivityMeta.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset(); initializeTeamHandlers( service as never, @@ -252,6 +278,8 @@ describe('ipc teams handlers', () => { it('registers all expected handlers', () => { expect(handlers.has(TEAM_LIST)).toBe(true); expect(handlers.has(TEAM_GET_DATA)).toBe(true); + expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true); + expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true); expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true); expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true); expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true); @@ -580,7 +608,6 @@ describe('ipc teams handlers', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [] as InboxMessage[], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -759,24 +786,7 @@ describe('ipc teams handlers', () => { }); }); - it('dedups live lead replies when lead_session already has same text', async () => { - service.getTeamData.mockResolvedValueOnce({ - teamName: 'my-team', - config: { name: 'My Team' }, - tasks: [], - members: [], - messages: [ - { - from: 'team-lead', - text: 'Hello there', - timestamp: '2026-02-23T10:00:00.000Z', - read: true, - source: 'lead_session' as const, - }, - ], - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], - }); + it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => { provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ { from: 'team-lead', @@ -791,59 +801,159 @@ describe('ipc teams handlers', () => { const getDataHandler = handlers.get(TEAM_GET_DATA)!; const result = (await getDataHandler({} as never, 'my-team')) as { success: boolean; - data: { messages: { source?: string }[] }; + data: Record; }; expect(result.success).toBe(true); - const sources = result.data.messages.map((m) => m.source); - expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0); - expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1); + expect(result.data.teamName).toBe('my-team'); + expect(result.data).not.toHaveProperty('messages'); + expect(service.getMessageFeed).not.toHaveBeenCalled(); }); - it('merges early live messages before durable lead_session backfill exists', async () => { - // Simulate: team just became readable but lead_session JSONL hasn't been written yet. - // Only live in-memory messages exist from the provisioning process. - service.getTeamData.mockResolvedValueOnce({ - teamName: 'my-team', - config: { name: 'My Team' }, - tasks: [], - members: [], - messages: [], // No durable messages yet - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], - }); - provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ - { - from: 'team-lead', - text: 'Команда создана. Запускаю тиммейтов.', - timestamp: '2026-02-23T10:00:00.000Z', - read: true, - source: 'lead_process' as const, - messageId: 'lead-turn-run-1-1', - }, - { - from: 'team-lead', - text: 'All teammates online!', - timestamp: '2026-02-23T10:00:01.000Z', - read: true, - source: 'lead_process' as const, - messageId: 'lead-turn-run-1-2', - to: 'user', - }, - ]); + it('rejects TEAM_GET_DATA fallback in packaged runtime when worker is unavailable', async () => { + const electron = await import('electron'); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + (electron.app as { isPackaged: boolean }).isPackaged = true; + + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); + expect(service.getTeamData).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; + }); + + it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Hello there', + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_session' as const, + messageId: 'msg-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-worker', + }); + + const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; + const result = (await handler({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; data: { feedRevision: string } }; - const getDataHandler = handlers.get(TEAM_GET_DATA)!; - const result = (await getDataHandler({} as never, 'my-team')) as { - success: boolean; - data: { messages: { source?: string; text: string }[] }; - }; expect(result.success).toBe(true); - // Both live messages should appear since there's no durable backfill yet - // Sorted by timestamp descending (newest first) - expect(result.data.messages).toHaveLength(2); - expect(result.data.messages[0].source).toBe('lead_process'); - expect(result.data.messages[0].text).toBe('All teammates online!'); - expect(result.data.messages[1].source).toBe('lead_process'); - expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.'); + expect(result.data.feedRevision).toBe('rev-worker'); + expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', { + cursor: undefined, + limit: 50, + }); + expect(service.getMessagesPage).not.toHaveBeenCalled(); + }); + + it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Please wait a bit before retrying.", + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_session' as const, + messageId: 'msg-rate-limit-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-worker', + }); + + const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; + const result = (await handler({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; data: { feedRevision: string } }; + + expect(result.success).toBe(true); + expect(result.data.feedRevision).toBe('rev-worker'); + expect(mockAddTeamNotification).toHaveBeenCalledWith( + expect.objectContaining({ + teamEventType: 'rate_limit', + teamName: 'my-team', + teamDisplayName: 'My Team', + from: 'team-lead', + dedupeKey: 'rate-limit:my-team:msg-rate-limit-1', + }) + ); + expect(service.getMessageFeed).not.toHaveBeenCalled(); + }); + + it('rejects heavy TEAM_GET_MESSAGES_PAGE fallback in packaged runtime when worker is unavailable', async () => { + const electron = await import('electron'); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + (electron.app as { isPackaged: boolean }).isPackaged = true; + + const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; + const result = (await handler({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); + expect(service.getMessagesPage).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; + }); + + it('uses the team-data worker for TEAM_GET_MEMBER_ACTIVITY_META when available', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getMemberActivityMeta.mockResolvedValueOnce({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 4, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-worker', + }); + + const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!; + const result = (await handler({} as never, 'my-team')) as { + success: boolean; + data: { feedRevision: string }; + }; + + expect(result.success).toBe(true); + expect(result.data.feedRevision).toBe('rev-worker'); + expect(mockTeamDataWorkerClient.getMemberActivityMeta).toHaveBeenCalledWith('my-team'); + expect(service.getMemberActivityMeta).not.toHaveBeenCalled(); + }); + + it('rejects heavy TEAM_GET_MEMBER_ACTIVITY_META fallback in packaged runtime when worker is unavailable', async () => { + const electron = await import('electron'); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + (electron.app as { isPackaged: boolean }).isPackaged = true; + + const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!; + const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); + expect(service.getMemberActivityMeta).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; }); it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index a2e077bb..2d9d61b7 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -11,8 +11,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ import type { InboxMessage, KanbanState, + ResolvedTeamMember, TeamConfig, - TeamData, + TeamProcess, TeamTask, TeamTaskWithKanban, } from '../../../../src/shared/types/team'; @@ -240,10 +241,9 @@ function createGetTeamDataHarness(options: { config: TeamConfig, metaMembers: TeamConfig['members'], inboxNames: string[], - tasks: TeamTaskWithKanban[], - messages: InboxMessage[] - ) => TeamData['members']; - listProcesses?: () => TeamData['processes']; + tasks: TeamTaskWithKanban[] + ) => ResolvedTeamMember[]; + listProcesses?: () => TeamProcess[]; getMemberAdvisories?: () => Promise>; } = {}) { const getConfig = vi.fn(async () => @@ -351,7 +351,7 @@ function createGetTeamDataHarness(options: { }; } -function buildResolvedMember(name: string): TeamData['members'][number] { +function buildResolvedMember(name: string): ResolvedTeamMember { return { name, status: 'unknown', @@ -628,6 +628,39 @@ describe('TeamDataService', () => { ); }); + it('returns lightweight notification context from config without hydrating team data', async () => { + const getConfig = vi.fn(async () => ({ + name: 'My Team', + projectPath: '/Users/dev/my-project', + members: [], + })); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig, + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + (() => ({ processes: { listProcesses: vi.fn(() => []) } })) as never + ); + + const result = await service.getTeamNotificationContext('my-team'); + + expect(result).toEqual({ + displayName: 'My Team', + projectPath: '/Users/dev/my-project', + }); + expect(getConfig).toHaveBeenCalledWith('my-team'); + }); + it('creates task with status pending when startImmediately is false', async () => { const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' })); const service = new TeamDataService( @@ -2437,8 +2470,8 @@ describe('TeamDataService', () => { } as never ); - const data = await service.getTeamData('my-team'); - const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1'); + const feed = await service.getMessageFeed('my-team'); + const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1'); expect(costResult).toMatchObject({ messageKind: 'slash_command_result', @@ -2507,8 +2540,8 @@ describe('TeamDataService', () => { } as never ); - const data = await service.getTeamData('my-team'); - const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1'); + const feed = await service.getMessageFeed('my-team'); + const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1'); expect(result).toBeDefined(); expect(result?.source).not.toBe('lead_process'); @@ -2582,8 +2615,8 @@ describe('TeamDataService', () => { sentMessages: [userReplyRow], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1'); + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1'); expect(linked?.relayOfMessageId).toBe('user-reply-1'); expect(passiveSummaryRow.relayOfMessageId).toBeUndefined(); @@ -2618,8 +2651,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find( + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find( (message) => message.messageId === 'passive-user-summary-contains-1' ); @@ -2655,8 +2688,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); expect(linked?.relayOfMessageId).toBeUndefined(); }); @@ -2690,8 +2723,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1'); + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1'); expect(linked?.relayOfMessageId).toBeUndefined(); }); @@ -2725,8 +2758,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find( + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find( (message) => message.messageId === 'passive-user-summary-sender-1' ); @@ -2772,8 +2805,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find( + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find( (message) => message.messageId === 'passive-user-summary-ambiguous-1' ); @@ -3281,8 +3314,6 @@ describe('TeamDataService', () => { it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => { const order: string[] = []; const tasksDeferred = createDeferred(); - const messagesDeferred = createDeferred(); - const leadTextsDeferred = createDeferred(); const harness = createGetTeamDataHarness({ getTasks: async () => { @@ -3293,10 +3324,6 @@ describe('TeamDataService', () => { order.push('inboxNames:start'); return []; }, - getMessages: async () => { - order.push('messages:start'); - return messagesDeferred.promise; - }, getMembers: async () => { order.push('meta:start'); return []; @@ -3305,10 +3332,6 @@ describe('TeamDataService', () => { order.push('kanban:start'); return { teamName: 'my-team', reviewers: [], tasks: {} }; }, - readMessages: async () => { - order.push('sent:start'); - return []; - }, resolveMembers: () => { order.push('resolveMembers'); return []; @@ -3330,39 +3353,21 @@ describe('TeamDataService', () => { }, }); - vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( - async () => { - order.push('leadTexts:start'); - return leadTextsDeferred.promise; - } - ); - const pending = harness.service.getTeamData('my-team'); await flushMicrotasks(); expect(order).toEqual( expect.arrayContaining([ 'inboxNames:start', - 'sent:start', 'meta:start', 'kanban:start', 'tasks:start', - 'messages:start', ]) ); - expect(order).not.toContain('leadTexts:start'); expect(order).not.toContain('processes:start'); + expect(order).not.toContain('leadTexts:start'); tasksDeferred.resolve([]); - await flushMicrotasks(); - - expect(order).toContain('leadTexts:start'); - expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start')); - expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start')); - expect(order).not.toContain('processes:start'); - - messagesDeferred.resolve([]); - leadTextsDeferred.resolve([]); const data = await pending; @@ -3372,7 +3377,7 @@ describe('TeamDataService', () => { pid: 101, }), ]); - expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start')); + expect(order).not.toContain('leadTexts:start'); expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start')); }); @@ -3417,47 +3422,64 @@ describe('TeamDataService', () => { ); }); + it('surfaces isAlive in the structural snapshot from live process state', async () => { + const aliveHarness = createGetTeamDataHarness({ + listProcesses: () => + [ + { + id: 'proc-1', + label: 'Lead', + pid: 101, + registeredAt: '2026-04-09T10:00:00.000Z', + }, + ] satisfies TeamProcess[], + }); + const offlineHarness = createGetTeamDataHarness({ + listProcesses: () => + [ + { + id: 'proc-1', + label: 'Lead', + pid: 101, + registeredAt: '2026-04-09T10:00:00.000Z', + stoppedAt: '2026-04-09T10:05:00.000Z', + }, + ] satisfies TeamProcess[], + }); + + const aliveData = await aliveHarness.service.getTeamData('my-team'); + const offlineData = await offlineHarness.service.getTeamData('my-team'); + + expect(aliveData.isAlive).toBe(true); + expect(offlineData.isAlive).toBe(false); + }); + it('keeps warning order deterministic even when read failures settle out of order', async () => { const tasksDeferred = createDeferred(); const inboxDeferred = createDeferred(); - const messagesDeferred = createDeferred(); - const leadTextsDeferred = createDeferred(); - const sentDeferred = createDeferred(); const metaDeferred = createDeferred(); const kanbanDeferred = createDeferred(); const harness = createGetTeamDataHarness({ getTasks: async () => tasksDeferred.promise, listInboxNames: async () => inboxDeferred.promise, - getMessages: async () => messagesDeferred.promise, getMembers: async () => metaDeferred.promise, getState: async () => kanbanDeferred.promise, - readMessages: async () => sentDeferred.promise, }); - vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( - async () => leadTextsDeferred.promise - ); - const pending = harness.service.getTeamData('my-team'); await flushMicrotasks(); - sentDeferred.reject(new Error('sent failed')); kanbanDeferred.reject(new Error('kanban failed')); tasksDeferred.reject(new Error('tasks failed')); metaDeferred.reject(new Error('meta failed')); inboxDeferred.reject(new Error('inbox failed')); - leadTextsDeferred.reject(new Error('lead failed')); - messagesDeferred.reject(new Error('messages failed')); const data = await pending; expect(data.warnings).toEqual([ 'Tasks failed to load', 'Inboxes failed to load', - 'Messages failed to load', - 'Lead session texts failed to load', - 'Sent messages failed to load', 'Member metadata failed to load', 'Kanban state failed to load', ]); @@ -3501,9 +3523,9 @@ describe('TeamDataService', () => { }, ]); - const data = await harness.service.getTeamData('my-team'); + const feed = await harness.service.getMessageFeed('my-team'); - expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']); + expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']); }); it('preserves assembled messages and resolver inputs when inbox messages fail', async () => { @@ -3552,11 +3574,10 @@ describe('TeamDataService', () => { ]); const data = await harness.service.getTeamData('my-team'); + const feed = await harness.service.getMessageFeed('my-team'); - expect(data.warnings).toEqual( - expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load']) - ); - expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']); + expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load'])); + expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']); expect(resolveMembersSpy).toHaveBeenCalledWith( buildDefaultTeamConfig(), metaMembers, @@ -3566,10 +3587,6 @@ describe('TeamDataService', () => { id: 'task-1', subject: 'Investigate rollout', }), - ], - [ - expect.objectContaining({ messageId: 'sent-1' }), - expect.objectContaining({ messageId: 'lead-1' }), ] ); }); @@ -3608,16 +3625,11 @@ describe('TeamDataService', () => { it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => { const order: string[] = []; const tasksDeferred = createDeferred(); - const messagesDeferred = createDeferred(); const harness = createGetTeamDataHarness({ getTasks: async () => { order.push('tasks:start'); return tasksDeferred.promise; }, - getMessages: async () => { - order.push('messages:start'); - return messagesDeferred.promise; - }, listProcesses: () => { order.push('processes:start'); return []; @@ -3635,14 +3647,9 @@ describe('TeamDataService', () => { expect(order).not.toContain('leadTexts:start'); tasksDeferred.resolve([]); - await flushMicrotasks(); - - expect(order).toContain('leadTexts:start'); - - messagesDeferred.resolve([]); const data = await pending; - expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load'])); + expect(data.warnings ?? []).not.toContain('Lead session texts failed to load'); expect(order).toContain('processes:start'); }); @@ -3780,7 +3787,7 @@ describe('TeamDataService', () => { expect(page1.hasMore).toBe(true); const page2 = await service.getMessagesPage('my-team', { - beforeTimestamp: page1.nextCursor!, + cursor: page1.nextCursor!, limit: 10, }); // Should get the remaining 2 messages, not lose the one with same timestamp @@ -3813,5 +3820,40 @@ describe('TeamDataService', () => { const result = page.messages.find((m) => m.messageId === 'resp1'); expect(result?.messageKind).toBe('slash_command_result'); }); + + it('normalizes stable effective message ids before pagination and cursoring', async () => { + const msgs = [ + { + from: 'alice', + text: 'same-ts-a', + timestamp: '2026-01-01T00:00:02.000Z', + source: 'inbox' as const, + }, + { + from: 'bob', + text: 'same-ts-b', + timestamp: '2026-01-01T00:00:02.000Z', + source: 'inbox' as const, + }, + { + from: 'carol', + text: 'older', + timestamp: '2026-01-01T00:00:01.000Z', + source: 'inbox' as const, + }, + ]; + const service = createPaginationService(msgs); + + const page1 = await service.getMessagesPage('my-team', { limit: 1 }); + const page2 = await service.getMessagesPage('my-team', { + cursor: page1.nextCursor!, + limit: 10, + }); + + expect(page1.messages[0]?.messageId).toMatch(/^inbox-/); + expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!); + expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true); + expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3); + }); }); }); diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index 1a0b855c..77f44ce7 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver'; import type { - InboxMessage, TeamConfig, TeamTask, TeamTaskWithKanban, @@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => { { id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' }, { id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' }, ]; - const now = new Date().toISOString(); - const messages: InboxMessage[] = [ - { from: 'bob', text: 'ready', timestamp: now, read: false, color: 'green' }, - { from: 'user', text: 'system note', timestamp: now, read: false }, - ]; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks); const names = members.map((member) => member.name); expect(names).toHaveLength(3); @@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => { ]; const inboxNames = ['user', 'alice']; const tasks: TeamTask[] = []; - const messages: InboxMessage[] = []; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks); const names = members.map((m) => m.name); expect(names).not.toContain('user'); @@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => { const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }]; const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead']; const tasks: TeamTask[] = []; - const messages: InboxMessage[] = []; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks); const names = members.map((m) => m.name); expect(names).toContain('alice'); @@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => { ]; const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd']; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, []); const names = members.map((m) => m.name); expect(names).toContain('alice'); @@ -124,7 +116,7 @@ describe('TeamMemberResolver', () => { ], }; - const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []); + const members = resolver.resolveMembers(config, [], ['ops.bot'], []); const names = members.map((m) => m.name); expect(names).toContain('ops.bot'); @@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => { config, [], ['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'], - [], [] ); const names = members.map((m) => m.name); @@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => { config, [], ['cross_team_send', 'cross_team_list_targets', 'alice'], - [], [] ); const names = members.map((m) => m.name); @@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => { config, [], ['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'], - [], [] ); const names = members.map((m) => m.name); @@ -206,7 +195,7 @@ describe('TeamMemberResolver', () => { ], }; - const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []); + const members = resolver.resolveMembers(config, [], ['ops.bot'], []); const names = members.map((m) => m.name); expect(names).toContain('Ops.Bot'); @@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => { const tasks: TeamTaskWithKanban[] = [ { id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBe('t1'); }); @@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => { kanbanColumn: 'approved', }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBeNull(); }); @@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => { // kanbanColumn not set — stale data scenario }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBeNull(); }); @@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => { // Teammates sometimes send messages to "lead" instead of "team-lead", // creating a separate inbox file that the resolver picks up. const inboxNames = ['team-lead', 'lead', 'alice']; - const members = resolver.resolveMembers(config, [], inboxNames, [], []); + const members = resolver.resolveMembers(config, [], inboxNames, []); const names = members.map((m) => m.name); expect(names).toContain('team-lead'); @@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => { name: 'Team', members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }], }; - const members = resolver.resolveMembers(config, [], ['lead'], [], []); + const members = resolver.resolveMembers(config, [], ['lead'], []); const names = members.map((m) => m.name); expect(names).toContain('lead'); @@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => { const tasks: TeamTaskWithKanban[] = [ { id: 't1', subject: 'Work', status: 'completed', owner: 'bob' }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBeNull(); }); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index 24e1ca81..d6ff7de6 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -39,6 +39,16 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({ selectTeamDataForName: (_state: typeof storeState, teamName: string) => storeState.teamDataCacheByName[teamName] ?? (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null), + selectTeamMemberSnapshotsForName: (_state: typeof storeState, teamName: string) => + ( + storeState.teamDataCacheByName[teamName] ?? + (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null) + )?.members ?? [], + selectResolvedMembersForTeamName: (_state: typeof storeState, teamName: string) => + ( + storeState.teamDataCacheByName[teamName] ?? + (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null) + )?.members ?? [], })); vi.mock('zustand/react/shallow', () => ({ diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts index 280c0b17..23e66673 100644 --- a/test/renderer/components/team/members/MemberHoverCard.test.ts +++ b/test/renderer/components/team/members/MemberHoverCard.test.ts @@ -61,6 +61,16 @@ vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({ getCurrentProvisioningProgressForTeam: () => storeState.progress, + selectResolvedMemberForTeamName: (state: typeof storeState, teamName: string, memberName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find( + (candidate) => candidate.name === memberName + ) ?? null, + selectTeamMemberSnapshotsForName: (state: typeof storeState, teamName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members ?? [], + selectTeamTasksForName: (state: typeof storeState, teamName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.tasks ?? [], + selectTeamIsAliveForName: (state: typeof storeState, teamName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.isAlive, })); vi.mock('@renderer/hooks/useTheme', () => ({ diff --git a/test/renderer/components/team/members/MemberMessagesTab.test.ts b/test/renderer/components/team/members/MemberMessagesTab.test.ts index fad6eedb..871e86e7 100644 --- a/test/renderer/components/team/members/MemberMessagesTab.test.ts +++ b/test/renderer/components/team/members/MemberMessagesTab.test.ts @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MemberMessagesTab } from '@renderer/components/team/members/MemberMessagesTab'; +import { useStore } from '@renderer/store'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -48,11 +49,27 @@ describe('MemberMessagesTab', () => { nextCursor: null, hasMore: false, }); + useStore.setState({ + teamMessagesByName: { + 'demo-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-empty', + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); }); afterEach(() => { document.body.innerHTML = ''; getMessagesPage.mockReset(); + useStore.setState({ teamMessagesByName: {} } as never); }); it('shows both messages and comments by default and filters them separately', async () => { @@ -110,10 +127,25 @@ describe('MemberMessagesTab', () => { document.body.appendChild(host); const root = createRoot(host); + useStore.setState({ + teamMessagesByName: { + 'demo-team': { + canonicalMessages: messages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); + await act(async () => { root.render( React.createElement(MemberMessagesTab, { - messages, teamName: 'demo-team', memberName: 'jack', members, @@ -123,6 +155,8 @@ describe('MemberMessagesTab', () => { await Promise.resolve(); }); + expect(getMessagesPage).not.toHaveBeenCalled(); + const getRenderedKinds = () => Array.from(host.querySelectorAll('[data-testid="activity-item"]')).map((node) => node.getAttribute('data-kind') @@ -209,10 +243,35 @@ describe('MemberMessagesTab', () => { document.body.appendChild(host); const root = createRoot(host); + useStore.setState({ + teamMessagesByName: { + 'demo-team': { + canonicalMessages: [ + { + from: 'team-lead', + to: 'alice', + text: 'Message for another member', + summary: 'Message for another member', + timestamp: '2026-04-13T13:34:00.000Z', + read: false, + messageId: 'msg-other-member', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-older', + nextCursor: 'older-cursor', + hasMore: true, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); + await act(async () => { root.render( React.createElement(MemberMessagesTab, { - messages: [], teamName: 'demo-team', memberName: 'jack', members, @@ -222,6 +281,7 @@ describe('MemberMessagesTab', () => { await Promise.resolve(); }); + expect(getMessagesPage).not.toHaveBeenCalled(); expect(host.textContent).toContain('No activity with this member'); expect(host.textContent).not.toContain('Load older messages'); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 5de525d6..cd276c9f 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -12,6 +12,21 @@ const storeState = { lastSendMessageResult: null, teams: [], openTeamTab: vi.fn(), + loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined), + teamMessagesByName: {} as Record< + string, + { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; + } + >, }; const readHookState = { @@ -146,6 +161,8 @@ describe('MessagesPanel idle summary invariants', () => { storeState.sendTeamMessage.mockClear(); storeState.sendCrossTeamMessage.mockClear(); storeState.openTeamTab.mockClear(); + storeState.loadOlderTeamMessages.mockClear(); + storeState.teamMessagesByName = {}; }); it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => { @@ -175,6 +192,17 @@ describe('MessagesPanel idle summary invariants', () => { ]; await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = { + canonicalMessages: messages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; root.render( React.createElement(MessagesPanel, { teamName: 'atlas-hq', @@ -182,7 +210,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages, timeWindow: null, teamSessionIds: new Set(), pendingRepliesByMember: {}, @@ -226,6 +253,17 @@ describe('MessagesPanel idle summary invariants', () => { ]; await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = { + canonicalMessages: messages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; root.render( React.createElement(MessagesPanel, { teamName: 'atlas-hq', @@ -233,7 +271,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages, timeWindow: null, teamSessionIds: new Set(), pendingRepliesByMember: { alice: pendingSentAtMs }, @@ -260,6 +297,17 @@ describe('MessagesPanel idle summary invariants', () => { const root = createRoot(host); await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = { + canonicalMessages: [makeMessage()], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; root.render( React.createElement(MessagesPanel, { teamName: 'atlas-hq', @@ -268,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages: [makeMessage()], timeWindow: null, teamSessionIds: new Set(), pendingRepliesByMember: {}, diff --git a/test/renderer/features/agent-graph/GraphActivityHud.test.ts b/test/renderer/features/agent-graph/GraphActivityHud.test.ts index b534a5e2..a51b7d3e 100644 --- a/test/renderer/features/agent-graph/GraphActivityHud.test.ts +++ b/test/renderer/features/agent-graph/GraphActivityHud.test.ts @@ -16,11 +16,10 @@ const teamState = { { name: 'jack', agentType: 'developer' }, ], tasks: [], - messages: [], }, teamDataCacheByName: new Map< string, - { members: Record[]; tasks: unknown[]; messages: unknown[] } + { members: Record[]; tasks: unknown[] } >([ [ 'demo-team', @@ -30,7 +29,6 @@ const teamState = { { name: 'jack', agentType: 'developer' }, ], tasks: [], - messages: [], }, ], ]), @@ -55,6 +53,12 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({ selectTeamDataForName: (_state: typeof teamState, teamName: string) => teamState.teamDataCacheByName.get(teamName) ?? (teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null), + selectResolvedMembersForTeamName: (_state: typeof teamState, teamName: string) => + ( + teamState.teamDataCacheByName.get(teamName) ?? + (teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null) + )?.members ?? [], + selectTeamMessages: () => [], })); vi.mock('zustand/react/shallow', () => ({ diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 4c2ba66f..70b33eeb 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1,16 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TeamGraphAdapter } from '@features/agent-graph/renderer/adapters/TeamGraphAdapter'; +import { + TeamGraphAdapter, + type TeamGraphData, +} from '@features/agent-graph/renderer/adapters/TeamGraphAdapter'; -import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; +import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team'; import type { GraphDataPort } from '@claude-teams/agent-graph'; function createBaseTeamData( - overrides?: Partial & { + overrides?: Partial & { tasks?: TeamTaskWithKanban[]; messages?: InboxMessage[]; } -): TeamData { +): TeamGraphData { + const { messages, ...restOverrides } = overrides ?? {}; return { teamName: 'my-team', config: { @@ -46,11 +50,11 @@ function createBaseTeamData( }, ], tasks: [], - messages: [], + messageFeed: messages ?? [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], isAlive: true, - ...overrides, + ...restOverrides, }; } diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts index 6c97a6ff..8ae5e030 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -1,25 +1,20 @@ import { describe, expect, it } from 'vitest'; import { + type ActivityEntrySourceData, buildInlineActivityEntries, getGraphLeadMemberName, } from '@features/agent-graph/core/domain/buildInlineActivityEntries'; -import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; +import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team'; function createBaseTeamData( - overrides?: Partial & { + overrides?: Partial & { tasks?: TeamTaskWithKanban[]; messages?: InboxMessage[]; } -): TeamData { +): ActivityEntrySourceData { return { - teamName: 'my-team', - config: { - name: 'My Team', - members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }], - projectPath: '/repo', - }, members: [ { name: 'team-lead', @@ -49,9 +44,6 @@ function createBaseTeamData( ], tasks: [], messages: [], - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], - isAlive: true, ...overrides, }; } diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 9d7fa351..8abe8f90 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -62,6 +62,7 @@ vi.mock('@renderer/api', () => ({ })); import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; +import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice'; import { api } from '@renderer/api'; describe('team change throttling', () => { @@ -69,17 +70,29 @@ describe('team change throttling', () => { beforeEach(async () => { vi.useFakeTimers(); + __resetTeamSliceModuleStateForTests(); const fetchTeams = vi.fn(async () => undefined); + const fetchMemberSpawnStatuses = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); + const refreshTeamMessagesHead = vi.fn(async () => ({ + feedChanged: true, + headChanged: true, + feedRevision: 'rev-1', + })); + const refreshMemberActivityMeta = vi.fn(async () => undefined); const refreshTeamChangePresence = vi.fn(async () => undefined); useStore.setState({ fetchTeams, + fetchMemberSpawnStatuses, refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, refreshTeamChangePresence, selectedTeamName: null, selectedTeamData: null, teamDataCacheByName: {}, + memberActivityMetaByTeam: {}, paneLayout: { focusedPaneId: 'p1', panes: [ @@ -103,6 +116,7 @@ describe('team change throttling', () => { afterEach(() => { cleanup?.(); cleanup = null; + __resetTeamSliceModuleStateForTests(); vi.mocked(console.warn).mockClear(); vi.useRealTimers(); }); @@ -149,10 +163,12 @@ describe('team change throttling', () => { expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); }); - it('lead-message refreshes detail only, not team list or tasks', async () => { + it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(state, 'refreshMemberActivityMeta'); // Emit a lead-message event hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); @@ -161,9 +177,11 @@ describe('team change throttling', () => { await vi.advanceTimersByTimeAsync(2100); expect(fetchTeamsSpy).not.toHaveBeenCalled(); - // Should trigger refreshTeamData at 800ms - expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('my-team'); }); it('lead-message refreshes visible graph tabs even when the team is not selected', async () => { @@ -174,7 +192,6 @@ describe('team change throttling', () => { config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -192,11 +209,88 @@ describe('team change throttling', () => { } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); + }); + + it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => { + useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000); + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], + activeTabId: 't1', + }, + ], + }, + } as never); + + const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); + + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); + + await vi.advanceTimersByTimeAsync(800); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team'); + }); + + it('lead-message does not refresh hidden inactive teams without pending replies', async () => { + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], + activeTabId: 't1', + }, + ], + }, + } as never); + + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); + + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); + + await vi.advanceTimersByTimeAsync(800); + expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalledWith('other-team'); + expect(refreshMemberActivityMetaSpy).not.toHaveBeenCalledWith('other-team'); + }); + + it('member-spawn refreshes spawn statuses without forcing structural refresh', async () => { + const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses'); + const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); + + hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(500); + expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + }); + + it('inbox/config/process do not refresh member spawn statuses by default', async () => { + const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses'); + + hoisted.onTeamChangeCb?.({}, { type: 'inbox', teamName: 'my-team' }); + hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' }); + hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(800); + expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled(); }); it('lead-message does not call fetchAllTasks', async () => { @@ -209,6 +303,17 @@ describe('team change throttling', () => { expect(fetchAllTasksSpy).not.toHaveBeenCalled(); }); + it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => { + useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000); + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team'); + }); + it('log-source-change refreshes only task change presence', async () => { useStore.setState({ selectedTeamName: 'my-team', @@ -217,7 +322,6 @@ describe('team change throttling', () => { config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -248,7 +352,6 @@ describe('team change throttling', () => { config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -258,7 +361,6 @@ describe('team change throttling', () => { config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -318,7 +420,6 @@ describe('team change throttling', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -354,7 +455,6 @@ describe('team change throttling', () => { config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -387,7 +487,6 @@ describe('team change throttling', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -448,6 +547,7 @@ describe('team change throttling', () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead'); // Fire rapid events for my-team (throttled) hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); @@ -459,9 +559,10 @@ describe('team change throttling', () => { await vi.advanceTimersByTimeAsync(800); // Both teams should get exactly 1 refresh each - expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team', { withDedup: true }); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(2); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); }); it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => { @@ -477,7 +578,6 @@ describe('team change throttling', () => { config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index d91c919f..5fe48ef9 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -4,12 +4,17 @@ import { create } from 'zustand'; import { __resetTeamSliceModuleStateForTests, createTeamSlice, + selectResolvedMemberForTeamName, getCurrentProvisioningProgressForTeam, + selectMemberMessagesForTeamMember, + selectResolvedMembersForTeamName, } from '../../../src/renderer/store/slices/teamSlice'; const hoisted = vi.hoisted(() => ({ list: vi.fn(), getData: vi.fn(), + getMessagesPage: vi.fn(), + getMemberActivityMeta: vi.fn(), createTeam: vi.fn(), getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), @@ -29,6 +34,8 @@ vi.mock('@renderer/api', () => ({ teams: { list: hoisted.list, getData: hoisted.getData, + getMessagesPage: hoisted.getMessagesPage, + getMemberActivityMeta: hoisted.getMemberActivityMeta, createTeam: hoisted.createTeam, getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, @@ -87,6 +94,28 @@ function createSliceStore() { })); } +function createTeamSnapshot( + overrides: Record = {} +): { + teamName: string; + config: { name: string; members?: unknown[]; projectPath?: string }; + tasks: unknown[]; + members: unknown[]; + kanbanState: { teamName: string; reviewers: unknown[]; tasks: Record }; + processes: unknown[]; + isAlive?: boolean; +} { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + function createMemberSpawnStatus(overrides: Record = {}) { return { status: 'online', @@ -125,19 +154,33 @@ function createMemberSpawnSnapshot(overrides: Record = {}) { }; } +function createDeferredPromise() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); __resetTeamSliceModuleStateForTests(); hoisted.list.mockResolvedValue([]); - hoisted.getData.mockResolvedValue({ - teamName: 'my-team', - config: { name: 'My Team' }, - tasks: [], - members: [], + hoisted.getData.mockResolvedValue(createTeamSnapshot()); + hoisted.getMessagesPage.mockResolvedValue({ messages: [], - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + }); + hoisted.getMemberActivityMeta.mockResolvedValue({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: {}, + feedRevision: 'rev-1', }); hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' }); hoisted.requestReview.mockResolvedValue(undefined); @@ -332,6 +375,701 @@ describe('teamSlice actions', () => { expect(updateTabLabel).toHaveBeenCalledWith('graph-tab', 'Northstar Graph'); }); + it('clears stale selectedTeamData immediately when selecting an uncached team', async () => { + const store = createSliceStore(); + const nextTeamData = createDeferredPromise>(); + + store.setState({ + selectedTeamName: 'alpha-team', + selectedTeamData: createTeamSnapshot({ + teamName: 'alpha-team', + config: { name: 'Alpha Team' }, + }), + }); + + hoisted.getData.mockImplementationOnce(async () => nextTeamData.promise); + + const selectPromise = store.getState().selectTeam('beta-team'); + + expect(store.getState().selectedTeamName).toBe('beta-team'); + expect(store.getState().selectedTeamLoading).toBe(true); + expect(store.getState().selectedTeamData).toBeNull(); + + nextTeamData.resolve( + createTeamSnapshot({ + teamName: 'beta-team', + config: { name: 'Beta Team' }, + }) + ); + await selectPromise; + + expect(store.getState().selectedTeamData?.teamName).toBe('beta-team'); + }); + + it('repoints selectedTeamData to the cached snapshot immediately on team switch', async () => { + const store = createSliceStore(); + const nextTeamData = createDeferredPromise>(); + const cachedBeta = createTeamSnapshot({ + teamName: 'beta-team', + config: { name: 'Beta Team' }, + }); + + store.setState({ + selectedTeamName: 'alpha-team', + selectedTeamData: createTeamSnapshot({ + teamName: 'alpha-team', + config: { name: 'Alpha Team' }, + }), + teamDataCacheByName: { + 'beta-team': cachedBeta, + }, + }); + + hoisted.getData.mockImplementationOnce(async () => nextTeamData.promise); + + const selectPromise = store.getState().selectTeam('beta-team'); + + expect(store.getState().selectedTeamName).toBe('beta-team'); + expect(store.getState().selectedTeamData).toBe(cachedBeta); + + nextTeamData.resolve(cachedBeta); + await selectPromise; + + expect(store.getState().selectedTeamData).toBe(cachedBeta); + }); + + it('distinguishes historical feed changes from visible head changes in refreshTeamMessagesHead', async () => { + const store = createSliceStore(); + const existingMessages = [ + { + from: 'team-lead', + text: 'Stable head', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-1', + }, + ]; + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: existingMessages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-1', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockResolvedValueOnce({ + messages: existingMessages.map((message) => ({ ...message })), + nextCursor: 'cursor-1', + hasMore: true, + feedRevision: 'rev-2', + }); + + const result = await store.getState().refreshTeamMessagesHead('my-team'); + const nextEntry = store.getState().teamMessagesByName['my-team']; + + expect(result).toEqual({ + feedChanged: true, + headChanged: false, + feedRevision: 'rev-2', + }); + expect(nextEntry?.canonicalMessages).toBe(existingMessages); + expect(nextEntry?.feedRevision).toBe('rev-2'); + expect(nextEntry?.nextCursor).toBe('cursor-1'); + expect(nextEntry?.hasMore).toBe(true); + }); + + it('keeps loaded older tail when head refresh updates only the visible top slice', async () => { + const store = createSliceStore(); + const existingMessages = [ + { + from: 'team-lead', + text: 'Head 2', + timestamp: '2026-03-20T08:00:03.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-4', + }, + { + from: 'alice', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'inbox', + messageId: 'msg-3', + }, + { + from: 'bob', + text: 'Older 1', + timestamp: '2026-03-20T08:00:01.000Z', + read: true, + source: 'inbox', + messageId: 'msg-2', + }, + { + from: 'carol', + text: 'Older 2', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'msg-1', + }, + ]; + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: existingMessages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-tail', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Fresh head', + timestamp: '2026-03-20T08:00:04.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-5', + }, + existingMessages[0], + existingMessages[1], + ], + nextCursor: 'cursor-head', + hasMore: true, + feedRevision: 'rev-2', + }); + + const result = await store.getState().refreshTeamMessagesHead('my-team'); + const nextEntry = store.getState().teamMessagesByName['my-team']; + + expect(result).toEqual({ + feedChanged: true, + headChanged: true, + feedRevision: 'rev-2', + }); + expect( + nextEntry?.canonicalMessages.map((message: { messageId?: string }) => message.messageId) + ).toEqual([ + 'msg-5', + 'msg-4', + 'msg-3', + 'msg-2', + 'msg-1', + ]); + expect(nextEntry?.nextCursor).toBe('cursor-tail'); + expect(nextEntry?.hasMore).toBe(true); + }); + + it('single-flights concurrent head refreshes and runs one fresh follow-up pass', async () => { + const store = createSliceStore(); + const firstRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + read: boolean; + source: string; + messageId: string; + }>; + nextCursor: string | null; + hasMore: boolean; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: false, + }, + }, + }); + + hoisted.getMessagesPage + .mockImplementationOnce(() => firstRequest.promise) + .mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Newest head', + timestamp: '2026-03-20T08:00:01.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-2', + }, + ], + nextCursor: 'cursor-2', + hasMore: true, + feedRevision: 'rev-2', + }); + + const p1 = store.getState().refreshTeamMessagesHead('my-team'); + const p2 = store.getState().refreshTeamMessagesHead('my-team'); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + + firstRequest.resolve({ + messages: [ + { + from: 'team-lead', + text: 'Old head', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-1', + }, + ], + nextCursor: 'cursor-1', + hasMore: true, + feedRevision: 'rev-1', + }); + + await p1; + await p2; + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + expect(store.getState().teamMessagesByName['my-team']).toMatchObject({ + feedRevision: 'rev-2', + nextCursor: 'cursor-2', + hasMore: true, + loadingHead: false, + headHydrated: true, + }); + expect(store.getState().teamMessagesByName['my-team']?.canonicalMessages[0]?.messageId).toBe( + 'msg-2' + ); + }); + + it('serializes head refresh behind an in-flight older-page load', async () => { + const store = createSliceStore(); + const olderRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + read: boolean; + source: string; + messageId: string; + }>; + nextCursor: string | null; + hasMore: boolean; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-3', + }, + { + from: 'alice', + text: 'Head 0', + timestamp: '2026-03-20T08:00:01.000Z', + read: true, + source: 'inbox', + messageId: 'msg-2', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-older', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage + .mockImplementationOnce(() => olderRequest.promise) + .mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Fresh head', + timestamp: '2026-03-20T08:00:03.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-4', + }, + { + from: 'team-lead', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-3', + }, + ], + nextCursor: 'cursor-head', + hasMore: true, + feedRevision: 'rev-2', + }); + + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + const headPromise = store.getState().refreshTeamMessagesHead('my-team'); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(hoisted.getMessagesPage.mock.calls[0]).toEqual([ + 'my-team', + { cursor: 'cursor-older', limit: 50 }, + ]); + + olderRequest.resolve({ + messages: [ + { + from: 'bob', + text: 'Older tail', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'msg-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + }); + + await olderPromise; + await headPromise; + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + expect(hoisted.getMessagesPage.mock.calls[1]).toEqual(['my-team', { limit: 50 }]); + expect( + store + .getState() + .teamMessagesByName['my-team']?.canonicalMessages.map( + (message: { messageId?: string }) => message.messageId + ) + ).toEqual(['msg-4', 'msg-3', 'msg-2', 'msg-1']); + }); + + it('schedules pending-reply refresh through store-owned timers', async () => { + vi.useFakeTimers(); + try { + const store = createSliceStore(); + const refreshTeamMessagesHeadSpy = vi + .spyOn(store.getState(), 'refreshTeamMessagesHead') + .mockResolvedValue({ + feedChanged: true, + headChanged: true, + feedRevision: 'rev-2', + }); + const refreshMemberActivityMetaSpy = vi + .spyOn(store.getState(), 'refreshMemberActivityMeta') + .mockResolvedValue(undefined); + + store.getState().syncTeamPendingReplyRefresh('my-team', true, 1_000); + + await vi.advanceTimersByTimeAsync(999); + expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1); + + store.getState().syncTeamPendingReplyRefresh('my-team', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', false); + + await vi.advanceTimersByTimeAsync(1_000); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('single-flights concurrent member activity refreshes and re-fetches after feed revision changes', async () => { + const store = createSliceStore(); + const firstRequest = createDeferredPromise<{ + teamName: string; + computedAt: string; + members: Record; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: {}, + }); + + hoisted.getMemberActivityMeta + .mockImplementationOnce(() => firstRequest.promise) + .mockResolvedValueOnce({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:01.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:01.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-2', + }); + + const p1 = store.getState().refreshMemberActivityMeta('my-team'); + + store.setState((state: any) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + 'my-team': { + ...state.teamMessagesByName['my-team'], + feedRevision: 'rev-2', + }, + }, + })); + + const p2 = store.getState().refreshMemberActivityMeta('my-team'); + + expect(hoisted.getMemberActivityMeta).toHaveBeenCalledTimes(1); + + firstRequest.resolve({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 2, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-1', + }); + + await p1; + await p2; + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getMemberActivityMeta).toHaveBeenCalledTimes(2); + expect(store.getState().memberActivityMetaByTeam['my-team']).toMatchObject({ + feedRevision: 'rev-2', + members: { + alice: { + messageCountExact: 3, + }, + }, + }); + }); + + it('reuses member activity facts and resolved member refs when only meta wrapper fields change', async () => { + const store = createSliceStore(); + const initialMetaMembers = { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 2, + latestAuthoredMessageSignalsTermination: false, + }, + }; + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: createTeamSnapshot({ + members: [ + { + name: 'alice', + currentTaskId: null, + taskCount: 0, + }, + ], + }), + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [ + { + name: 'alice', + currentTaskId: null, + taskCount: 0, + }, + ], + }), + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-2', + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: { + 'my-team': { + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: initialMetaMembers, + feedRevision: 'rev-1', + }, + }, + }); + + const initialResolvedMembers = selectResolvedMembersForTeamName(store.getState(), 'my-team'); + + hoisted.getMemberActivityMeta.mockResolvedValueOnce({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:05.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 2, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-2', + }); + + await store.getState().refreshMemberActivityMeta('my-team'); + + const nextMeta = store.getState().memberActivityMetaByTeam['my-team']; + const nextResolvedMembers = selectResolvedMembersForTeamName(store.getState(), 'my-team'); + + expect(nextMeta?.feedRevision).toBe('rev-2'); + expect(nextMeta?.members).toBe(initialMetaMembers); + expect(nextResolvedMembers).toBe(initialResolvedMembers); + }); + + it('memoizes team-scoped member messages selectors over the merged message feed', () => { + const store = createSliceStore(); + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + to: 'alice', + text: 'Ping Alice', + summary: 'Ping Alice', + timestamp: '2026-03-12T10:00:00.000Z', + read: false, + messageId: 'msg-1', + }, + { + from: 'team-lead', + to: 'bob', + text: 'Ping Bob', + summary: 'Ping Bob', + timestamp: '2026-03-12T10:00:01.000Z', + read: false, + messageId: 'msg-2', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + const first = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + const second = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + + expect(first).toBe(second); + expect(first.map((message) => message.messageId)).toEqual(['msg-1']); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + to: 'alice', + text: 'Ping Alice', + summary: 'Ping Alice', + timestamp: '2026-03-12T10:00:00.000Z', + read: false, + messageId: 'msg-1', + }, + { + from: 'alice', + to: 'team-lead', + text: 'Reply from Alice', + summary: 'Reply from Alice', + timestamp: '2026-03-12T10:00:02.000Z', + read: false, + messageId: 'msg-3', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-2', + nextCursor: null, + hasMore: false, + lastFetchedAt: 1, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + const third = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + expect(third).not.toBe(first); + expect(third.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']); + }); + it('removes non-selected team cache entries on permanent delete', async () => { const store = createSliceStore(); store.setState({ @@ -341,7 +1079,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -351,7 +1088,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -360,7 +1096,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -383,7 +1118,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -393,7 +1127,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -417,7 +1150,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -441,7 +1173,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }, selectedTeamError: null, @@ -466,7 +1197,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [{ from: 'lead', text: 'Hello', timestamp: '2026-01-01T00:00:00Z' }], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }; store.setState({ @@ -484,6 +1214,162 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamData).toEqual(existingData); }); + it('reuses the existing selectedTeamData ref on a semantic no-op refresh', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + tasks: [ + { + id: 'task-1', + subject: 'Stable task', + status: 'pending', + createdAt: '2026-03-20T08:00:00.000Z', + updatedAt: '2026-03-20T08:00:00.000Z', + }, + ], + members: [ + { + name: 'alice', + currentTaskId: 'task-1', + taskCount: 1, + }, + ], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: { + 'my-team': existingData, + }, + selectedTeamError: 'stale error', + }); + + hoisted.getData.mockResolvedValue({ + ...existingData, + tasks: existingData.tasks.map((task: any) => ({ ...task })), + members: existingData.members.map((member: any) => ({ ...member })), + kanbanState: { + ...existingData.kanbanState, + reviewers: [...existingData.kanbanState.reviewers], + tasks: { ...existingData.kanbanState.tasks }, + }, + processes: [...existingData.processes], + }); + + await store.getState().refreshTeamData('my-team'); + + expect(store.getState().selectedTeamData).toBe(existingData); + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().selectedTeamError).toBeNull(); + }); + + it('memoizes focused resolved member selection against unrelated member activity churn', () => { + const aliceSnapshot = { + name: 'alice', + currentTaskId: null, + taskCount: 0, + role: 'Reviewer', + }; + const bobSnapshot = { + name: 'bob', + currentTaskId: null, + taskCount: 0, + role: 'Builder', + }; + const baseState = { + selectedTeamName: 'my-team', + selectedTeamData: null, + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [aliceSnapshot, bobSnapshot], + }), + }, + memberActivityMetaByTeam: { + 'my-team': { + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-1', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + bob: { + memberName: 'bob', + lastAuthoredMessageAt: '2026-03-12T10:01:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }, + }, + }; + + const firstAlice = selectResolvedMemberForTeamName(baseState as never, 'my-team', 'alice'); + const nextState = { + ...baseState, + memberActivityMetaByTeam: { + 'my-team': { + ...baseState.memberActivityMetaByTeam['my-team'], + computedAt: '2026-03-12T10:02:00.000Z', + feedRevision: 'rev-2', + members: { + ...baseState.memberActivityMetaByTeam['my-team'].members, + bob: { + ...baseState.memberActivityMetaByTeam['my-team'].members.bob, + messageCountExact: 2, + }, + }, + }, + }, + }; + + const secondAlice = selectResolvedMemberForTeamName(nextState as never, 'my-team', 'alice'); + + expect(firstAlice).not.toBeNull(); + expect(secondAlice).toBe(firstAlice); + }); + + it('re-canonicalizes selectedTeamData into the cache on a no-op refresh', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + tasks: [ + { + id: 'task-1', + subject: 'Stable task', + status: 'pending', + createdAt: '2026-03-20T08:00:00.000Z', + updatedAt: '2026-03-20T08:00:00.000Z', + }, + ], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: {}, + }); + + hoisted.getData.mockResolvedValue({ + ...existingData, + tasks: existingData.tasks.map((task: any) => ({ ...task })), + members: existingData.members.map((member: any) => ({ ...member })), + kanbanState: { + ...existingData.kanbanState, + reviewers: [...existingData.kanbanState.reviewers], + tasks: { ...existingData.kanbanState.tasks }, + }, + processes: [...existingData.processes], + }); + + await store.getState().refreshTeamData('my-team'); + + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().selectedTeamData).toBe(existingData); + }); + it('clears non-selected cache on TEAM_DRAFT refresh failure', async () => { const store = createSliceStore(); store.setState({ @@ -493,7 +1379,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -503,7 +1388,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -527,7 +1411,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -537,7 +1420,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -561,7 +1443,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }, selectedTeamError: 'Previous failure', @@ -584,7 +1465,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }; store.setState({ @@ -664,7 +1544,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -714,7 +1593,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -749,7 +1627,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -774,7 +1651,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [{ from: 'team-lead', text: 'Ping', timestamp: '2026-03-01T10:10:00.000Z' }], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -812,7 +1688,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -858,7 +1733,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index f24f8896..667c3774 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -20,12 +20,6 @@ describe('buildTeamProvisioningPresentation', () => { members: [ { name: 'team-lead', - agentType: 'team-lead', - status: 'active', - currentTaskId: null, - taskCount: 0, - lastActiveAt: null, - messageCount: 0, }, ], memberSpawnStatuses: {},