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/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts index 0a842b91..fbfa2b38 100644 --- a/packages/agent-graph/src/hooks/useGraphCamera.ts +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -4,7 +4,7 @@ * All state in refs — no React re-renders. */ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import type { GraphNode } from '../ports/types'; import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; import type { WorldBounds } from '../layout/launchAnchor'; @@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult { t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); }, []); - return { - transformRef, - screenToWorld, - worldToScreen, - handleWheel, - handlePanStart, - handlePanMove, - handlePanEnd, - zoomToFit, - zoomIn, - zoomOut, - updateInertia, - }; + return useMemo( + () => ({ + transformRef, + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + }), + [ + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + ] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 33862ef3..7fdbf13e 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -3,7 +3,7 @@ * Delegates hit testing to strategy pattern. */ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import type { GraphNode } from '../ports/types'; import { ANIM } from '../constants/canvas-constants'; import { findNodeAt } from '../canvas/hit-detection'; @@ -81,13 +81,16 @@ export function useGraphInteraction( return findNodeAt(wx, wy, nodes); }, []); - return { - hoveredNodeId, - dragNodeId, - isDragging, - handleMouseDown, - handleMouseMove, - handleMouseUp, - handleDoubleClick, - }; + return useMemo( + () => ({ + hoveredNodeId, + dragNodeId, + isDragging, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleDoubleClick, + }), + [handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index cd4d62ad..db34adde 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { getStateColor } from '../constants/colors'; @@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult { }; }, []); - return { - stateRef, - updateData, - tick, - setNodePosition, - clearNodePosition, - clearTransientOwnerPositions, - resolveNearestOwnerSlot, - getLaunchAnchorWorldPosition: (leadNodeId: string) => - launchAnchorPositionsRef.current.get(leadNodeId) ?? null, - getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, - getExtraWorldBounds: () => extraWorldBoundsRef.current, - }; + return useMemo( + () => ({ + stateRef, + updateData, + tick, + setNodePosition, + clearNodePosition, + clearTransientOwnerPositions, + resolveNearestOwnerSlot, + getLaunchAnchorWorldPosition: (leadNodeId: string) => + launchAnchorPositionsRef.current.get(leadNodeId) ?? null, + getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, + getExtraWorldBounds: () => extraWorldBoundsRef.current, + }), + [ + updateData, + tick, + setNodePosition, + clearNodePosition, + clearTransientOwnerPositions, + resolveNearestOwnerSlot, + ] + ); } function applySnapshotToNodes( diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index c9f1d744..4f26e365 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -32,7 +32,7 @@ import { findNodeAt, getEdgeMidpoint, } from '../canvas/hit-detection'; -import { ANIM_SPEED } from '../constants/canvas-constants'; +import { ANIM, ANIM_SPEED } from '../constants/canvas-constants'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; export interface GraphViewProps { @@ -148,13 +148,6 @@ export function GraphView({ // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); const camera = useGraphCamera(); - - // Stable refs for RAF loop (avoid recreating animate on hook identity change) - const simulationRef = useRef(simulation); - simulationRef.current = simulation; - const cameraRef = useRef(camera); - cameraRef.current = camera; - const interaction = useGraphInteraction( useCallback( (nodeId: string, x: number, y: number) => { @@ -164,6 +157,20 @@ export function GraphView({ ) ); + // Stable refs for RAF loop (avoid recreating animate on hook identity change) + const simulationRef = useRef(simulation); + simulationRef.current = simulation; + const cameraRef = useRef(camera); + cameraRef.current = camera; + const interactionRef = useRef(interaction); + interactionRef.current = interaction; + const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>( + null + ); + const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>( + null + ); + const getVisibleNodes = useCallback( (nodes: GraphNode[]): GraphNode[] => nodes.filter((node) => { @@ -433,16 +440,16 @@ export function GraphView({ }, []); useLayoutEffect(() => { - if (!isSurfaceActive) { + if (isSurfaceActive) { return; } - interaction.handleMouseUp(); - simulation.clearTransientOwnerPositions(); + interactionRef.current.handleMouseUp(); + simulationRef.current.clearTransientOwnerPositions(); dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; setInteractionGuards(false); - }, [interaction, isSurfaceActive, simulation]); + }, [isSurfaceActive, setInteractionGuards]); const handleWheel = useCallback( (e: WheelEvent) => { @@ -454,7 +461,13 @@ export function GraphView({ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); - const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null); + const edgeMouseDownRef = useRef<{ + id: string; + worldX: number; + worldY: number; + clientX: number; + clientY: number; + } | null>(null); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -491,7 +504,13 @@ export function GraphView({ if (hitEdge) { markUserInteracted(); isPanningRef.current = false; - edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y }; + edgeMouseDownRef.current = { + id: hitEdge, + worldX: world.x, + worldY: world.y, + clientX: e.clientX, + clientY: e.clientY, + }; hoveredEdgeIdRef.current = hitEdge; } else { // Hit empty space → pan @@ -518,11 +537,6 @@ export function GraphView({ const processActivePointerMove = useCallback( (clientX: number, clientY: number) => { - if (!activePrimaryInteractionRef.current) { - dragPreviewRef.current = null; - return false; - } - if (isPanningRef.current) { if (typeof document !== 'undefined') { document.getSelection()?.removeAllRanges(); @@ -531,6 +545,36 @@ export function GraphView({ return true; } + const edgeMouseDown = edgeMouseDownRef.current; + if ( + edgeMouseDown && + !interaction.dragNodeId.current && + !interaction.isDragging.current + ) { + const dx = clientX - edgeMouseDown.clientX; + const dy = clientY - edgeMouseDown.clientY; + if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } + hoveredEdgeIdRef.current = null; + edgeMouseDownRef.current = null; + isPanningRef.current = true; + camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY); + camera.handlePanMove(clientX, clientY); + return true; + } + } + + if ( + !activePrimaryInteractionRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current + ) { + dragPreviewRef.current = null; + return false; + } + const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { dragPreviewRef.current = null; @@ -627,8 +671,8 @@ export function GraphView({ if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); - const dx = world.x - edgeMouseDownRef.current.x; - const dy = world.y - edgeMouseDownRef.current.y; + const dx = world.x - edgeMouseDownRef.current.worldX; + const dy = world.y - edgeMouseDownRef.current.worldY; if (dx * dx + dy * dy <= 25) { clickedEdgeId = edgeMouseDownRef.current.id; } @@ -656,6 +700,8 @@ export function GraphView({ }, [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); + processActivePointerMoveRef.current = processActivePointerMove; + completePointerInteractionRef.current = completePointerInteraction; const handleMouseMove = useCallback( (e: React.MouseEvent) => { @@ -711,36 +757,40 @@ export function GraphView({ if ( !activePrimaryInteractionRef.current && !isPanningRef.current && - !interaction.dragNodeId.current && - !interaction.isDragging.current && + !interactionRef.current.dragNodeId.current && + !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { return; } event.preventDefault(); - processActivePointerMove(event.clientX, event.clientY); + processActivePointerMoveRef.current?.(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( !activePrimaryInteractionRef.current && !isPanningRef.current && - !interaction.dragNodeId.current && - !interaction.isDragging.current && + !interactionRef.current.dragNodeId.current && + !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { setInteractionGuards(false); return; } - completePointerInteraction(event.clientX, event.clientY); + completePointerInteractionRef.current?.(event.clientX, event.clientY); }; const clearInteraction = (): void => { - if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { + if ( + !activePrimaryInteractionRef.current && + !isPanningRef.current && + !interactionRef.current.isDragging.current + ) { return; } - interaction.handleMouseUp(); - camera.handlePanEnd(); + interactionRef.current.handleMouseUp(); + cameraRef.current.handlePanEnd(); isPanningRef.current = false; edgeMouseDownRef.current = null; dragPreviewRef.current = null; @@ -756,9 +806,14 @@ export function GraphView({ window.removeEventListener('mouseup', handleWindowMouseUp); window.removeEventListener('blur', clearInteraction); window.removeEventListener('dragstart', clearInteraction); + }; + }, [setInteractionGuards]); + + useEffect(() => { + return () => { setInteractionGuards(false); }; - }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); + }, [setInteractionGuards]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index cfbcbaa5..69b58351 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 @@ -57,12 +57,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 = ''; @@ -89,7 +95,7 @@ export class TeamGraphAdapter { * Adapt team data into a GraphDataPort snapshot. */ adapt( - teamData: TeamData | null, + teamData: TeamGraphData | null, teamName: string, spawnStatuses?: Record, leadActivity?: LeadActivityState, @@ -190,7 +196,7 @@ export class TeamGraphAdapter { this.#buildMessageParticles( particles, nodes, - teamData.messages, + teamData.messageFeed, teamName, leadId, leadName, @@ -233,11 +239,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 #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map { + static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map { return buildGraphMemberNodeIdAliasMap( teamName, data.members.filter((member) => !isLeadMember(member)) @@ -245,7 +251,7 @@ export class TeamGraphAdapter { } static #buildLayoutPort( - data: TeamData, + data: TeamGraphData, teamName: string, slotAssignments?: Record ): GraphLayoutPort { @@ -263,7 +269,7 @@ export class TeamGraphAdapter { ); const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); - const pushMember = (member: TeamData['members'][number] | undefined): void => { + const pushMember = (member: TeamGraphData['members'][number] | undefined): void => { if (!member) { return; } @@ -315,7 +321,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) { @@ -337,9 +343,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); } @@ -360,7 +366,7 @@ export class TeamGraphAdapter { #buildLeadNode( nodes: GraphNode[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, leadName: string, pendingApprovalAgents?: Set, @@ -456,7 +462,7 @@ export class TeamGraphAdapter { nodes: GraphNode[], edges: GraphEdge[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByAlias: ReadonlyMap, spawnStatuses?: Record, @@ -560,14 +566,14 @@ export class TeamGraphAdapter { #buildTaskNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, commentReadState?: Record, memberNodeIdByAlias?: ReadonlyMap, leadId?: string, leadName?: string ): void { - const taskStateById = new Map>(); + const taskStateById = new Map>(); const taskDisplayIds = new Map(); const memberColorByName = new Map(); @@ -752,7 +758,7 @@ export class TeamGraphAdapter { #buildProcessNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByAlias?: ReadonlyMap ): void { @@ -830,7 +836,7 @@ export class TeamGraphAdapter { #attachActivityFeeds( nodes: GraphNode[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string @@ -847,7 +853,10 @@ export class TeamGraphAdapter { } const entriesByOwnerNodeId = buildInlineActivityEntries({ - data, + data: { + ...data, + messages: data.messageFeed, + }, teamName, leadId, leadName, @@ -1008,7 +1017,7 @@ export class TeamGraphAdapter { #buildCommentParticles( particles: GraphParticle[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string, @@ -1101,8 +1110,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..84cc991c 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 { TeamGraphData } from '../adapters/TeamGraphAdapter'; +import type { TeamSummary } from '@shared/types/team'; 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 42545bb3..2506d5a0 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -10,20 +10,25 @@ import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, isTeamGraphSlotPersistenceDisabled, + selectResolvedMembersForTeamName, selectTeamDataForName, + selectTeamMessages, } from '@renderer/store/slices/teamSlice'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; import type { GraphDataPort } from '@claude-teams/agent-graph'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); const { - teamData, + teamSnapshot, + members, + messages, spawnStatuses, leadActivity, leadContext, @@ -38,7 +43,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, @@ -64,6 +71,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); const effectiveSlotAssignments = useMemo(() => { @@ -97,9 +115,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const currentAssignment = slotAssignments[stableOwnerId]; const defaultAssignment = defaultSeed.assignments[stableOwnerId]; return ( - currentAssignment && - defaultAssignment && - currentAssignment.ringIndex === defaultAssignment.ringIndex && + currentAssignment?.ringIndex === defaultAssignment?.ringIndex && currentAssignment.sectorIndex === defaultAssignment.sectorIndex ); }); diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts index ed30a998..b7565a16 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity'; @@ -8,6 +9,7 @@ import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; export function useTeamGraphSurfaceActions(teamName: string): { openTeamPage: () => void; + resetOwnerSlotAssignmentsToDefaults: () => void; commitOwnerSlotDrop: (payload: { nodeId: string; assignment: GraphOwnerSlotAssignment; @@ -19,6 +21,13 @@ export function useTeamGraphSurfaceActions(teamName: string): { useStore.getState().openTeamTab(teamName); }, [teamName]); + const resetOwnerSlotAssignmentsToDefaults = useCallback(() => { + if (!isTeamGraphSlotPersistenceDisabled()) { + return; + } + useStore.getState().resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [teamName]); + const commitOwnerSlotDrop = useCallback( (payload: { nodeId: string; @@ -51,6 +60,7 @@ export function useTeamGraphSurfaceActions(teamName: string): { return { openTeamPage, + resetOwnerSlotAssignmentsToDefaults, commitOwnerSlotDrop, }; } diff --git a/src/features/agent-graph/renderer/index.ts b/src/features/agent-graph/renderer/index.ts index 91f45840..ba6320ac 100644 --- a/src/features/agent-graph/renderer/index.ts +++ b/src/features/agent-graph/renderer/index.ts @@ -5,6 +5,7 @@ * into ui/, hooks/, or core/ directly. */ +export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntries'; export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries'; export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity'; export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; diff --git a/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx index e75f5696..363cc711 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx @@ -1,7 +1,7 @@ import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { - resolveMessageRenderProps, type MessageContext, + resolveMessageRenderProps, } from '@renderer/components/team/activity/activityMessageContext'; import type { diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 1e26cf33..4e31fec3 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -13,6 +13,7 @@ import { type InlineActivityEntry, } from '../../core/domain/buildInlineActivityEntries'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; + import { GraphActivityCard } from './GraphActivityCard'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -74,6 +75,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( () => @@ -84,21 +88,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); @@ -350,7 +360,7 @@ export const GraphActivityHud = ({ }; }, [enabled, forwardWheelToGraph, visibleLanes]); - if (!enabled || !teamData || visibleLanes.length === 0) { + if (!enabled || !teamSnapshot || visibleLanes.length === 0) { return null; } @@ -477,7 +487,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 a9f9d441..a25e3c84 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/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 33948aba..69e3bc65 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -89,7 +89,6 @@ export const TeamGraphOverlay = ({ const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); - const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 4391e107..f4374d32 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -79,7 +79,6 @@ export const TeamGraphTab = ({ const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); - // Task action dispatchers const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index baa2f48b..79ae9ce3 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type DashboardRecentProject } from '@features/recent-projects/contracts'; import { api, isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; -import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize'; +import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize'; import { useShallow } from 'zustand/react/shallow'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; +import { buildActiveTeamsByProject } from '../utils/activeProjectTeams'; import { sortRecentProjectsByDisplayPriority, subscribeRecentProjectOpenHistory, @@ -62,16 +64,27 @@ export function useRecentProjectsSection( openProjectPath: (projectPath: string) => Promise; selectProjectFolder: () => Promise; } { - const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } = - useStore( - useShallow((state) => ({ - globalTasks: state.globalTasks, - globalTasksInitialized: state.globalTasksInitialized, - globalTasksLoading: state.globalTasksLoading, - fetchAllTasks: state.fetchAllTasks, - teams: state.teams, - })) - ); + const { + globalTasks, + globalTasksInitialized, + globalTasksLoading, + fetchAllTasks, + teams, + provisioningRuns, + currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam, + } = useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksInitialized: state.globalTasksInitialized, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + provisioningRuns: state.provisioningRuns, + currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam: state.provisioningSnapshotByTeam, + })) + ); const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const [recentProjects, setRecentProjects] = useState( @@ -92,6 +105,21 @@ export function useRecentProjectsSection( const recentProjectsRef = useRef( initialSnapshot?.payload.projects ?? [] ); + const provisioningState = useMemo( + () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), + [currentProvisioningRunIdByTeam, provisioningRuns] + ); + const provisioningTeamNames = useMemo( + () => + Object.keys(currentProvisioningRunIdByTeam).filter((teamName) => + isTeamProvisioningActive(provisioningState, teamName) + ), + [currentProvisioningRunIdByTeam, provisioningState] + ); + const provisioningTeamNamesKey = useMemo( + () => [...provisioningTeamNames].sort().join('\u0000'), + [provisioningTeamNames] + ); useEffect(() => { recentProjectsRef.current = recentProjects; @@ -173,7 +201,7 @@ export function useRecentProjectsSection( return () => { cancelled = true; }; - }, [teams]); + }, [provisioningTeamNamesKey, teams]); useEffect(() => { if (!searchQuery.trim()) { @@ -189,25 +217,13 @@ export function useRecentProjectsSection( const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); const activeTeamsByProject = useMemo(() => { - const aliveSet = new Set(aliveTeams); - const teamsByProject = new Map(); - - for (const team of teams) { - if (!team.projectPath || !aliveSet.has(team.teamName)) { - continue; - } - - const key = normalizePath(team.projectPath); - const existing = teamsByProject.get(key); - if (existing) { - existing.push(team); - } else { - teamsByProject.set(key, [team]); - } - } - - return teamsByProject; - }, [aliveTeams, teams]); + return buildActiveTeamsByProject({ + teams, + aliveTeamNames: aliveTeams, + provisioningTeamNames, + provisioningSnapshotByTeam, + }); + }, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]); const decoratedCards = useMemo( () => diff --git a/src/features/recent-projects/renderer/utils/activeProjectTeams.ts b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts new file mode 100644 index 00000000..273d60f8 --- /dev/null +++ b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts @@ -0,0 +1,48 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; + +import type { TeamSummary } from '@shared/types'; + +interface BuildActiveTeamsByProjectInput { + teams: TeamSummary[]; + aliveTeamNames: readonly string[]; + provisioningTeamNames: readonly string[]; + provisioningSnapshotByTeam: Record; +} + +export function buildActiveTeamsByProject({ + teams, + aliveTeamNames, + provisioningTeamNames, + provisioningSnapshotByTeam, +}: BuildActiveTeamsByProjectInput): Map { + const activeTeamNames = new Set([...aliveTeamNames, ...provisioningTeamNames]); + if (activeTeamNames.size === 0) { + return new Map(); + } + + const existingTeamNames = new Set(teams.map((team) => team.teamName)); + const syntheticProvisioningTeams = provisioningTeamNames + .filter((teamName) => !existingTeamNames.has(teamName)) + .map((teamName) => provisioningSnapshotByTeam[teamName]) + .filter((team): team is TeamSummary => Boolean(team)); + + const teamsByProject = new Map(); + const visibleTeams = + syntheticProvisioningTeams.length > 0 ? [...teams, ...syntheticProvisioningTeams] : teams; + + for (const team of visibleTeams) { + if (!team.projectPath || !activeTeamNames.has(team.teamName)) { + continue; + } + + const key = normalizePath(team.projectPath); + const existing = teamsByProject.get(key); + if (existing) { + existing.push(team); + } else { + teamsByProject.set(key, [team]); + } + } + + return teamsByProject; +} diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts index 25e60cab..66c4cd3f 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -157,7 +157,7 @@ function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): num } const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath)); - if (!foldedMatch || foldedMatch.exactPaths.size !== 1) { + if (foldedMatch?.exactPaths.size !== 1) { return 0; } diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index edbd8a4d..335a5e69 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -471,7 +471,7 @@ export class TmuxWslService { ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY], 6_000 ); - if (!result || result.exitCode !== 0 || !result.stdout.trim()) { + if (result?.exitCode !== 0 || !result.stdout.trim()) { return null; } diff --git a/src/main/index.ts b/src/main/index.ts index 05c22b62..42b9d15a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -88,6 +88,7 @@ import { } from './services/extensions'; import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; +import { clearAutoResumeService } from './services/team/AutoResumeService'; import { buildTeamControlApiBaseUrl, clearTeamControlApiState, @@ -100,7 +101,6 @@ import { type TeamReconcileTrigger, } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; -import { clearAutoResumeService } from './services/team/AutoResumeService'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { @@ -564,6 +564,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) { @@ -906,6 +913,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 f88bedce..ca2d6dbb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -16,13 +16,14 @@ import { TEAM_DELETE_DRAFT, TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, + TEAM_GET_AGENT_RUNTIME, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, - TEAM_GET_AGENT_RUNTIME, TEAM_GET_CLAUDE_LOGS, 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, @@ -59,8 +60,8 @@ import { TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, - TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -96,7 +97,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'; @@ -166,7 +167,6 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, SendMessageRequest, @@ -174,15 +174,16 @@ import type { TaskAttachmentMeta, TaskComment, TaskRef, + TeamAgentRuntimeSnapshot, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, @@ -190,6 +191,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TeamViewSnapshot, ToolApprovalFileContent, ToolApprovalSettings, UpdateKanbanPatch, @@ -206,6 +208,16 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; +function noteHeavyTeamDataWorkerFallback(operation: string): void { + if (!app.isPackaged) { + return; + } + + logger.error( + `[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path` + ); +} + async function getDurableLeadTeammateRoster( teamName: string, leadName: string @@ -436,6 +448,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; @@ -519,6 +544,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); @@ -595,6 +621,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); @@ -772,14 +799,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 @@ -791,9 +818,11 @@ async function handleGetData( logger.warn( `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` ); + noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } else { + noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } catch (error) { @@ -833,22 +862,30 @@ async function handleGetData( const displayName = data.config.name || tn; const projectPath = data.config.projectPath; - const live = provisioning.getLiveLeadProcessMessages(tn); + const durableMessages = Array.isArray((data as { messages?: unknown }).messages) + ? ((data as { messages?: typeof live }).messages ?? []) + : []; + if (live.length === 0) { - checkRateLimitMessages( - data.messages, - tn, - displayName, - projectPath, - isAlive, - currentLeadSessionId - ); - checkApiErrorMessages(data.messages, tn, displayName, projectPath); + if (durableMessages.length > 0) { + checkRateLimitMessages( + durableMessages, + tn, + displayName, + projectPath, + isAlive, + currentLeadSessionId + ); + checkApiErrorMessages(durableMessages, tn, displayName, projectPath); + } else { + scanTeamMessageNotifications(live, tn, displayName, projectPath); + } return { success: true, data: { ...data, isAlive } }; } - let merged = mergeLiveLeadProcessMessages(data.messages, live); - if (data.messages.length >= 50) { + + let merged = mergeLiveLeadProcessMessages(durableMessages, live); + if (durableMessages.length >= 50) { try { const newestPage = await teamDataService.getMessagesPage(tn, { limit: 50, @@ -866,7 +903,7 @@ async function handleGetData( checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId); checkApiErrorMessages(merged, tn, displayName, projectPath); - return { success: true, data: { ...data, isAlive, messages: merged } }; + return { success: true, data: { ...data, isAlive } }; } async function handleGetTaskChangePresence( @@ -1767,19 +1804,89 @@ 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(); - const liveMessages = beforeTimestamp - ? undefined - : getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!); - return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit, liveMessages }); + let page: MessagesPage; + const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!); + const liveMessages = + cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : []; + + if (liveMessages.length > 0) { + page = await getTeamDataService().getMessagesPage(vTeam.value!, { + cursor, + limit, + liveMessages, + }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + return page; + } + + 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 + }` + ); + } + } + noteHeavyTeamDataWorkerFallback('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 + }` + ); + } + } + noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta'); + return getTeamDataService().getMemberActivityMeta(vTeam.value!); }); } diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index 19c4500a..bb9280fa 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -88,7 +88,7 @@ export class ApiKeyService { ); } if (!request.value) throw new Error('Key value is required'); - if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) { + if (request.scope === 'project' && !request.projectPath?.trim()) { throw new Error('Project-scoped API keys require a project path'); } diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts index 9fb3e4ce..716236db 100644 --- a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -21,7 +21,6 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise; diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts index e35d6b7f..e43c87bf 100644 --- a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -48,7 +48,6 @@ function isSensitiveCliFlag(flag: string): boolean { const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, ''); return SENSITIVE_FLAG_NAMES.has(normalizedFlag); } - function extractJsonObject(raw: string): T { const trimmed = raw.trim(); try { diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 025d2e90..96cc048d 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -515,8 +515,7 @@ export class ConfigManager { ignoredRepositories: loadedNotifications.ignoredRepositories ?? DEFAULT_CONFIG.notifications.ignoredRepositories, - snoozedUntil: - loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil, + snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil, snoozeMinutes: loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes, includeSubagentErrors: diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts index 75ee0d79..0637839a 100644 --- a/src/main/services/runtime/CliProviderModelAvailabilityService.ts +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -161,7 +161,7 @@ function classifyFailedProbe( export class CliProviderModelAvailabilityService { private readonly cache = new Map(); - private readonly queue: Array<() => void> = []; + private readonly queue: (() => void)[] = []; private activeProbeCount = 0; constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {} 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 470a55bf..5d29e6db 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -32,6 +32,7 @@ import { } from './cache/LeadSessionParseCache'; import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; +import { MemberActivityMetaService } from './MemberActivityMetaService'; import { getLiveLeadProcessMessageKey, mergeLiveLeadProcessMessages, @@ -44,6 +45,7 @@ import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { TeamMessageFeedService } from './TeamMessageFeedService'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal'; @@ -63,7 +65,6 @@ import type { KanbanColumnId, KanbanState, MessagesPage, - ResolvedTeamMember, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -72,13 +73,14 @@ import type { TaskRef, TeamConfig, TeamCreateConfigRequest, - TeamData, TeamMember, + TeamMemberActivityMeta, TeamProcess, TeamSummary, TeamTask, TeamTaskStatus, TeamTaskWithKanban, + TeamViewSnapshot, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; @@ -96,6 +98,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; @@ -160,6 +170,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(), @@ -184,7 +196,15 @@ export class TeamDataService { private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( configReader ) - ) {} + ) { + this.messageFeedService = new TeamMessageFeedService({ + getConfig: (teamName) => this.configReader.getConfig(teamName), + getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName), + getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config), + getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName), + }); + this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService); + } private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); @@ -623,7 +643,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 => { @@ -727,12 +747,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: () => [], @@ -757,40 +771,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(teamName, 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 @@ -798,178 +780,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; @@ -1001,8 +823,7 @@ export class TeamDataService { config, metaMembers, inboxNames, - tasksWithKanban, - messages + tasksWithKanban ); mark('resolveMembers'); @@ -1037,30 +858,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( @@ -1089,21 +893,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, }; } @@ -1114,112 +911,35 @@ export class TeamDataService { */ async getMessagesPage( teamName: string, - options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] } + options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] } ): Promise { - const config = await this.configReader.getConfig(teamName); - if (!config) { - return { messages: [], nextCursor: null, hasMore: false }; - } - - // 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(teamName, 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 ?? ''); - }); - - const newestDurableMessages = messages; + const feed = await this.messageFeedService.getFeed(teamName); + const newestDurableMessages = feed.messages; const durableMessageIndexByKey = new Map( newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index]) ); + let messages = newestDurableMessages; - // 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; - if (options.beforeTimestamp || !options.liveMessages?.length) { - return { messages: page, nextCursor, hasMore }; + if (options.cursor || !options.liveMessages?.length) { + return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision }; } // Merge live lead thoughts against the full durable newest-page history so we do not @@ -1230,7 +950,12 @@ export class TeamDataService { ).slice(0, options.limit); if (displayMessages.length === 0) { - return { messages: displayMessages, nextCursor: null, hasMore: false }; + return { + messages: displayMessages, + nextCursor: null, + hasMore: false, + feedRevision: feed.feedRevision, + }; } let lastDurableDisplayed: InboxMessage | null = null; @@ -1251,6 +976,7 @@ export class TeamDataService { ? `${boundary.timestamp}|${boundary.messageId ?? ''}` : null, hasMore: newestDurableMessages.length > 0, + feedRevision: feed.feedRevision, }; } @@ -1265,15 +991,31 @@ export class TeamDataService { ? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}` : null, hasMore: durableHasMore, + 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); + } + /** * Enriches members with gitBranch when their cwd differs from the lead's. * 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)); @@ -1945,7 +1687,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, @@ -1966,6 +1708,8 @@ export class TeamDataService { leadSessionId: enrichedRequest.leadSessionId, attachments: enrichedRequest.attachments, }) as SendMessageResult; + this.invalidateMessageFeed(teamName); + return result; } private resolveLeadNameFromConfig(config: TeamConfig | null): string { @@ -2522,6 +2266,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/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index c3ca6bb5..331d7f79 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -223,8 +223,7 @@ export function createPersistedLaunchSnapshot(params: { for (const name of expectedMembers) { const member = members[name]; if ( - member && - member.launchState === 'starting' && + member?.launchState === 'starting' && !member.agentToolAccepted && !member.runtimeAlive && !member.bootstrapConfirmed && diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 0cafac54..ec65957e 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -1,17 +1,11 @@ +import { getMemberColorByName } from '@shared/constants/memberColors'; import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; -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..b40d01f8 --- /dev/null +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -0,0 +1,408 @@ +import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; +import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; +import { createHash } from 'crypto'; + +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: (teamName: string, 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(teamName, 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?.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/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2b6c4974..9e64bbae 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -90,6 +90,7 @@ import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; import { peekAutoResumeService } from './AutoResumeService'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; +import { getConfiguredCliCommandLabel } from './cliFlavor'; import { withFileLock } from './fileLock'; import { type ClassifiedMainProcessIdle, @@ -721,6 +722,8 @@ interface ProvisioningRun { >; /** Agent tool_use_id -> teammate name for persistent teammate spawns. */ memberSpawnToolUseIds: Map; + /** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */ + memberSpawnLeadInboxCursorByMember: Map; /** Highest accepted deterministic bootstrap event sequence for this run. */ lastDeterministicBootstrapSeq: number; /** Throttles config/inbox audit work triggered by frequent status polling. */ @@ -839,6 +842,75 @@ function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean ); } +interface MemberSpawnInboxCursor { + timestamp: string; + messageId: string; +} + +type LeadInboxMemberSpawnMessage = InboxMessage & { messageId: string }; + +function compareMemberSpawnInboxCursor( + left: MemberSpawnInboxCursor, + right: MemberSpawnInboxCursor +): number { + const leftMs = Date.parse(left.timestamp); + const rightMs = Date.parse(right.timestamp); + const leftValid = Number.isFinite(leftMs); + const rightValid = Number.isFinite(rightMs); + + if (leftValid && rightValid && leftMs !== rightMs) { + return leftMs - rightMs; + } + if (leftValid !== rightValid) { + return leftValid ? -1 : 1; + } + return left.messageId.localeCompare(right.messageId); +} + +function toMemberSpawnInboxCursor( + message: Pick +): MemberSpawnInboxCursor | null { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!messageId) { + return null; + } + return { + timestamp: message.timestamp, + messageId, + }; +} + +function maxMemberSpawnInboxCursor( + left: MemberSpawnInboxCursor | undefined, + right: MemberSpawnInboxCursor +): MemberSpawnInboxCursor { + if (!left) { + return right; + } + return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right; +} + +function isMemberSpawnHeartbeatTimestampNewer( + previous: string | undefined, + incoming: string | undefined +): boolean { + const normalizedIncoming = incoming?.trim(); + if (!normalizedIncoming) { + return false; + } + const normalizedPrevious = previous?.trim(); + if (!normalizedPrevious) { + return true; + } + + const previousMs = Date.parse(normalizedPrevious); + const incomingMs = Date.parse(normalizedIncoming); + if (Number.isFinite(previousMs) && Number.isFinite(incomingMs)) { + return incomingMs > previousMs; + } + return normalizedIncoming > normalizedPrevious; +} + function stripWrappedCliFlagValue(raw: string | undefined): string | undefined { const trimmed = raw?.trim(); if (!trimmed) { @@ -1339,6 +1411,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess After member_briefing succeeds: - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully. - If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments. +- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result. +- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap. - Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. @@ -1409,6 +1483,8 @@ ${actionModeProtocol} After member_briefing succeeds: - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully. - If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue. + - If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately. + - Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - Use task_briefing as your compact queue view. - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. @@ -3094,12 +3170,15 @@ export class TeamProvisioningService { } const runStartedAtMs = Date.parse(run.startedAt); - const expectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : []; + const expectedMembers = new Set(Array.isArray(run.expectedMembers) ? run.expectedMembers : []); const teammateMessages = leadInboxMessages - .filter((message) => { + .filter((message): message is LeadInboxMemberSpawnMessage => { const from = typeof message.from === 'string' ? message.from.trim() : ''; if (!from || from === leadName || from === 'user' || from === 'system') return false; - if (!expectedMembers.includes(from)) return false; + if (!expectedMembers.has(from)) return false; + if (typeof message.messageId !== 'string' || message.messageId.trim().length === 0) { + return false; + } const messageTs = Date.parse(message.timestamp); if ( Number.isFinite(messageTs) && @@ -3110,24 +3189,67 @@ export class TeamProvisioningService { } return typeof message.text === 'string' && message.text.trim().length > 0; }) - .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - - for (const message of teammateMessages) { - const from = message.from.trim(); - const reason = extractBootstrapFailureReason(message.text); - if (reason) { - this.setMemberSpawnStatus(run, from, 'error', reason); - continue; - } - this.setMemberSpawnStatus( - run, - from, - 'online', - undefined, - 'heartbeat', - extractHeartbeatTimestamp(message.text, message.timestamp) + .sort((left, right) => + compareMemberSpawnInboxCursor( + { timestamp: left.timestamp, messageId: left.messageId }, + { timestamp: right.timestamp, messageId: right.messageId } + ) ); + + const messagesByMember = new Map(); + for (const message of teammateMessages) { + const memberName = message.from.trim(); + const bucket = messagesByMember.get(memberName) ?? []; + bucket.push(message); + messagesByMember.set(memberName, bucket); } + + for (const [memberName, messages] of messagesByMember.entries()) { + const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName); + let nextCursor = currentCursor; + + for (const message of messages) { + const messageCursor = toMemberSpawnInboxCursor(message); + const effectiveCursor = nextCursor ?? currentCursor; + if (messageCursor && effectiveCursor) { + if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) { + continue; + } + } + + this.applyLeadInboxSpawnSignal(run, memberName, message); + if (messageCursor) { + nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor); + } + } + + if ( + nextCursor && + (currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0) + ) { + run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor); + } + } + } + + private applyLeadInboxSpawnSignal( + run: ProvisioningRun, + memberName: string, + message: LeadInboxMemberSpawnMessage + ): void { + const reason = extractBootstrapFailureReason(message.text); + if (reason) { + this.setMemberSpawnStatus(run, memberName, 'error', reason); + return; + } + this.setMemberSpawnStatus( + run, + memberName, + 'online', + undefined, + 'heartbeat', + extractHeartbeatTimestamp(message.text, message.timestamp) + ); } private persistSentMessage(teamName: string, message: InboxMessage): void { @@ -3725,8 +3847,14 @@ export class TeamProvisioningService { next.livenessSource = livenessSource; next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; if (livenessSource === 'heartbeat') { + const incomingHeartbeatAt = heartbeatAt?.trim() || updatedAt; next.bootstrapConfirmed = true; - next.lastHeartbeatAt = heartbeatAt?.trim() || prev.lastHeartbeatAt || updatedAt; + next.lastHeartbeatAt = isMemberSpawnHeartbeatTimestampNewer( + prev.lastHeartbeatAt, + incomingHeartbeatAt + ) + ? incomingHeartbeatAt + : prev.lastHeartbeatAt; } next.hardFailure = false; next.error = undefined; @@ -5612,6 +5740,7 @@ export class TeamProvisioningService { request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + memberSpawnLeadInboxCursorByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, @@ -6192,6 +6321,7 @@ export class TeamProvisioningService { expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + memberSpawnLeadInboxCursorByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, @@ -12968,6 +13098,7 @@ export class TeamProvisioningService { providerId: TeamProviderId | undefined = 'anthropic' ): Promise<{ warning?: string }> { const resolvedProviderId = resolveTeamProviderId(providerId); + const cliCommandLabel = getConfiguredCliCommandLabel(); try { const versionProbe = await this.spawnProbe( claudePath, @@ -12979,9 +13110,9 @@ export class TeamProvisioningService { if (versionProbe.exitCode !== 0) { const errorText = buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || - `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; + `${cliCommandLabel} exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; return { - warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`, + warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`, }; } } catch (error) { @@ -12992,7 +13123,7 @@ export class TeamProvisioningService { }; } return { - warning: `Claude CLI binary failed to start. Details: ${message}`, + warning: `${cliCommandLabel} binary failed to start. Details: ${message}`, }; } @@ -13054,7 +13185,7 @@ export class TeamProvisioningService { } return { warning: - 'Preflight check for `claude -p` did not complete. ' + + `Preflight check for \`${cliCommandLabel} -p\` did not complete. ` + `Proceeding anyway. Details: ${message}`, }; } @@ -13075,13 +13206,15 @@ export class TeamProvisioningService { const hint = isAuthFailure ? resolvedProviderId === 'codex' ? 'Codex provider is not authenticated for `-p` mode. ' + - 'Run `claude-multimodel auth login --provider codex` and retry.' + + `Authenticate Codex in ${cliCommandLabel} and retry.` + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') - : 'Claude CLI `-p` mode is not authenticated. ' + - 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + + : `${cliCommandLabel} \`-p\` mode is not authenticated. ` + + (cliCommandLabel === 'claude' + ? 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + : `Authenticate Anthropic in ${cliCommandLabel} and retry. `) + 'For automation/headless use, set ANTHROPIC_API_KEY.' + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') - : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; + : `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; return { warning: hint }; } @@ -13122,7 +13255,7 @@ export class TeamProvisioningService { const targetCwd = cwd ?? process.cwd(); const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic'); if (!probeResult?.claudePath) { - throw new Error('Claude CLI not found'); + throw new Error(`${getConfiguredCliCommandLabel()} not found`); } const { env } = await this.buildProvisioningEnv(); const result = await this.spawnProbe( @@ -13135,7 +13268,7 @@ export class TeamProvisioningService { const output = (result.stdout + '\n' + result.stderr).trim(); if (!output) { throw new Error( - `claude --help returned empty output (exit code: ${String(result.exitCode)})` + `${getConfiguredCliCommandLabel()} --help returned empty output (exit code: ${String(result.exitCode)})` ); } this.helpOutputCache = output; @@ -13493,7 +13626,7 @@ export class TeamProvisioningService { const timeoutHandle = setTimeout(() => { settled = true; killProcessTree(child); - reject(new Error(`Timeout running: claude ${args.join(' ')}`)); + reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`)); }, timeoutMs); const maybeResolveEarly = (): void => { diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index 062f8ff0..63c1de58 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -1,3 +1,4 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { extractCwd } from '@main/utils/jsonl'; import { encodePath, @@ -5,7 +6,6 @@ import { getProjectsBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; -import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { createReadStream, type Dirent } from 'fs'; @@ -219,7 +219,8 @@ export class TeamTranscriptProjectResolver { ...config, projectPath: resolution.effectiveProjectPath, projectPathHistory: this.buildRepairedProjectPathHistory( - config, + config.projectPath, + config.projectPathHistory, resolution.effectiveProjectPath ), } @@ -598,25 +599,11 @@ export class TeamTranscriptProjectResolver { parsed.projectPath = normalizedNextPath; - const history: string[] = []; - const seen = new Set(); - const pushHistory = (value: unknown): void => { - const normalized = normalizeProjectPathCandidate(value); - if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) { - return; - } - seen.add(normalized); - history.push(normalized); - }; - - if (Array.isArray(parsed.projectPathHistory)) { - for (const value of parsed.projectPathHistory) { - pushHistory(value); - } - } - pushHistory(rawProjectPath); - - parsed.projectPathHistory = history.slice(-500); + parsed.projectPathHistory = this.buildRepairedProjectPathHistory( + rawProjectPath, + parsed.projectPathHistory, + normalizedNextPath + ); await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); logger.info( `[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}` @@ -663,7 +650,11 @@ export class TeamTranscriptProjectResolver { return orderedSessionIds; } - private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] { + private buildRepairedProjectPathHistory( + currentProjectPath: unknown, + rawProjectPathHistory: unknown, + nextProjectPath: string + ): string[] { const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); const history: string[] = []; const seen = new Set(); @@ -676,12 +667,12 @@ export class TeamTranscriptProjectResolver { history.push(normalized); }; - if (Array.isArray(config.projectPathHistory)) { - for (const value of config.projectPathHistory) { + if (Array.isArray(rawProjectPathHistory)) { + for (const value of rawProjectPathHistory) { pushHistory(value); } } - pushHistory(config.projectPath); + pushHistory(currentProjectPath); return history.slice(-500); } diff --git a/src/main/services/team/cliFlavor.ts b/src/main/services/team/cliFlavor.ts index 787bcec1..5936f527 100644 --- a/src/main/services/team/cliFlavor.ts +++ b/src/main/services/team/cliFlavor.ts @@ -41,3 +41,17 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions { }; } } + +export function getCliFlavorCommandLabel(flavor: CliFlavor): string { + switch (flavor) { + case 'agent_teams_orchestrator': + return 'orchestrator-cli'; + case 'claude': + default: + return 'claude'; + } +} + +export function getConfiguredCliCommandLabel(): string { + return getCliFlavorCommandLabel(getConfiguredCliFlavor()); +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index a87be45a..f6290a2f 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,3 +1,9 @@ +export { + AutoResumeService, + clearAutoResumeService, + getAutoResumeService, + initializeAutoResumeService, +} from './AutoResumeService'; export { BranchStatusService } from './BranchStatusService'; export { CascadeGuard } from './CascadeGuard'; export { ChangeExtractorService } from './ChangeExtractorService'; @@ -16,12 +22,6 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; -export { - AutoResumeService, - clearAutoResumeService, - getAutoResumeService, - initializeAutoResumeService, -} from './AutoResumeService'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamBackupService } from './TeamBackupService'; export { TeamConfigReader } from './TeamConfigReader'; 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 8c9c7125..95de628a 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -237,6 +237,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 d2a593b1..ac737d09 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -127,6 +127,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, @@ -299,9 +300,9 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, @@ -309,6 +310,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TeamViewSnapshot, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -829,7 +831,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>( @@ -897,10 +899,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 3d2afc3b..7037e29e 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -59,15 +59,16 @@ import type { TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamsAPI, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, TmuxAPI, TmuxStatus, TriggerTestResult, @@ -678,7 +679,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< @@ -746,7 +747,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/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a9b9454c..1e671096 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -71,10 +71,21 @@ interface MarkdownViewerProps { onTeamClick?: (teamName: string) => void; } +interface CompactMarkdownPreviewProps { + content: string; + className?: string; + /** Optional precomputed team color map to avoid subscribing to the full team list. */ + teamColorByName?: ReadonlyMap; + /** Optional team click handler to avoid subscribing to store in leaf renderers. */ + onTeamClick?: (teamName: string) => void; +} + const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const NOOP_TEAM_CLICK = (): void => undefined; +type ViewerMarkdownMode = 'default' | 'compact-preview'; + // ============================================================================= // Helpers // ============================================================================= @@ -322,53 +333,89 @@ function createViewerMarkdownComponents( isLight = false, teamColorByName: ReadonlyMap = new Map(), onTeamClick?: (teamName: string) => void, - copyCodeBlocks: boolean = false + copyCodeBlocks: boolean = false, + mode: ViewerMarkdownMode = 'default' ): Components { const hl = (children: React.ReactNode): React.ReactNode => searchCtx ? highlightSearchInChildren(children, searchCtx) : children; + const isCompactPreview = mode === 'compact-preview'; + + const renderCompactInline = ( + children: React.ReactNode, + className: string, + style: React.CSSProperties + ): React.ReactElement => ( + + {hl(children)}{' '} + + ); return { // Headings - h1: ({ children }) => ( -

- {hl(children)} -

- ), - h2: ({ children }) => ( -

- {hl(children)} -

- ), - h3: ({ children }) => ( -

- {hl(children)} -

- ), - h4: ({ children }) => ( -

- {hl(children)} -

- ), - h5: ({ children }) => ( -
- {hl(children)} -
- ), - h6: ({ children }) => ( -
- {hl(children)} -
- ), + h1: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h2: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h3: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h4: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h5: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), + h6: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), // Paragraphs - p: ({ children }) => ( -

- {hl(children)} -

- ), + p: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( +

+ {hl(children)} +

+ ), // Links — inline element, no hl(); parent block element's hl() descends here // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture @@ -570,6 +617,20 @@ function createViewerMarkdownComponents( // Code blocks — intercept mermaid diagrams at the pre level pre: ({ children, node }) => { + if (isCompactPreview) { + const compactText = extractTextFromReactNode(children).trim(); + return ( + + {compactText} + + ); + } // Check if this pre contains a mermaid code block const codeEl = node?.children?.[0]; if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) { @@ -596,74 +657,107 @@ function createViewerMarkdownComponents( }, // Blockquotes - blockquote: ({ children }) => ( -
- {hl(children)} -
- ), + blockquote: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'italic', { color: PROSE_MUTED }) + ) : ( +
+ {hl(children)} +
+ ), // Lists - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • - {hl(children)} -
  • - ), + ul: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + ol: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + li: ({ children }) => + isCompactPreview ? ( + + • {hl(children)}{' '} + + ) : ( +
  • + {hl(children)} +
  • + ), // Tables - table: ({ children }) => ( -
    - + isCompactPreview ? ( + {children} + ) : ( +
    +
    + {children} +
    +
    + ), + thead: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( + {children} + ), + th: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( + - {children} - - - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {hl(children)} - - ), - td: ({ children }) => ( - - {hl(children)} - - ), + {hl(children)} + + ), + td: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( + + {hl(children)} + + ), // Horizontal rule - hr: () =>
    , + hr: () => + isCompactPreview ? ( + + · + + ) : ( +
    + ), }; } @@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000; // Component // ============================================================================= +function useResolvedViewerTeamContext( + providedTeamColorByName?: ReadonlyMap, + providedOnTeamClick?: (teamName: string) => void +): { + teamColorByName: ReadonlyMap; + onTeamClick?: (teamName: string) => void; +} { + const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); + const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); + + const fallbackTeamColorByName = React.useMemo(() => { + const result = new Map(); + for (const team of teams) { + if (team.teamName) { + result.set(team.teamName, team.color ?? ''); + } + if (team.displayName) { + result.set(team.displayName, team.color ?? ''); + } + } + return result; + }, [teams]); + + return { + teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP, + onTeamClick: providedOnTeamClick ?? openTeamTab, + }; +} + +export const CompactMarkdownPreview: React.FC = React.memo( + function CompactMarkdownPreview({ + content, + className = '', + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, + }) { + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); + + const components = React.useMemo( + () => + createViewerMarkdownComponents( + null, + isLight, + teamColorByName, + onTeamClick, + false, + 'compact-preview' + ), + [isLight, onTeamClick, teamColorByName] + ); + + return ( +
    + + {content} + +
    + ); + } +); + export const MarkdownViewer: React.FC = ({ content, maxHeight = 'max-h-96', @@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC = ({ const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); - const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); - const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); - - const fallbackTeamColorByName = React.useMemo(() => { - const result = new Map(); - for (const team of teams) { - if (team.teamName) { - result.set(team.teamName, team.color ?? ''); - } - if (team.displayName) { - result.set(team.displayName, team.color ?? ''); - } - } - return result; - }, [teams]); - const teamColorByName = - providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP; - const onTeamClick = providedOnTeamClick ?? openTeamTab; + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index 458bcce7..40df90fd 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -29,7 +29,7 @@ import { useShallow } from 'zustand/react/shallow'; import { resolveSkillProjectPath } from './skillProjectUtils'; -import type { SkillValidationIssue } from '@shared/types'; +import type { SkillValidationIssue } from '@shared/types/extensions'; interface SkillDetailDialogProps { skillId: string | null; diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 5267a413..688aa958 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -144,11 +144,13 @@ export const SidebarTaskItem = ({ ); const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; return ( + )} + + + + +
    + +
    +
    + + {compactPreviewTooltipText} + +
    +
    ) : ( <> diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 6943a68b..9ee1adc1 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -9,8 +9,14 @@ import { useState, } from 'react'; +import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, @@ -26,12 +32,14 @@ import { areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, @@ -582,18 +590,30 @@ const LeadThoughtsGroupRowComponent = ({ return calls.length > 0 ? calls : undefined; }, [thoughts]); - // Extract text preview for header: use newest thought's text, fallback through group - const headerTextPreview = useMemo(() => { + // Reuse the same markdown preprocessing as the expanded thought body. + const compactPreviewMarkdown = useMemo(() => { // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { - const plain = extractMarkdownPlainText(t.text); - const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? ''; - return firstLine.trim(); + const stripped = stripAgentBlocks(t.text).trim(); + if (stripped) { + return buildThoughtDisplayContent(t, memberColorMap, teamNames, { + preserveLineBreaks: false, + stripAgentOnlyBlocks: true, + }) + .replace(/\n+/g, ' ') + .trim(); + } } } - return null; - }, [thoughts]); + return totalToolSummary; + }, [memberColorMap, teamNames, thoughts, totalToolSummary]); + const compactPreviewTooltipText = useMemo(() => { + const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '') + .replace(/\n+/g, ' ') + .trim(); + return normalized || compactPreviewMarkdown; + }, [compactPreviewMarkdown]); // Detect if any thought in this group is an API error const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); @@ -756,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({ ? formatTime(oldest.timestamp) : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; const useCompactCollapsedHeader = compactHeader && !isBodyVisible; - const compactPreviewText = headerTextPreview ?? totalToolSummary; return ( @@ -829,14 +848,113 @@ const LeadThoughtsGroupRowComponent = ({ )} - {compactPreviewText ? ( -
    - {compactPreviewText} + {compactPreviewMarkdown ? ( + + + +
    + +
    +
    + + {compactPreviewTooltipText} + +
    +
    + ) : null} +
    + ) : !isBodyVisible ? ( +
    +
    + {canToggleBodyVisibility && !compactHeader ? ( + + ) : null} + {!compactHeader ? ( +
    + + +
    + ) : null} + + + {thoughts.length} thoughts + +
    + + {timestampLabel} + + {onExpand && expandItemKey && ( + + )}
    +
    + {compactPreviewMarkdown ? ( + + + +
    + +
    +
    + + {compactPreviewTooltipText} + +
    +
    ) : null}
    ) : ( @@ -871,26 +989,7 @@ const LeadThoughtsGroupRowComponent = ({ {thoughts.length} thoughts - {!isBodyVisible && headerTextPreview ? ( - - - - {headerTextPreview} - - - {totalToolSummary ? ( - - - - ) : null} - - ) : totalToolSummary ? ( + {totalToolSummary ? ( diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx index d4b7c212..75985812 100644 --- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx +++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx @@ -4,17 +4,16 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { CopyButton } from '@renderer/components/common/CopyButton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables'; -import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { areStringArraysEqual, areStringMapsEqual, areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; -import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; +import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; -import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; import { Reply } from 'lucide-react'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; import type { InboxMessage } from '@shared/types'; @@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo( onTeamClick, }: ThoughtBodyContentProps): JSX.Element { const displayContent = useMemo(() => { - // Strip leaked protocol XML ( blocks) before rendering - let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n'); - text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { - text = linkifyAllMentionsInMarkdown( - text, - (memberColorMap ?? new Map()) as Map, - teamNames - ); - } - return text; + return buildThoughtDisplayContent(thought, memberColorMap, teamNames, { + preserveLineBreaks: true, + stripAgentOnlyBlocks: true, + }); }, [thought.text, thought.taskRefs, memberColorMap, teamNames]); const handleTaskLinkClick = useCallback( diff --git a/src/renderer/components/team/activity/activityMarkdown.ts b/src/renderer/components/team/activity/activityMarkdown.ts new file mode 100644 index 00000000..02776c70 --- /dev/null +++ b/src/renderer/components/team/activity/activityMarkdown.ts @@ -0,0 +1,36 @@ +import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; + +import type { InboxMessage } from '@shared/types'; + +interface ThoughtDisplayContentOptions { + preserveLineBreaks?: boolean; + stripAgentOnlyBlocks?: boolean; +} + +export function buildThoughtDisplayContent( + thought: Pick, + memberColorMap?: ReadonlyMap, + teamNames: string[] = [], + options: ThoughtDisplayContentOptions = {} +): string { + const { preserveLineBreaks = true, stripAgentOnlyBlocks = false } = options; + let text = stripTeammateMessageBlocks(thought.text); + if (stripAgentOnlyBlocks) { + text = stripAgentBlocks(text); + } + if (preserveLineBreaks) { + text = text.replace(/\n/g, ' \n'); + } + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); + if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { + text = linkifyAllMentionsInMarkdown( + text, + (memberColorMap ?? new Map()) as Map, + teamNames + ); + } + return text; +} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index e2aaee78..f6216327 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -43,12 +43,9 @@ import { import { normalizePath } from '@renderer/utils/pathNormalize'; import { getTeamModelSelectionError, - normalizeTeamModelForUi, + normalizeExplicitTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; -import { - getTeamProviderLabel as getCatalogTeamProviderLabel, - normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, -} from '@renderer/utils/teamModelCatalog'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; @@ -56,7 +53,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { + buildReusableProviderPrepareModelResults, getProviderPrepareCachedSnapshot, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, @@ -111,7 +110,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } - return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored); + return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { @@ -127,14 +126,6 @@ function getProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } -function buildPrepareModelCacheKey( - cwd: string, - providerId: TeamProviderId, - backendSummary: string | null | undefined -): string { - return `${cwd}::${providerId}::${backendSummary ?? ''}`; -} - function alignProvisioningChecks( existingChecks: ProvisioningProviderCheck[], providerIds: TeamProviderId[] @@ -408,7 +399,7 @@ export const CreateTeamDialog = ({ }, [advancedKey]); const setSelectedModel = (value: string): void => { - const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value); setSelectedModelRaw(normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; @@ -646,7 +637,12 @@ export const CreateTeamDialog = ({ return Array.from(next); })(); const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd: effectiveCwd, + providerId, + backendSummary, + limitContext, + }); const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; const cachedSnapshot = getProviderPrepareCachedSnapshot({ providerId, @@ -716,7 +712,7 @@ export const CreateTeamDialog = ({ } prepareModelResultsCacheRef.current.set( plan.cacheKey, - plan.prepResult.modelResultsById + buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) ); checks = updateProviderCheck(checks, plan.providerId, { status: plan.prepResult.status, 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 ad9e4fd0..7574de53 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, @@ -45,12 +48,9 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { getTeamModelSelectionError, - normalizeTeamModelForUi, + normalizeExplicitTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; -import { - getTeamProviderLabel as getCatalogTeamProviderLabel, - normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, -} from '@renderer/utils/teamModelCatalog'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { @@ -72,7 +72,9 @@ import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { + buildReusableProviderPrepareModelResults, getProviderPrepareCachedSnapshot, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, @@ -109,14 +111,6 @@ import type { UpdateSchedulePatch, } from '@shared/types'; -function buildPrepareModelCacheKey( - cwd: string, - providerId: TeamProviderId, - backendSummary: string | null | undefined -): string { - return `${cwd}::${providerId}::${backendSummary ?? ''}`; -} - function alignProvisioningChecks( existingChecks: ProvisioningProviderCheck[], providerIds: TeamProviderId[] @@ -195,7 +189,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } - return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored); + return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function getProviderLabel(providerId: TeamProviderId): string { @@ -319,7 +313,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 ); @@ -467,7 +461,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const setSelectedModel = (value: string): void => { - const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value); setSelectedModelRaw(normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; @@ -932,7 +926,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const providerPlans = selectedMemberProviders.map((providerId) => { const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd: effectiveCwd, + providerId, + backendSummary, + limitContext, + }); const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; const cachedSnapshot = getProviderPrepareCachedSnapshot({ providerId, @@ -1000,7 +999,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById); + prepareModelResultsCacheRef.current.set( + plan.cacheKey, + buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) + ); checks = updateProviderCheck(checks, plan.providerId, { status: plan.prepResult.status, backendSummary: plan.backendSummary, diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 27bb8dfe..d4be245c 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -135,7 +135,7 @@ function summarizeDetail( ) { return 'CLI binary could not be started'; } - if (lower.includes('preflight check for `claude -p` did not complete')) { + if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) { return 'CLI preflight did not complete'; } if (lower.includes('not authenticated') || lower.includes('not logged in')) { diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts index 638097b1..242bfb42 100644 --- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts +++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts @@ -1,5 +1,5 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; -import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog'; +import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -102,7 +102,7 @@ export function resolveLaunchDialogPrefill({ return { providerId, model: matchingModel - ? normalizeCatalogTeamModelForUi(providerId, matchingModel) + ? normalizeExplicitTeamModelForUi(providerId, matchingModel) : getStoredModel(providerId), effort, limitContext, diff --git a/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts new file mode 100644 index 00000000..e67e1efa --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts @@ -0,0 +1,20 @@ +import type { TeamProviderId } from '@shared/types'; + +export function buildProviderPrepareModelCacheKey({ + cwd, + providerId, + backendSummary, + limitContext, +}: { + cwd: string; + providerId: TeamProviderId; + backendSummary: string | null | undefined; + limitContext: boolean; +}): string { + return [ + cwd, + providerId, + backendSummary ?? '', + limitContext ? 'limit-context:on' : 'limit-context:off', + ].join('::'); +} diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index cf1bb17d..626c5476 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -39,6 +39,14 @@ export interface ProviderPrepareDiagnosticsResult { modelResultsById: Record; } +export function buildReusableProviderPrepareModelResults( + modelResultsById: Record +): Record { + return Object.fromEntries( + Object.entries(modelResultsById).filter(([, result]) => result.status !== 'notes') + ); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult) return null; } +function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareResult): string[] { + const escapedModelId = escapeRegExp(modelId); + const scopedPattern = new RegExp(`^Selected model ${escapedModelId}\\b`, 'i'); + return [...(result.details ?? []), ...(result.warnings ?? []), result.message] + .map((entry) => entry?.trim() ?? '') + .filter(Boolean) + .filter((entry) => scopedPattern.test(entry)); +} + +function getScopedModelReason(modelId: string, entries: string[]): string | null { + for (const entry of entries) { + const stripped = stripSelectedModelPrefix(modelId, entry); + if (!stripped) { + continue; + } + const normalized = normalizeModelReason(stripped); + if (normalized) { + return normalized; + } + } + return null; +} + function buildModelFailureLine( providerId: TeamProviderId, modelId: string, @@ -201,6 +232,116 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string return [...(result.details ?? []), ...(result.warnings ?? [])]; } +function extractTimedOutPreflightProbeModelId(detail: string): string | null { + const trimmed = detail.trim(); + if (!trimmed) { + return null; + } + if ( + !trimmed.toLowerCase().includes('preflight check for `') || + !trimmed.toLowerCase().includes('-p` did not complete') + ) { + return null; + } + const match = /--model\s+([^\s]+)/i.exec(trimmed); + return match?.[1]?.trim() || null; +} + +function suppressSupersededRuntimeWarnings(params: { + runtimeDetailLines: string[]; + runtimeWarnings: string[]; + modelResultsById: Map; +}): { + runtimeDetailLines: string[]; + runtimeWarnings: string[]; +} { + const suppressedEntries = new Set(); + + for (const warning of params.runtimeWarnings) { + const probedModelId = extractTimedOutPreflightProbeModelId(warning); + if (!probedModelId) { + continue; + } + if (params.modelResultsById.get(probedModelId)?.status !== 'ready') { + continue; + } + suppressedEntries.add(warning); + } + + return { + runtimeDetailLines: params.runtimeDetailLines.filter( + (detail) => !suppressedEntries.has(detail) + ), + runtimeWarnings: params.runtimeWarnings.filter((warning) => !suppressedEntries.has(warning)), + }; +} + +function resolveModelResultFromBatch( + providerId: TeamProviderId, + modelId: string, + result: TeamProvisioningPrepareResult, + isOnlyModel: boolean +): ProviderPrepareDiagnosticsModelResult { + const modelScopedEntries = getModelScopedEntries(modelId, result); + const normalizedReason = + getScopedModelReason(modelId, modelScopedEntries) ?? + (isOnlyModel ? normalizeModelReason(result.message) : null); + + const hasVerifiedLine = modelScopedEntries.some((entry) => + /selected model .* verified for launch\./i.test(entry) + ); + if (hasVerifiedLine) { + return { + status: 'ready', + line: buildModelSuccessLine(providerId, modelId), + warningLine: null, + }; + } + + const hasUnavailableLine = modelScopedEntries.some((entry) => + /selected model .* is unavailable\./i.test(entry) + ); + if (hasUnavailableLine || (!result.ready && isOnlyModel)) { + return { + status: 'failed', + line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason), + warningLine: null, + }; + } + + const hasVerificationWarningLine = modelScopedEntries.some((entry) => + /selected model .* could not be verified\./i.test(entry) + ); + if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason); + return { + status: 'notes', + line, + warningLine: line, + }; + } + + if (result.ready) { + return { + status: 'ready', + line: buildModelSuccessLine(providerId, modelId), + warningLine: null, + }; + } + + const line = buildModelFailureLine( + providerId, + modelId, + 'check failed', + normalizedReason ?? 'Model verification failed' + ); + return { + status: 'notes', + line, + warningLine: line, + }; +} + export async function runProviderPrepareDiagnostics({ cwd, providerId, @@ -254,7 +395,7 @@ export async function runProviderPrepareDiagnostics({ const modelLines = new Map(); let completedCount = 0; let hasFailure = false; - let hasNotes = runtimeWarnings.length > 0; + let hasNotes = false; const modelWarnings: string[] = []; for (const modelId of orderedModelIds) { @@ -289,73 +430,64 @@ export async function runProviderPrepareDiagnostics({ emitProgress(); - await Promise.all( - orderedModelIds - .filter((modelId) => !modelResultsById.has(modelId)) - .map(async (modelId) => { - try { - const modelResult = await prepareProvisioning( - cwd, - providerId, - [providerId], - [modelId], - limitContext - ); - if (!modelResult.ready) { - hasFailure = true; - const line = buildModelFailureLine( - providerId, - modelId, - 'unavailable', - getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message) - ); - modelLines.set(modelId, line); - modelResultsById.set(modelId, { - status: 'failed', - line, - warningLine: null, - }); - } else if ((modelResult.warnings?.length ?? 0) > 0) { - hasNotes = true; - const reason = getResultReason(modelId, modelResult); - const line = buildModelFailureLine(providerId, modelId, 'check failed', reason); - modelLines.set(modelId, line); - modelWarnings.push(line); - modelResultsById.set(modelId, { - status: 'notes', - line, - warningLine: line, - }); - } else { - const line = buildModelSuccessLine(providerId, modelId); - modelLines.set(modelId, line); - modelResultsById.set(modelId, { - status: 'ready', - line, - warningLine: null, - }); - } - } catch (error) { - hasNotes = true; - const reason = normalizeModelReason( - error instanceof Error ? error.message.trim() : String(error).trim() - ); - const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); - modelLines.set(modelId, line); - modelWarnings.push(line); - modelResultsById.set(modelId, { - status: 'notes', - line, - warningLine: line, - }); - } finally { - completedCount += 1; - emitProgress(); - } - }) - ); + const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId)); + if (uncachedModelIds.length > 0) { + try { + const batchedModelResult = await prepareProvisioning( + cwd, + providerId, + [providerId], + uncachedModelIds, + limitContext + ); - const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings])); + for (const modelId of uncachedModelIds) { + const resolvedResult = resolveModelResultFromBatch( + providerId, + modelId, + batchedModelResult, + uncachedModelIds.length === 1 + ); + modelLines.set(modelId, resolvedResult.line); + modelResultsById.set(modelId, resolvedResult); + if (resolvedResult.status === 'failed') { + hasFailure = true; + } else if (resolvedResult.status === 'notes') { + hasNotes = true; + } + if (resolvedResult.warningLine) { + modelWarnings.push(resolvedResult.warningLine); + } + } + } catch (error) { + hasNotes = true; + const reason = normalizeModelReason( + error instanceof Error ? error.message.trim() : String(error).trim() + ); + for (const modelId of uncachedModelIds) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); + modelLines.set(modelId, line); + modelWarnings.push(line); + modelResultsById.set(modelId, { + status: 'notes', + line, + warningLine: line, + }); + } + } finally { + completedCount += uncachedModelIds.length; + emitProgress(); + } + } + + const filteredRuntime = suppressSupersededRuntimeWarnings({ + runtimeDetailLines, + runtimeWarnings, + modelResultsById, + }); + const dedupedWarnings = Array.from( + new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings]) + ); const selectedModelResultsById = Object.fromEntries( orderedModelIds .map((modelId) => [modelId, modelResultsById.get(modelId)] as const) @@ -365,9 +497,9 @@ export async function runProviderPrepareDiagnostics({ ); return { - status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready', + status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready', details: [ - ...runtimeDetailLines, + ...filteredRuntime.runtimeDetailLines, ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), ], warnings: dedupedWarnings, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx new file mode 100644 index 00000000..908c7c35 --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -0,0 +1,162 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); + +vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({ + UnreadCommentsBadge: () => null, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + className, + onClick, + disabled, + 'aria-label': ariaLabel, + }: { + children: React.ReactNode; + className?: string; + onClick?: React.MouseEventHandler; + disabled?: boolean; + 'aria-label'?: string; + }) => + React.createElement( + 'button', + { className, onClick, disabled, 'aria-label': ariaLabel, type: 'button' }, + children + ), +})); + +vi.mock('@renderer/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ isLight: false }), +})); + +vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({ + useUnreadCommentCount: () => 0, +})); + +import { KanbanTaskCard } from './KanbanTaskCard'; + +import type { TeamTaskWithKanban } from '@shared/types/team'; + +const baseTask: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abcd1234', + subject: 'Implement safer onboarding flow', + owner: 'alice', + reviewer: '', + status: 'in_progress', + changePresence: 'unknown', + comments: [], + blockedBy: [], + blocks: [], + workIntervals: [], + historyEvents: [], + createdAt: '2026-04-18T10:00:00.000Z', + updatedAt: '2026-04-18T10:10:00.000Z', +} as unknown as TeamTaskWithKanban; + +const noop = (): void => undefined; + +describe('KanbanTaskCard change badge', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('does not render a No changes badge when changePresence is no_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'no_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('No changes'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('still renders the Changes action when changePresence is has_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'has_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Changes"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 87933fb6..7c84488d 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -268,10 +268,6 @@ export const KanbanTaskCard = memo( onViewChanges!(task.id); }} /> - ) : canDisplay && task.changePresence === 'no_changes' ? ( - - No changes - ) : null} {onDeleteTask ? ( diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index e4df93f6..d168359a 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types'; interface CurrentTaskIndicatorProps { task: TeamTaskWithKanban; borderColor: string; - /** Max characters for the subject before truncating */ maxSubjectLength?: number; activityLabel?: string; onOpenTask?: () => void; @@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps { export const CurrentTaskIndicator = ({ task, borderColor, - maxSubjectLength = 36, + maxSubjectLength, activityLabel = 'working on', onOpenTask, }: CurrentTaskIndicatorProps): React.JSX.Element => { - const truncated = task.subject.length > maxSubjectLength; - const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}…` : task.subject; + const subjectText = + typeof maxSubjectLength === 'number' && + maxSubjectLength > 0 && + task.subject.length > maxSubjectLength + ? `${task.subject.slice(0, maxSubjectLength)}…` + : task.subject; return ( - <> +
    {activityLabel} - +
    ); }; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 3937e09f..5ea2e0fe 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,6 +1,6 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { @@ -101,6 +101,7 @@ export const MemberCard = ({ const completed = taskCounts?.completed ?? 0; const totalTasks = pending + inProgress + completed; const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); const activityTask = currentTask ?? reviewTask ?? null; const activityTitle = currentTask ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` @@ -111,7 +112,8 @@ export const MemberCard = ({ !isRemoved && presenceLabel === 'starting' && spawnLaunchState !== 'failed_to_start' && - !activityTask; + !activityTask && + !runtimeSummary; const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask; const showRuntimeAdvisoryBadge = !isRemoved && @@ -119,18 +121,14 @@ export const MemberCard = ({ !showStartingBadge && spawnStatus !== 'error' && (Boolean(activityTask) || !isAwaitingReply); - const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5); return (
    - {member.name} +
    + {member.name} +
    -
    +
    {displayMemberName(member.name)} @@ -209,20 +215,16 @@ export const MemberCard = ({ style={{ backgroundColor: 'var(--skeleton-base)' }} />
    - ) : runtimeSummary ? ( -
    - {runtimeSummary} + ) : runtimeSummary || roleLabel ? ( +
    + {runtimeSummary ? {runtimeSummary} : null} + {runtimeSummary && roleLabel ? ( + + ) : null} + {roleLabel ? {roleLabel} : null}
    ) : null}
    - {(() => { - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - return roleLabel ? ( - - {roleLabel} - - ) : null; - })()} {showStartingBadge ? ( (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 = useStore((state) => + selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null) ); - 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, - }, + return buildMemberActivityEntries({ teamName, - leadId, - leadName, - ownerNodeIds: new Set([leadId, ownerNodeId]), - }); - return (entries.get(ownerNodeId) ?? []).length; + memberName: member.name, + members, + tasks, + messages: memberMessages, + }).length; }, [member, memberMessages, members, tasks, teamName]); const inProgressTasks = useMemo( @@ -236,7 +219,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/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 06ede4cf..ece0043c 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -10,9 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, - TeamAgentRuntimeEntry, MemberSpawnStatusEntry, ResolvedTeamMember, + TeamAgentRuntimeEntry, TeamTaskWithKanban, } from '@shared/types'; diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 9c646550..6e5c9eac 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -1,7 +1,5 @@ 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 +8,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 { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { useStore } from '@renderer/store'; +import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { isLeadMember } from '@shared/utils/leadDetection'; +import { useShallow } from 'zustand/react/shallow'; + +import { buildMemberActivityEntries } from './memberActivityEntries'; 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 +30,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 +37,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] ]; export const MemberMessagesTab = ({ - messages, teamName, memberName, members, @@ -48,21 +45,16 @@ export const MemberMessagesTab = ({ onCreateTask, onTaskClick, }: MemberMessagesTabProps): React.JSX.Element => { - const [pagedMessages, setPagedMessages] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [initialPageLoading, setInitialPageLoading] = useState(false); - const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); const [activityFilter, setActivityFilter] = useState(initialFilter); const [expandedItem, setExpandedItem] = useState(null); - const { readSet } = useTeamMessagesRead(teamName); - const leadId = `lead:${teamName}`; - const leadName = useMemo( - () => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`, - [members, teamName] + const { messages, messagesState, loadOlderTeamMessages } = useStore( + useShallow((s) => ({ + messages: selectMemberMessagesForTeamMember(s, teamName, memberName), + messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, + loadOlderTeamMessages: s.loadOlderTeamMessages, + })) ); - const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; - const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]); + const { readSet } = useTeamMessagesRead(teamName); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); const messageContext = useMemo(() => buildMessageContext(members), [members]); @@ -70,108 +62,45 @@ export const MemberMessagesTab = ({ setActivityFilter(initialFilter); }, [initialFilter, memberName, teamName]); - useEffect(() => { - let cancelled = false; - setPagedMessages([]); - setNextCursor(null); - setHasMore(false); - setInitialPageLoading(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) { - setInitialPageLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [teamName, memberName]); - const loadOlderMessages = useCallback(async () => { - if (!nextCursor || loadingOlderMessages) return; - setLoadingOlderMessages(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 { - setLoadingOlderMessages(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [loadingOlderMessages, memberName, nextCursor, teamName]); + await loadOlderTeamMessages(teamName); + }, [loadOlderTeamMessages, messagesState, teamName]); - const effectiveMessages = useMemo( - () => mergeTeamMessages(messages, pagedMessages), - [messages, pagedMessages] - ); - - const filteredMessages = useMemo( - () => - filterTeamMessages(effectiveMessages, { - timeWindow: null, - filter: { from: new Set(), to: new Set(), showNoise: true }, - searchQuery: '', - }), - [effectiveMessages] - ); + const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const loadingOlderMessages = messagesState?.loadingOlder ?? false; + const hasMore = messagesState?.hasMore ?? false; const activityEntries = useMemo(() => { - const entriesByOwner = buildInlineActivityEntries({ - data: { - members, - tasks, - messages: filteredMessages, - }, + return buildMemberActivityEntries({ teamName, - leadId, - leadName, - ownerNodeIds, + memberName, + members, + tasks, + messages, }); - return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES); - }, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]); + }, [memberName, members, messages, tasks, teamName]); + const visibleActivityEntries = useMemo( + () => activityEntries.slice(0, MAX_MESSAGES), + [activityEntries] + ); const displayEntries = useMemo(() => { switch (activityFilter) { case 'messages': - return activityEntries.filter( + return visibleActivityEntries.filter( (entry) => entry.message.messageKind !== 'task_comment_notification' ); case 'comments': - return activityEntries.filter( + return visibleActivityEntries.filter( (entry) => entry.message.messageKind === 'task_comment_notification' ); default: - return activityEntries; + return visibleActivityEntries; } - }, [activityEntries, activityFilter]); + }, [activityFilter, visibleActivityEntries]); const expandedItemsByKey = useMemo(() => { const items = new Map(); @@ -201,6 +130,7 @@ export const MemberMessagesTab = ({ [onTaskClick, taskMap, tasks] ); + const initialPageLoading = loading && activityEntries.length === 0; const emptyStateText = initialPageLoading ? 'Loading activity...' : activityFilter === 'comments' @@ -209,9 +139,10 @@ export const MemberMessagesTab = ({ ? hasMore ? 'No loaded messages for this member yet' : 'No messages with this member' - : 'No activity with this member'; - const canLoadOlderMessages = - hasMore && activityFilter !== 'comments' && displayEntries.length > 0; + : hasMore + ? 'No loaded activity for this member yet' + : 'No activity with this member'; + const canLoadOlderMessages = hasMore && activityFilter !== 'comments'; return (
    diff --git a/src/renderer/components/team/members/memberActivityEntries.ts b/src/renderer/components/team/members/memberActivityEntries.ts new file mode 100644 index 00000000..d3e1edf1 --- /dev/null +++ b/src/renderer/components/team/members/memberActivityEntries.ts @@ -0,0 +1,42 @@ +import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; +import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { InlineActivityEntry } from '@features/agent-graph/renderer'; +import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; + +export function buildMemberActivityEntries({ + teamName, + memberName, + members, + tasks, + messages, +}: { + teamName: string; + memberName: string; + members: ResolvedTeamMember[]; + tasks: TeamTaskWithKanban[]; + messages: InboxMessage[]; +}): InlineActivityEntry[] { + const filteredMessages = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + const leadId = `lead:${teamName}`; + const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; + const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; + const ownerNodeIds = new Set([leadId, ownerNodeId]); + const entriesByOwner = buildInlineActivityEntries({ + data: { + members, + tasks, + messages: filteredMessages, + }, + teamName, + leadId, + leadName, + ownerNodeIds, + }); + return entriesByOwner.get(ownerNodeId) ?? []; +} diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 599ebd15..b69843ad 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -2,9 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; -import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog'; -import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -34,7 +32,6 @@ function newDraftId(): string { export function createMemberDraft(initial?: Partial): MemberDraft { const providerId = initial?.providerId; - const normalizedModel = extractProviderScopedBaseModel(initial?.model ?? '', providerId) ?? ''; return { id: initial?.id ?? newDraftId(), name: initial?.name ?? '', @@ -42,7 +39,7 @@ export function createMemberDraft(initial?: Partial): MemberDraft { customRole: initial?.customRole ?? '', workflow: initial?.workflow, providerId, - model: normalizeCatalogTeamModelForUi(providerId, normalizedModel), + model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''), effort: initial?.effort, removedAt: initial?.removedAt, }; @@ -221,7 +218,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning } const model = member.model?.trim(); if (model) { - result.model = normalizeTeamModelForUi(providerId, model); + result.model = normalizeExplicitTeamModelForUi(providerId, model); } const effort = normalizeDraftEffort(member.effort); if (effort) { diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 7bd3ad22..8a883579 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,11 +8,10 @@ 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'; -import { createLogger } from '@shared/utils/logger'; import { CheckCheck, ChevronsDownUp, @@ -53,9 +51,6 @@ interface TimeWindow { end: number; } -const logger = createLogger('Component:MessagesPanel'); -const MESSAGES_PANEL_FILTER_WARN_MS = 8; -const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6; const BOTTOM_SHEET_HEADER_HEIGHT = 40; const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1; const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2; @@ -70,8 +65,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. */ @@ -107,7 +100,6 @@ export const MessagesPanel = memo(function MessagesPanel({ mountPoint, members, tasks, - messages, isTeamAlive, leadActivity, leadContextUpdatedAt, @@ -130,6 +122,9 @@ export const MessagesPanel = memo(function MessagesPanel({ lastSendMessageResult, teams, openTeamTab, + messages, + messagesState, + loadOlderTeamMessages, } = useStore( useShallow((s) => ({ sendTeamMessage: s.sendTeamMessage, @@ -139,76 +134,24 @@ 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 [loadingOlderMessages, setLoadingOlderMessages] = useState(false); - const fetchIdRef = useRef(0); - - // Initial fetch on mount or team change - useEffect(() => { - const id = ++fetchIdRef.current; - 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); - } - } - })(); - }, [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 || loadingOlderMessages) return; - setLoadingOlderMessages(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 { - setLoadingOlderMessages(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [loadingOlderMessages, nextCursor, teamName]); + 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 loadingOlderMessages = messagesState?.loadingOlder ?? false; + const hasMore = messagesState?.hasMore ?? false; + const effectiveMessages = messages; const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); @@ -323,41 +266,21 @@ export const MessagesPanel = memo(function MessagesPanel({ }, [position, mountPoint]); const filteredMessages = useMemo(() => { - const startedAt = performance.now(); - const result = filterTeamMessages(effectiveMessages, { + return filterTeamMessages(effectiveMessages, { timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { - logger.warn( - `[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ - messagesFilter.showNoise ? 'on' : 'off' - }` - ); - } - return result; - }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); + }, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]); const activityTimelineMessages = useMemo(() => { - const startedAt = performance.now(); - const result = filterTeamMessages(effectiveMessages, { + return filterTeamMessages(effectiveMessages, { includePassiveIdlePeerSummariesWhenNoiseHidden: true, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { - logger.warn( - `[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ - messagesFilter.showNoise ? 'on' : 'off' - }` - ); - } - return result; - }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); + }, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]); const replyCandidateMessages = useMemo( () => @@ -371,33 +294,21 @@ export const MessagesPanel = memo(function MessagesPanel({ // Resolve the expanded item from filtered messages const expandedItem = useMemo(() => { - const startedAt = performance.now(); - if (!expandedItemKey) return null; + if (!expandedItemKey) { + return null; + } if (!expandedItemKey.startsWith('thoughts-')) { const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey); - const result: TimelineItem | null = msg ? { type: 'message', message: msg } : null; - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) { - logger.warn( - `[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=message timelineMessages=${activityTimelineMessages.length}` - ); - } - return result; + return msg ? { type: 'message', message: msg } : null; } const allItems = groupTimelineItems(activityTimelineMessages); - const result = + return ( allItems.find( (item) => item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey - ) ?? null; - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) { - logger.warn( - `[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=thoughts timelineMessages=${activityTimelineMessages.length} groups=${allItems.length}` - ); - } - return result; - }, [expandedItemKey, activityTimelineMessages, teamName]); + ) ?? null + ); + }, [expandedItemKey, activityTimelineMessages]); // Auto-clear stale expanded key useEffect(() => { 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..77857dde 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?.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/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 0fcb0ac8..3543bcd6 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -139,7 +139,7 @@ export interface ExtensionsSlice { // Slice Creator // ============================================================================= -let pluginFetchInFlight: { key: string; promise: Promise } | null = null; +let pluginFetchInFlight: { key: string; promise: Promise; token: symbol } | null = null; let pluginCatalogRequestSeq = 0; const pluginSuccessResetTimers = new Map>(); const mcpSuccessResetTimers = new Map>(); @@ -409,6 +409,7 @@ export const createExtensionsSlice: StateCreator | null = null; @@ -468,13 +469,13 @@ export const createExtensionsSlice: StateCreator>(); -const inFlightRefreshTeamDataCalls = new Set(); +const inFlightTeamDataRequests = new Map>(); +const inFlightRefreshTeamDataCalls = new Map>(); 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 activeTeamPendingReplyWaitSourceIdsByTeam = new Map>(); const lastResolvedTeamDataRefreshAtByTeam = new Map(); +const teamLocalStateEpochByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); @@ -91,9 +109,9 @@ interface RefreshTeamDataOptions { } type TeamGraphSlotAssignments = Record; -type TeamGraphMemberSeedInput = Pick; +type TeamGraphMemberSeedInput = Pick; type TeamGraphConfigMemberSeedInput = Pick< - NonNullable[number], + NonNullable[number], 'name' | 'agentId' | 'removedAt' >; interface TeamGraphLayoutSessionState { @@ -104,7 +122,7 @@ interface TeamGraphLayoutSessionState { export function isTeamDataRefreshPending(teamName: string): boolean { return ( inFlightTeamDataRequests.has(teamName) || - inFlightRefreshTeamDataCalls.has(teamName) || + (inFlightRefreshTeamDataCalls.get(teamName)?.size ?? 0) > 0 || pendingFreshTeamDataRefreshes.has(teamName) ); } @@ -113,14 +131,313 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und return lastResolvedTeamDataRefreshAtByTeam.get(teamName); } +export function hasActiveTeamPendingReplyWait(teamName: string): boolean { + return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0; +} + +export function getActiveTeamPendingReplyWaits(): Set { + return new Set( + Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries()) + .filter(([, sourceIds]) => sourceIds.size > 0) + .map(([teamName]) => teamName) + ); +} + 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(); + activeTeamPendingReplyWaitSourceIdsByTeam.clear(); lastResolvedTeamDataRefreshAtByTeam.clear(); + teamLocalStateEpochByTeam.clear(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); + resolvedMembersSelectorCache.clear(); + resolvedMemberSelectorCache.clear(); + mergedMessagesSelectorCache.clear(); + memberMessagesSelectorCache.clear(); +} + +function clearTeamScopedSelectorCaches(teamName: string): void { + resolvedMembersSelectorCache.delete(teamName); + mergedMessagesSelectorCache.delete(teamName); + + const teamScopedPrefix = `${teamName}:`; + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCache.delete(key); + } + } + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCache.delete(key); + } + } +} + +function clearTeamScopedTransientState(teamName: string): void { + inFlightTeamDataRequests.delete(teamName); + inFlightRefreshTeamDataCalls.delete(teamName); + pendingFreshTeamDataRefreshes.delete(teamName); + inFlightTeamMessagesHeadRequests.delete(teamName); + inFlightTeamMessagesOlderRequests.delete(teamName); + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + pendingFreshTeamMessagesHeadRefreshes.delete(teamName); + inFlightTeamMemberActivityMetaRequests.delete(teamName); + pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName); + lastResolvedTeamDataRefreshAtByTeam.delete(teamName); + memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); + teamRefreshBurstDiagnostics.delete(teamName); + memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); + clearTeamScopedSelectorCaches(teamName); +} + +function collectTeamScopedVisibleLoadingResets( + state: Pick< + TeamSlice, + 'teamMessagesByName' | 'selectedTeamName' | 'selectedTeamLoading' | 'selectedTeamError' + >, + teamName: string +): Partial { + const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; + const nextTeamMessagesByName = + nextTeamMessagesEntry && + (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) + ? { + ...state.teamMessagesByName, + [teamName]: { + ...nextTeamMessagesEntry, + loadingHead: false, + loadingOlder: false, + }, + } + : null; + + const shouldResetSelectedSurface = + state.selectedTeamName === teamName && + (state.selectedTeamLoading || state.selectedTeamError != null); + + return { + ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), + ...(shouldResetSelectedSurface + ? { + selectedTeamLoading: false, + selectedTeamError: null, + } + : {}), + }; +} + +function omitTeamKey(record: Record, teamName: string): Record | null { + if (!(teamName in record)) { + return null; + } + const next = { ...record }; + delete next[teamName]; + return next; +} + +function collectTeamScopedStateRemovals( + state: Pick< + TeamSlice, + | 'provisioningRuns' + | 'teamDataCacheByName' + | 'teamAgentRuntimeByTeam' + | 'teamMessagesByName' + | 'memberActivityMetaByTeam' + | 'provisioningSnapshotByTeam' + | 'currentProvisioningRunIdByTeam' + | 'currentRuntimeRunIdByTeam' + | 'provisioningStartedAtFloorByTeam' + | 'leadActivityByTeam' + | 'leadContextByTeam' + | 'activeToolsByTeam' + | 'finishedVisibleByTeam' + | 'toolHistoryByTeam' + | 'memberSpawnStatusesByTeam' + | 'memberSpawnSnapshotsByTeam' + | 'provisioningErrorByTeam' + >, + teamName: string +): Partial { + const nextProvisioningRuns = Object.fromEntries( + Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) + ) as Record; + const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); + const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); + const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); + const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); + const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); + const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); + const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); + const nextProvisioningStartedAtFloor = omitTeamKey( + state.provisioningStartedAtFloorByTeam, + teamName + ); + const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); + const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); + const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); + const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); + const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); + const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); + const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); + const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); + + return { + ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length + ? { provisioningRuns: nextProvisioningRuns } + : {}), + ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), + ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), + ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), + ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), + ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), + ...(nextCurrentProvisioningRunId + ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } + : {}), + ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), + ...(nextProvisioningStartedAtFloor + ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } + : {}), + ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), + ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), + ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), + ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), + ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), + ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), + ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), + ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), + }; +} + +function buildTeamScopedProgressTombstones( + state: Pick< + TeamSlice, + | 'currentProvisioningRunIdByTeam' + | 'currentRuntimeRunIdByTeam' + | 'ignoredProvisioningRunIds' + | 'ignoredRuntimeRunIds' + | 'provisioningStartedAtFloorByTeam' + >, + teamName: string, + floor: string +): Pick< + TeamSlice, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' +> { + const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; + const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; + + const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; + const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; + if (currentProvisioningRunId) { + nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; + } + if (currentRuntimeRunId) { + nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; + } + + return { + ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + provisioningStartedAtFloorByTeam: { + ...state.provisioningStartedAtFloorByTeam, + [teamName]: floor, + }, + }; +} + +function captureTeamLocalStateEpoch(teamName: string): number { + return teamLocalStateEpochByTeam.get(teamName) ?? 0; +} + +function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { + return captureTeamLocalStateEpoch(teamName) === epoch; +} + +function invalidateTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); +} + +function beginInFlightTeamDataRefresh(teamName: string): symbol { + const token = Symbol(teamName); + const existing = inFlightRefreshTeamDataCalls.get(teamName); + if (existing) { + existing.add(token); + return token; + } + inFlightRefreshTeamDataCalls.set(teamName, new Set([token])); + return token; +} + +function endInFlightTeamDataRefresh(teamName: string, token: symbol): void { + const existing = inFlightRefreshTeamDataCalls.get(teamName); + if (!existing) { + return; + } + existing.delete(token); + if (existing.size === 0) { + inFlightRefreshTeamDataCalls.delete(teamName); + } +} + +export function __getTeamScopedTransientStateForTests(teamName: string): { + hasResolvedMembersSelector: boolean; + resolvedMemberSelectorCount: number; + hasMergedMessagesSelector: boolean; + memberMessagesSelectorCount: number; + hasPendingFreshTeamDataRefresh: boolean; + hasQueuedHeadRefreshAfterOlder: boolean; + hasPendingFreshMessagesHeadRefresh: boolean; + hasPendingFreshMemberActivityMetaRefresh: boolean; + hasLastResolvedTeamDataRefresh: boolean; + hasCurrentLocalStateEpoch: boolean; + hasMemberSpawnStatusesIpcBackoff: boolean; + hasTeamRefreshBurstDiagnostics: boolean; + hasMemberSpawnUiEqualLastWarn: boolean; +} { + const teamScopedPrefix = `${teamName}:`; + let resolvedMemberSelectorCount = 0; + let memberMessagesSelectorCount = 0; + + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCount += 1; + } + } + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCount += 1; + } + } + + return { + hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), + resolvedMemberSelectorCount, + hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), + memberMessagesSelectorCount, + hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName), + hasQueuedHeadRefreshAfterOlder: queuedTeamMessagesHeadRefreshesAfterOlder.has(teamName), + hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName), + hasPendingFreshMemberActivityMetaRefresh: + pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), + hasLastResolvedTeamDataRefresh: lastResolvedTeamDataRefreshAtByTeam.has(teamName), + hasCurrentLocalStateEpoch: teamLocalStateEpochByTeam.has(teamName), + hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), + hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), + hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), + }; } function nowIso(): string { @@ -131,6 +448,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', @@ -162,7 +539,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; @@ -182,7 +559,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, @@ -190,19 +567,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, @@ -210,20 +585,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; @@ -234,8 +600,6 @@ function estimateTeamPayloadWeight(data: TeamData): { } return { - messageTextChars, - messageAttachments, taskComments, taskHistoryEvents, taskDescriptionChars, @@ -280,8 +644,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; @@ -302,8 +666,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 || @@ -319,15 +682,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}` ); } @@ -487,32 +848,215 @@ 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 clearPendingReplyRefreshWaits(teamName: string): void { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); +} + +function setPendingReplyRefreshEnabled( + teamName: string, + sourceId: string, + enabled: boolean +): boolean { + if (enabled) { + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set(); + existing.add(sourceId); + activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing); + return true; + } + + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName); + if (!existing) { + return false; + } + existing.delete(sourceId); + if (existing.size === 0) { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); + return false; + } + return true; +} + +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, @@ -880,8 +1424,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) => @@ -909,9 +1453,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; } @@ -984,10 +1528,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; } @@ -1026,6 +1687,156 @@ 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?.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?.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?.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?.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 seedStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments, members: readonly TeamGraphMemberSeedInput[], @@ -1177,11 +1988,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; graphLayoutSessionByTeam: Record; selectedTeamLoading: boolean; selectedTeamLoadNonce: number; @@ -1262,6 +2075,15 @@ 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, + sourceId: string, + enabled: boolean, + delayMs?: number + ) => void; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; crossTeamTargets: { teamName: string; @@ -1504,6 +2326,8 @@ export const createTeamSlice: StateCreator = (set, teamDataCacheByName: {}, slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: {}, + teamMessagesByName: {}, + memberActivityMetaByTeam: {}, graphLayoutSessionByTeam: {}, selectedTeamLoading: false, selectedTeamLoadNonce: 0, @@ -1582,17 +2406,9 @@ export const createTeamSlice: StateCreator = (set, ...prev.currentRuntimeRunIdByTeam, [teamName]: snapshot.runId, }; - const hasIgnoredRuntimeEntriesForTeam = Object.values(prev.ignoredRuntimeRunIds).some( - (ignoredTeamName) => ignoredTeamName === teamName - ); - const nextIgnoredRuntimeRunIds = - snapshot.runId == null || !hasIgnoredRuntimeEntriesForTeam - ? prev.ignoredRuntimeRunIds - : Object.fromEntries( - Object.entries(prev.ignoredRuntimeRunIds).filter( - ([, ignoredTeamName]) => ignoredTeamName !== teamName - ) - ); + // Keep same-team ignored runtime tombstones intact here. + // Member-spawn snapshots do not carry a run start time, so clearing older + // ignored ids can reopen stale zombie snapshots during create/launch churn. const previousSnapshot = prev.memberSpawnSnapshotsByTeam[teamName]; const snapshotChanged = !areMemberSpawnSnapshotsSemanticallyEqual( previousSnapshot, @@ -1601,22 +2417,17 @@ export const createTeamSlice: StateCreator = (set, if (!snapshotChanged) { maybeLogMemberSpawnUiEqualSuppressed(teamName, snapshot.runId); - if ( - nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam && - nextIgnoredRuntimeRunIds === prev.ignoredRuntimeRunIds - ) { + if (nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam) { return {}; } return { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam, - ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, }; } return { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam, - ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, memberSpawnStatusesByTeam: { ...prev.memberSpawnStatusesByTeam, [teamName]: snapshot.statuses, @@ -2389,6 +3200,7 @@ export const createTeamSlice: StateCreator = (set, selectTeam: async (teamName: string, opts) => { const startedAt = performance.now(); + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. // GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession. @@ -2402,11 +3214,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, @@ -2417,6 +3229,9 @@ export const createTeamSlice: StateCreator = (set, try { const data = await fetchTeamDataDeduped(teamName); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } const ipcMs = performance.now() - startedAt; // Stale check: user may have switched to another team during the async call if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) { @@ -2442,23 +3257,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(); @@ -2480,7 +3303,7 @@ export const createTeamSlice: StateCreator = (set, postMs, totalMs: performance.now() - startedAt, previousData, - nextData: data, + nextData: nextTeamData, deduped: true, reusedInFlightRequest: false, }); @@ -2497,6 +3320,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; } @@ -2532,6 +3360,9 @@ export const createTeamSlice: StateCreator = (set, } } } catch (error) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } // If provisioning is in progress for this team, stay in loading state; // file watcher / progress callback will refresh once config is written. const currentState = get(); @@ -2580,7 +3411,8 @@ export const createTeamSlice: StateCreator = (set, refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { const startedAt = performance.now(); - inFlightRefreshTeamDataCalls.add(teamName); + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). const reusedInFlightRequest = @@ -2597,26 +3429,48 @@ export const createTeamSlice: StateCreator = (set, const data = opts?.withDedup ? await fetchTeamDataDeduped(teamName) : await fetchTeamDataFresh(teamName); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } 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(); @@ -2638,12 +3492,15 @@ export const createTeamSlice: StateCreator = (set, postMs, totalMs: performance.now() - startedAt, previousData, - nextData: data, + nextData: nextTeamData, deduped: opts?.withDedup === true, reusedInFlightRequest, burstCount, }); } catch (error) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } const msg = error instanceof IpcError ? error.message @@ -2702,13 +3559,391 @@ export const createTeamSlice: StateCreator = (set, } set({ selectedTeamError: msg }); } finally { - inFlightRefreshTeamDataCalls.delete(teamName); + endInFlightTeamDataRefresh(teamName, refreshToken); if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) { void get().refreshTeamData(teamName); } } }, + 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) { + const queuedEpoch = captureTeamLocalStateEpoch(teamName); + const queuedRequest: Promise = existingOlderRequest + .then(() => { + if (!isTeamLocalStateEpochCurrent(teamName, queuedEpoch)) { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; + } + if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) { + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + } else { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; + } + return get().refreshTeamMessagesHead(teamName); + }) + .finally(() => { + if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) { + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + } + }); + queuedTeamMessagesHeadRefreshesAfterOlder.set(teamName, queuedRequest); + return queuedRequest; + } + + const requestRef: { current: Promise | null } = { + current: null, + }; + requestRef.current = (async (): Promise => { + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingHead: true, + }, + }, + })); + + try { + const page = await unwrapIpc('team:getMessagesPage', () => + api.teams.getMessagesPage(teamName, { limit: 50 }) + ); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; + } + + 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) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; + } + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingHead: false, + }, + }, + })); + throw error; + } finally { + if (inFlightTeamMessagesHeadRequests.get(teamName) === requestRef.current) { + inFlightTeamMessagesHeadRequests.delete(teamName); + if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) { + void get().refreshTeamMessagesHead(teamName); + } + } + } + })(); + + const request = requestRef.current; + inFlightTeamMessagesHeadRequests.set(teamName, request); + return request; + }, + + loadOlderTeamMessages: async (teamName: string) => { + const requestedEpoch = captureTeamLocalStateEpoch(teamName); + const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName); + if (existingRequest) { + return existingRequest; + } + + const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName); + if (existingHeadRequest) { + await existingHeadRequest; + if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) { + return; + } + } + + let entry = getTeamMessagesCacheEntry(get(), teamName); + if (!entry.headHydrated) { + await get().refreshTeamMessagesHead(teamName); + if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) { + return; + } + entry = getTeamMessagesCacheEntry(get(), teamName); + } + + if (!entry.headHydrated || !entry.nextCursor || entry.loadingOlder || entry.loadingHead) { + return; + } + + const requestRef: { current: Promise | null } = { current: null }; + requestRef.current = (async (): Promise => { + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + 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, + }) + ); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } + + 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 { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: false, + }, + }, + })); + } finally { + if (inFlightTeamMessagesOlderRequests.get(teamName) === requestRef.current) { + inFlightTeamMessagesOlderRequests.delete(teamName); + } + } + })(); + + const request = requestRef.current; + 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 requestRef: { current: Promise | null } = { current: null }; + requestRef.current = (async (): Promise => { + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + try { + const meta = await unwrapIpc('team:getMemberActivityMeta', () => + api.teams.getMemberActivityMeta(teamName) + ); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } + + set((state) => { + const currentFeedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (currentFeedRevision && meta.feedRevision !== currentFeedRevision) { + return {}; + } + const existing = state.memberActivityMetaByTeam[teamName]; + if (existing?.feedRevision === meta.feedRevision) { + return {}; + } + const sharedMembers = structurallyShareMemberActivityFacts( + existing?.members, + meta.members + ); + const nextMeta = + existing?.members === sharedMembers && + existing.feedRevision === meta.feedRevision && + existing.computedAt === meta.computedAt + ? existing + : { + ...meta, + members: sharedMembers, + }; + return { + memberActivityMetaByTeam: { + ...state.memberActivityMetaByTeam, + [teamName]: nextMeta, + }, + }; + }); + } catch (error) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } + throw error; + } finally { + if (inFlightTeamMemberActivityMetaRequests.get(teamName) === requestRef.current) { + inFlightTeamMemberActivityMetaRequests.delete(teamName); + if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) { + void get().refreshMemberActivityMeta(teamName); + } + } + } + })(); + + const request = requestRef.current; + inFlightTeamMemberActivityMetaRequests.set(teamName, request); + return request; + }, + + syncTeamPendingReplyRefresh: ( + teamName: string, + sourceId: string, + enabled: boolean, + delayMs = 10_000 + ) => { + clearPendingReplyRefreshTimer(teamName); + const shouldKeepRefreshActive = setPendingReplyRefreshEnabled(teamName, sourceId, enabled); + if (!shouldKeepRefreshActive) { + 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 }); @@ -2765,24 +4000,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, @@ -2816,7 +4042,7 @@ export const createTeamSlice: StateCreator = (set, deduplicated: result.deduplicated, }, }); - await get().refreshTeamData(request.fromTeam); + await get().refreshTeamMessagesHead(request.fromTeam); } catch (error) { set({ sendingMessage: false, @@ -2999,32 +4225,26 @@ export const createTeamSlice: StateCreator = (set, deleteTeam: async (teamName: string) => { await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); + invalidateTeamLocalStateEpoch(teamName); + clearPendingReplyRefreshTimer(teamName); + clearPendingReplyRefreshWaits(teamName); + clearTeamScopedTransientState(teamName); set((state) => { - const nextCache = state.teamDataCacheByName[teamName] - ? { ...state.teamDataCacheByName } - : null; - const nextRuntime = state.teamAgentRuntimeByTeam[teamName] - ? { ...state.teamAgentRuntimeByTeam } - : null; - if (nextCache) { - delete nextCache[teamName]; - } - if (nextRuntime) { - delete nextRuntime[teamName]; - } + const clearedState = collectTeamScopedStateRemovals(state, teamName); + const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso()); if (state.selectedTeamName === teamName) { return { selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, selectedTeamError: null, - ...(nextCache ? { teamDataCacheByName: nextCache } : {}), - ...(nextRuntime ? { teamAgentRuntimeByTeam: nextRuntime } : {}), + ...clearedState, + ...tombstones, }; } return { - ...(nextCache ? { teamDataCacheByName: nextCache } : {}), - ...(nextRuntime ? { teamAgentRuntimeByTeam: nextRuntime } : {}), + ...clearedState, + ...tombstones, }; }); await get().fetchTeams(); @@ -3033,24 +4253,20 @@ export const createTeamSlice: StateCreator = (set, restoreTeam: async (teamName: string) => { await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); + invalidateTeamLocalStateEpoch(teamName); + clearPendingReplyRefreshTimer(teamName); + clearPendingReplyRefreshWaits(teamName); + clearTeamScopedTransientState(teamName); set((state) => { - const hasCache = Boolean(state.teamDataCacheByName[teamName]); - const hasRuntime = Boolean(state.teamAgentRuntimeByTeam[teamName]); - if (!hasCache && !hasRuntime) { - return {}; + const clearedState = collectTeamScopedStateRemovals(state, teamName); + const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso()); + if (Object.keys(clearedState).length === 0) { + return tombstones; } - const nextState: Partial = {}; - if (hasCache) { - const nextCache = { ...state.teamDataCacheByName }; - delete nextCache[teamName]; - nextState.teamDataCacheByName = nextCache; - } - if (hasRuntime) { - const nextRuntime = { ...state.teamAgentRuntimeByTeam }; - delete nextRuntime[teamName]; - nextState.teamAgentRuntimeByTeam = nextRuntime; - } - return nextState; + return { + ...clearedState, + ...tombstones, + }; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -3058,24 +4274,28 @@ export const createTeamSlice: StateCreator = (set, permanentlyDeleteTeam: async (teamName: string) => { await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); + invalidateTeamLocalStateEpoch(teamName); + clearPendingReplyRefreshTimer(teamName); + clearPendingReplyRefreshWaits(teamName); + clearTeamScopedTransientState(teamName); const state = get(); - const nextCache = { ...state.teamDataCacheByName }; - const nextRuntime = { ...state.teamAgentRuntimeByTeam }; - delete nextCache[teamName]; - delete nextRuntime[teamName]; + const clearedState = collectTeamScopedStateRemovals(state, teamName); + const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso()); if (state.selectedTeamName === teamName) { set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null, - teamDataCacheByName: nextCache, - teamAgentRuntimeByTeam: nextRuntime, + ...clearedState, + ...tombstones, }); - } else if (state.teamDataCacheByName[teamName] || state.teamAgentRuntimeByTeam[teamName]) { + } else if (Object.keys(clearedState).length > 0) { set({ - teamDataCacheByName: nextCache, - teamAgentRuntimeByTeam: nextRuntime, + ...clearedState, + ...tombstones, }); + } else { + set(tombstones); } await get().fetchTeams(); await get().fetchAllTasks(); @@ -3084,6 +4304,10 @@ export const createTeamSlice: StateCreator = (set, createTeam: async (request: TeamCreateRequest) => { // Ensure provisioning progress subscription is active (defensive). get().subscribeProvisioningProgress(); + invalidateTeamLocalStateEpoch(request.teamName); + clearPendingReplyRefreshTimer(request.teamName); + clearPendingReplyRefreshWaits(request.teamName); + clearTeamScopedTransientState(request.teamName); // Establish a per-team floor so late events from a previous run can't override UI. const floor = nowIso(); @@ -3119,25 +4343,13 @@ export const createTeamSlice: StateCreator = (set, const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam }; const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName]; delete nextRuntimeRunIdByTeam[request.teamName]; - const nextIgnoredRunIds = Object.fromEntries( - Object.entries(state.ignoredProvisioningRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); const nextIgnoredRuntimeRunIds = previousRuntimeRunId ? { - ...Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), + ...state.ignoredRuntimeRunIds, [previousRuntimeRunId]: request.teamName, } - : Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); + : state.ignoredRuntimeRunIds; + const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName); return { provisioningRuns: cleaned, provisioningErrorByTeam: nextErrors, @@ -3148,8 +4360,9 @@ export const createTeamSlice: StateCreator = (set, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, - ignoredProvisioningRunIds: nextIgnoredRunIds, + ignoredProvisioningRunIds: state.ignoredProvisioningRunIds, ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + ...visibleLoadingResets, }; }); @@ -3241,11 +4454,6 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [request.teamName]: response.runId, }, - ignoredRuntimeRunIds: Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), }; }); try { @@ -3285,6 +4493,10 @@ export const createTeamSlice: StateCreator = (set, launchTeam: async (request: TeamLaunchRequest) => { // Ensure provisioning progress subscription is active (defensive). get().subscribeProvisioningProgress(); + invalidateTeamLocalStateEpoch(request.teamName); + clearPendingReplyRefreshTimer(request.teamName); + clearPendingReplyRefreshWaits(request.teamName); + clearTeamScopedTransientState(request.teamName); // Establish a per-team floor so late events from a previous run can't override UI. const floor = nowIso(); @@ -3320,25 +4532,13 @@ export const createTeamSlice: StateCreator = (set, const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam }; const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName]; delete nextRuntimeRunIdByTeam[request.teamName]; - const nextIgnoredRunIds = Object.fromEntries( - Object.entries(state.ignoredProvisioningRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); const nextIgnoredRuntimeRunIds = previousRuntimeRunId ? { - ...Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), + ...state.ignoredRuntimeRunIds, [previousRuntimeRunId]: request.teamName, } - : Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); + : state.ignoredRuntimeRunIds; + const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName); return { provisioningRuns: cleaned, provisioningErrorByTeam: nextErrors, @@ -3349,8 +4549,9 @@ export const createTeamSlice: StateCreator = (set, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, - ignoredProvisioningRunIds: nextIgnoredRunIds, + ignoredProvisioningRunIds: state.ignoredProvisioningRunIds, ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + ...visibleLoadingResets, }; }); @@ -3424,11 +4625,6 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [request.teamName]: response.runId, }, - ignoredRuntimeRunIds: Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), }; }); try { @@ -3627,11 +4823,6 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [progress.teamName]: progress.runId, }, - ignoredRuntimeRunIds: Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== progress.teamName - ) - ), provisioningErrorByTeam: nextErrors, provisioningSnapshotByTeam: nextSnapshots, }; diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts index d6ee8102..5b6667ed 100644 --- a/src/renderer/utils/displayItemBuilder.ts +++ b/src/renderer/utils/displayItemBuilder.ts @@ -148,11 +148,7 @@ export function buildDisplayItems( // Build display items for (const step of steps) { // Skip the last output step - if ( - lastOutputStepRef && - step.id === lastOutputStepRef.id && - step.type === lastOutputStepRef.type - ) { + if (step.id === lastOutputStepRef?.id && step.type === lastOutputStepRef.type) { continue; } diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 7fd76aba..585ec2d5 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -1,6 +1,5 @@ import { getProviderScopedTeamModelLabel, - isSupportedAnthropicTeamModel, getRuntimeAwareTeamModelUiDisabledReason, getTeamProviderLabel, getTeamProviderModelOptions, @@ -12,11 +11,13 @@ import { GPT_5_2_CODEX_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, + isSupportedAnthropicTeamModel, normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, sortTeamProviderModels, TEAM_MODEL_UI_DISABLED_BADGE_LABEL, type TeamProviderModelOption, } from './teamModelCatalog'; +import { extractProviderScopedBaseModel } from './teamModelContext'; import type { CliProviderId, @@ -237,6 +238,14 @@ export function isTeamModelAvailableForUi( return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; } +export function normalizeExplicitTeamModelForUi( + providerId: SupportedProviderId | undefined, + model: string | undefined +): string { + const normalized = extractProviderScopedBaseModel(model, providerId) ?? ''; + return normalizeCatalogTeamModelForUi(providerId, normalized).trim(); +} + export function normalizeTeamModelForUi( providerId: SupportedProviderId | undefined, model: string | undefined, diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index f51c161a..4f593561 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -1,10 +1,10 @@ +import { parseModelString } from '@shared/utils/modelParser'; import { filterVisibleProviderRuntimeModels, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_2_CODEX_UI_DISABLED_MODEL, GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, } from '@shared/utils/providerModelVisibility'; -import { parseModelString } from '@shared/utils/modelParser'; import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index c2a1adf2..3d45b7d8 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,17 @@ type MemberSpawnStatusCollection = | Map | undefined; +interface ProvisioningMemberLike { + name: string; + removedAt?: number; + agentType?: string; + status?: string; + currentTaskId?: string | null; + taskCount?: number; + lastActiveAt?: string | null; + messageCount?: number; +} + interface FailedSpawnDetail { name: string; reason: string | null; @@ -138,7 +148,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 eb09c4c7..0a9ca719 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -56,7 +56,6 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, ProjectBranchChangeEvent, @@ -66,6 +65,7 @@ import type { TaskAttachmentMeta, TaskChangePresenceState, TaskComment, + TeamAgentRuntimeSnapshot, TeamChangeEvent, TeamClaudeLogsQuery, TeamClaudeLogsResponse, @@ -73,9 +73,9 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, @@ -83,6 +83,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TeamViewSnapshot, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -427,7 +428,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; @@ -451,8 +452,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 9dda365e..195fc79d 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -606,6 +606,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'; @@ -733,12 +738,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/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 484c441d..a00f616a 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -159,13 +159,7 @@ export function hasInstallationInScope( return installations.some((installation) => installation.scope === scope); } -/** - * Build a concise install-status label for plugin badges. - */ -export function getInstallationSummaryLabel( - installations: Pick[] -): string | null { - const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); +function summarizeInstallationScopes(scopes: InstallScope[]): string | null { if (scopes.length === 0) { return null; } @@ -175,6 +169,7 @@ export function getInstallationSummaryLabel( } switch (scopes[0]) { + case 'global': case 'user': return 'Installed globally'; case 'project': @@ -186,6 +181,16 @@ export function getInstallationSummaryLabel( } } +/** + * Build a concise install-status label for plugin badges. + */ +export function getInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + return summarizeInstallationScopes(scopes); +} + const MCP_SCOPE_PRIORITY: Record = { local: 0, project: 1, @@ -216,25 +221,7 @@ export function getMcpInstallationSummaryLabel( installations: Pick[] ): string | null { const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); - if (scopes.length === 0) { - return null; - } - - if (scopes.length > 1) { - return `Installed in ${scopes.length} scopes`; - } - - switch (scopes[0]) { - case 'global': - case 'user': - return 'Installed globally'; - case 'project': - return 'Installed in project'; - case 'local': - return 'Installed locally'; - default: - return 'Installed'; - } + return summarizeInstallationScopes(scopes); } /** diff --git a/src/shared/utils/rateLimitDetector.ts b/src/shared/utils/rateLimitDetector.ts index 20f54acf..2d1252a1 100644 --- a/src/shared/utils/rateLimitDetector.ts +++ b/src/shared/utils/rateLimitDetector.ts @@ -104,10 +104,10 @@ function parseRelativeResetDuration(text: string): number | null { const match = LEADING_TIME_VALUE_RE.exec(tail); if (!match) return null; - const amount = Number.parseFloat(match[1]!); + const amount = Number.parseFloat(match[1]); if (!Number.isFinite(amount) || amount < 0) return null; - const unit = match[2]!.toLowerCase(); + const unit = match[2].toLowerCase(); if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) { return Math.round(amount * 1000); } @@ -157,7 +157,7 @@ function parseAbsoluteResetClockTime(text: string, now: Date): Date | null { const afterMatch = tail.slice(tzTokenLength); if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null; - const hourRaw = Number.parseInt(match[1]!, 10); + const hourRaw = Number.parseInt(match[1], 10); const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0; const ampm = match[3]?.toLowerCase() ?? null; const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? ''; diff --git a/src/shared/utils/teamGraphDefaultLayout.ts b/src/shared/utils/teamGraphDefaultLayout.ts index 005883ae..c100a189 100644 --- a/src/shared/utils/teamGraphDefaultLayout.ts +++ b/src/shared/utils/teamGraphDefaultLayout.ts @@ -15,7 +15,7 @@ export interface TeamGraphDefaultLayoutSeed { assignments: Record; } -const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray> = [ +const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[])[] = [ [], [{ ringIndex: 0, sectorIndex: 0 }], [ @@ -83,7 +83,7 @@ export function buildTeamGraphDefaultLayoutSeed( const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length]; const assignments: Record = {}; - if (preset && preset.length === orderedVisibleOwnerIds.length) { + if (preset?.length === orderedVisibleOwnerIds.length) { orderedVisibleOwnerIds.forEach((stableOwnerId, index) => { assignments[stableOwnerId] = preset[index]!; }); diff --git a/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts index de8345a6..59d41c27 100644 --- a/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts +++ b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts @@ -44,7 +44,7 @@ describe('normalizeDashboardRecentProjectsPayload', () => { normalizeDashboardRecentProjectsPayload({ degraded: false, projects: null, - } as unknown as { degraded: boolean; projects: null }) + } as unknown as Parameters[0]) ).toBeNull(); }); }); diff --git a/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts b/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts new file mode 100644 index 00000000..e2f2b44c --- /dev/null +++ b/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { buildActiveTeamsByProject } from '@features/recent-projects/renderer/utils/activeProjectTeams'; + +import type { TeamSummary } from '@shared/types'; + +function makeTeamSummary( + overrides: Partial & Pick +): TeamSummary { + return { + ...overrides, + description: overrides.description ?? '', + memberCount: overrides.memberCount ?? 0, + taskCount: overrides.taskCount ?? 0, + lastActivity: overrides.lastActivity ?? null, + teamName: overrides.teamName, + displayName: overrides.displayName, + }; +} + +describe('buildActiveTeamsByProject', () => { + it('treats provisioning-active existing teams as active before aliveList catches up', () => { + const lintai = makeTeamSummary({ + teamName: 'signal-ops-3', + displayName: 'signal-ops-3', + projectPath: '/Users/test/lintai', + }); + + const teamsByProject = buildActiveTeamsByProject({ + teams: [lintai], + aliveTeamNames: [], + provisioningTeamNames: ['signal-ops-3'], + provisioningSnapshotByTeam: {}, + }); + + expect(teamsByProject.get('/users/test/lintai')).toEqual([lintai]); + }); + + it('includes synthetic provisioning snapshots for teams not yet present in team summaries', () => { + const provisioningSnapshot = makeTeamSummary({ + teamName: 'northstar-team', + displayName: 'Northstar Team', + projectPath: '/Users/test/northstar', + }); + + const teamsByProject = buildActiveTeamsByProject({ + teams: [], + aliveTeamNames: [], + provisioningTeamNames: ['northstar-team'], + provisioningSnapshotByTeam: { + 'northstar-team': provisioningSnapshot, + }, + }); + + expect(teamsByProject.get('/users/test/northstar')).toEqual([provisioningSnapshot]); + }); +}); diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 45a3d2f7..d7f68173 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -8,12 +8,13 @@ import type { BoardTaskExactLogSummariesResponse, InboxMessage, MessagesPage, + TeamViewSnapshot, TeamCreateRequest, TeamProvisioningProgress, } 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(() => []) }, })); @@ -35,6 +36,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ mockTeamDataWorkerClient: { isAvailable: vi.fn(), getTeamData: vi.fn(), + getMessagesPage: vi.fn(), + getMemberActivityMeta: vi.fn(), findLogsForTask: vi.fn(), }, })); @@ -63,6 +66,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, @@ -81,7 +86,6 @@ import { TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, - TEAM_GET_MESSAGES_PAGE, TEAM_START_TASK, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, @@ -133,23 +137,38 @@ describe('ipc teams handlers', () => { const service = { listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]), - getTeamData: vi.fn(async () => ({ + getTeamData: vi.fn(async (): Promise => ({ teamName: 'my-team', 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 (..._args: unknown[]): Promise => ({ 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'), @@ -241,6 +260,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, @@ -250,6 +271,7 @@ describe('ipc teams handlers', () => { undefined, undefined, undefined, + undefined, boardTaskActivityService as never, boardTaskActivityDetailService as never, boardTaskLogStreamService as never, @@ -266,6 +288,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); @@ -594,7 +618,6 @@ describe('ipc teams handlers', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [] as InboxMessage[], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -773,24 +796,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', @@ -805,12 +811,31 @@ 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('falls back TEAM_GET_DATA to the main thread 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; + data?: { teamName: string }; + }; + + expect(result.success).toBe(true); + expect(result.data?.teamName).toBe('my-team'); + expect(service.getTeamData).toHaveBeenCalledWith('my-team'); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; }); it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => { @@ -867,7 +892,7 @@ describe('ipc teams handlers', () => { const getDataHandler = handlers.get(TEAM_GET_DATA)!; const result = (await getDataHandler({} as never, 'my-team')) as { success: boolean; - data: { messages: Array<{ source?: string; messageId?: string }> }; + data: { messages?: InboxMessage[] }; }; expect(result.success).toBe(true); @@ -888,51 +913,141 @@ describe('ipc teams handlers', () => { } }); - 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: [], + 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', }); - 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', - }, - ]); - const getDataHandler = handlers.get(TEAM_GET_DATA)!; - const result = (await getDataHandler({} as never, 'my-team')) as { - success: boolean; - data: { messages: { source?: string; text: string }[] }; - }; + 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); - // 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('falls back TEAM_GET_MESSAGES_PAGE to the main thread 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; data?: { feedRevision: string } }; + + expect(result.success).toBe(true); + expect(result.data?.feedRevision).toBe('rev-1'); + expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', { + cursor: undefined, + limit: 50, + }); + 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('falls back TEAM_GET_MEMBER_ACTIVITY_META to the main thread 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; + data?: { feedRevision: string }; + }; + + expect(result.success).toBe(true); + expect(result.data?.feedRevision).toBe('rev-1'); + expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team'); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; }); it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => { @@ -1316,6 +1431,7 @@ describe('ipc teams handlers', () => { ], nextCursor: null, hasMore: false, + feedRevision: 'rev-1', }); provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ { @@ -1332,7 +1448,7 @@ describe('ipc teams handlers', () => { const getDataHandler = handlers.get(TEAM_GET_DATA)!; const result = (await getDataHandler({} as never, 'my-team')) as { success: boolean; - data: { messages: InboxMessage[] }; + data: { messages?: InboxMessage[] }; }; expect(result.success).toBe(true); @@ -1345,7 +1461,7 @@ describe('ipc teams handlers', () => { }), ]), }); - expect(result.data.messages.map((message) => message.messageId)).toEqual(['durable-0']); + expect(result.data.messages).toHaveLength(50); }); it('overlays live lead_process messages onto the newest messages page', async () => { @@ -1365,7 +1481,8 @@ describe('ipc teams handlers', () => { ].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)), nextCursor: '2026-02-23T10:00:00.000Z|durable-1', hasMore: true, - }; + feedRevision: 'rev-1', + } satisfies MessagesPage; }); provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ { @@ -1393,7 +1510,7 @@ describe('ipc teams handlers', () => { expect(result.data.hasMore).toBe(true); expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', { limit: 20, - beforeTimestamp: undefined, + cursor: undefined, liveMessages: expect.arrayContaining([ expect.objectContaining({ source: 'lead_process', @@ -1421,7 +1538,8 @@ describe('ipc teams handlers', () => { ], nextCursor: null, hasMore: false, - }; + feedRevision: 'rev-1', + } satisfies MessagesPage; }); provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ { @@ -1461,11 +1579,12 @@ describe('ipc teams handlers', () => { ], nextCursor: null, hasMore: false, + feedRevision: 'rev-1', }); const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', { limit: 20, - beforeTimestamp: '2026-02-23T10:00:00.000Z|cursor', + cursor: '2026-02-23T10:00:00.000Z|cursor', })) as { success: boolean; data: { messages: InboxMessage[] }; diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts index d9a2e8f2..69244398 100644 --- a/test/main/services/extensions/McpInstallationStateService.test.ts +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -1,22 +1,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter'; import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader'; import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService'; +const TEST_ROOT = path.parse(process.cwd()).root || path.sep; +const MOCK_HOME_PATH = path.join(TEST_ROOT, 'tmp', 'mock-home'); +const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a'); +const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b'); + +function normalizeMockPath(filePath: unknown): string { + return String(filePath).replaceAll('\\', '/'); +} + vi.mock('@main/utils/pathDecoder', () => ({ - getHomeDir: () => '/tmp/mock-home', - getClaudeBasePath: () => '/tmp/mock-home/.claude', + getHomeDir: () => { + const cwd = process.cwd(); + const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null; + const root = windowsRoot ?? '/'; + const sep = windowsRoot ? '\\' : '/'; + return `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`; + }, + getClaudeBasePath: () => { + const cwd = process.cwd(); + const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null; + const root = windowsRoot ?? '/'; + const sep = windowsRoot ? '\\' : '/'; + const mockHome = `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`; + return `${mockHome}${sep}.claude`; + }, setClaudeBasePathOverride: vi.fn(), })); vi.mock('node:fs/promises'); -function toPortablePath(filePath: unknown): string { - return String(filePath).replaceAll('\\', '/'); -} - describe('McpInstallationStateService', () => { let service: McpInstallationStateService; const mockedFs = vi.mocked(fs); @@ -35,14 +54,14 @@ describe('McpInstallationStateService', () => { describe('getInstalled', () => { it('includes local scope from the current project entry in ~/.claude.json', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); - if (normalizedPath === '/tmp/mock-home/.claude.json') { + const normalizedPath = normalizeMockPath(filePath); + if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) { return JSON.stringify({ mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, }, projects: { - '/tmp/project-a': { + [PROJECT_A_PATH]: { mcpServers: { stripe: { url: 'https://mcp.stripe.com' }, }, @@ -51,7 +70,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { paypal: { url: 'https://mcp.paypal.com/mcp' }, @@ -62,7 +81,7 @@ describe('McpInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - const entries = await service.getInstalled('/tmp/project-a'); + const entries = await service.getInstalled(PROJECT_A_PATH); expect(entries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, @@ -73,8 +92,8 @@ describe('McpInstallationStateService', () => { it('caches results within TTL for the same project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); - if (normalizedPath === '/tmp/mock-home/.claude.json') { + const normalizedPath = normalizeMockPath(filePath); + if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) { return JSON.stringify({ mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, @@ -82,7 +101,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, @@ -93,27 +112,27 @@ describe('McpInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - await service.getInstalled('/tmp/project-a'); - await service.getInstalled('/tmp/project-a'); + await service.getInstalled(PROJECT_A_PATH); + await service.getInstalled(PROJECT_A_PATH); expect(mockedFs.readFile).toHaveBeenCalledTimes(2); }); it('caches results independently per project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); - if (normalizedPath === '/tmp/mock-home/.claude.json') { + const normalizedPath = normalizeMockPath(filePath); + if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) { return JSON.stringify({ mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, }, projects: { - '/tmp/project-a': { + [PROJECT_A_PATH]: { mcpServers: { stripe: { url: 'https://mcp.stripe.com' }, }, }, - '/tmp/project-b': { + [PROJECT_B_PATH]: { mcpServers: { github: { command: 'uvx github-mcp' }, }, @@ -122,7 +141,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, @@ -130,7 +149,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-b/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_B_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { 'repo-b-server': { command: 'uvx repo-b-mcp' }, @@ -141,8 +160,8 @@ describe('McpInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - const projectAEntries = await service.getInstalled('/tmp/project-a'); - const projectBEntries = await service.getInstalled('/tmp/project-b'); + const projectAEntries = await service.getInstalled(PROJECT_A_PATH); + const projectBEntries = await service.getInstalled(PROJECT_B_PATH); expect(projectAEntries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, diff --git a/test/main/services/extensions/PluginInstallationStateService.test.ts b/test/main/services/extensions/PluginInstallationStateService.test.ts index e0c74ca7..cde8e492 100644 --- a/test/main/services/extensions/PluginInstallationStateService.test.ts +++ b/test/main/services/extensions/PluginInstallationStateService.test.ts @@ -1,20 +1,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { PluginInstallationStateService } from '@main/services/extensions/state/PluginInstallationStateService'; -// Mock pathDecoder to control ~/.claude path -vi.mock('@main/utils/pathDecoder', () => ({ - getClaudeBasePath: () => '/tmp/mock-claude', -})); +const TEST_ROOT = path.parse(process.cwd()).root || path.sep; +const MOCK_CLAUDE_BASE_PATH = path.join(TEST_ROOT, 'tmp', 'mock-claude'); +const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a'); +const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b'); -// Mock filesystem -vi.mock('node:fs/promises'); - -function toPortablePath(filePath: unknown): string { +function normalizeMockPath(filePath: unknown): string { return String(filePath).replaceAll('\\', '/'); } +vi.mock('@main/utils/pathDecoder', () => ({ + getClaudeBasePath: () => { + const cwd = process.cwd(); + const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null; + const root = windowsRoot ?? '/'; + const sep = windowsRoot ? '\\' : '/'; + return `${root}tmp${sep}mock-claude`; + }, +})); + +vi.mock('node:fs/promises'); + describe('PluginInstallationStateService', () => { let service: PluginInstallationStateService; const mockedFs = vi.mocked(fs); @@ -31,7 +41,7 @@ describe('PluginInstallationStateService', () => { describe('getInstalledPlugins', () => { it('returns user-scoped plugins enabled in user settings', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); + const normalizedPath = normalizeMockPath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, @@ -56,7 +66,7 @@ describe('PluginInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/mock-claude/settings.json') { + if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) { return JSON.stringify({ enabledPlugins: { 'context7@claude-plugins-official': true, @@ -79,7 +89,7 @@ describe('PluginInstallationStateService', () => { it('includes project and local scopes only for the active project', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); + const normalizedPath = normalizeMockPath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, @@ -109,7 +119,7 @@ describe('PluginInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/mock-claude/settings.json') { + if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) { return JSON.stringify({ enabledPlugins: { 'context7@claude-plugins-official': true, @@ -117,7 +127,7 @@ describe('PluginInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.claude/settings.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.json'))) { return JSON.stringify({ enabledPlugins: { 'typescript-lsp@claude-plugins-official': true, @@ -125,7 +135,10 @@ describe('PluginInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.claude/settings.local.json') { + if ( + normalizedPath === + normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.local.json')) + ) { return JSON.stringify({ enabledPlugins: { 'formatter@claude-plugins-official': true, @@ -136,7 +149,7 @@ describe('PluginInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - const entries = await service.getInstalledPlugins('/tmp/project-a'); + const entries = await service.getInstalledPlugins(PROJECT_A_PATH); expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([ ['context7@claude-plugins-official', 'user'], @@ -147,7 +160,7 @@ describe('PluginInstallationStateService', () => { it('does not leak another project scope into the current project', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); + const normalizedPath = normalizeMockPath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, @@ -170,7 +183,7 @@ describe('PluginInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - const entries = await service.getInstalledPlugins('/tmp/project-b'); + const entries = await service.getInstalledPlugins(PROJECT_B_PATH); expect(entries).toEqual([]); }); @@ -186,7 +199,7 @@ describe('PluginInstallationStateService', () => { it('returns empty array for unexpected version', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); + const normalizedPath = normalizeMockPath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 1, plugins: {} }); } @@ -202,7 +215,7 @@ describe('PluginInstallationStateService', () => { it('caches within TTL', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); + const normalizedPath = normalizeMockPath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, plugins: {} }); } @@ -220,7 +233,7 @@ describe('PluginInstallationStateService', () => { it('caches results independently per project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = toPortablePath(filePath); + const normalizedPath = normalizeMockPath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, plugins: {} }); } @@ -230,8 +243,8 @@ describe('PluginInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - await service.getInstalledPlugins('/tmp/project-a'); - await service.getInstalledPlugins('/tmp/project-b'); + await service.getInstalledPlugins(PROJECT_A_PATH); + await service.getInstalledPlugins(PROJECT_B_PATH); expect(mockedFs.readFile).toHaveBeenCalledTimes(8); }); diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 8e7549c7..49492cca 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -292,6 +292,9 @@ describe('CliInstallerService', () => { expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }), ]) ); + expect(verifiedProvider?.modelAvailability).not.toEqual( + expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })]) + ); await vi.waitFor(() => { const latestCodexProvider = service @@ -307,6 +310,12 @@ describe('CliInstallerService', () => { ]); }); + expect(execCli).not.toHaveBeenCalledWith( + '/usr/local/bin/claude', + expect.arrayContaining(['--model', 'gpt-5.2-codex']), + expect.anything() + ); + const statusEvents = mockWindow.webContents.send.mock.calls .filter((call: unknown[]) => call[0] === 'cliInstaller:progress') .map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } }) @@ -323,12 +332,29 @@ describe('CliInstallerService', () => { 'modelAvailability' in provider && (provider as { providerId?: string }).providerId === 'codex' && Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) && - (provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some( - (item) => item.status === 'unavailable' - ) + (provider as { modelAvailability: Array<{ modelId?: string; status?: string }> }) + .modelAvailability.some( + (item) => item.modelId === 'gpt-5.4' && item.status === 'available' + ) ) ) ).toBe(true); + expect( + statusEvents.some((event) => + event.status?.providers?.some( + (provider) => + typeof provider === 'object' && + provider !== null && + 'providerId' in provider && + 'modelAvailability' in provider && + (provider as { providerId?: string }).providerId === 'codex' && + Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) && + (provider as { modelAvailability: Array<{ modelId?: string }> }).modelAvailability.some( + (item) => item.modelId === 'gpt-5.2-codex' + ) + ) + ) + ).toBe(false); }); }); diff --git a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts index ab0634fe..578fad06 100644 --- a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts +++ b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts @@ -74,7 +74,7 @@ describe('CliProviderModelAvailabilityService', () => { expect(execCliMock).toHaveBeenCalledTimes(2); }); - it('marks unsupported models as unavailable with the runtime reason', async () => { + it('marks visible unsupported models as unavailable with the runtime reason', async () => { buildProviderAwareCliEnvMock.mockResolvedValue({ env: { HOME: '/Users/tester' }, connectionIssues: {}, diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index 1ae82d60..e19ac3b8 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -826,7 +826,6 @@ describe('BoardTaskLogStreamService integration', () => { expect(bashCommands).not.toContain('echo alien'); expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); }); - it('falls back to createdAt/updatedAt time window when workIntervals are missing', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-')); tempDirs.push(dir); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 5d184a7d..3f204b61 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -13,8 +13,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ import type { InboxMessage, KanbanState, + ResolvedTeamMember, TeamConfig, - TeamData, + TeamProcess, TeamTask, TeamTaskWithKanban, } from '../../../../src/shared/types/team'; @@ -338,10 +339,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 () => @@ -449,7 +449,7 @@ function createGetTeamDataHarness(options: { }; } -function buildResolvedMember(name: string): TeamData['members'][number] { +function buildResolvedMember(name: string): ResolvedTeamMember { return { name, status: 'unknown', @@ -726,6 +726,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( @@ -2535,8 +2568,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', @@ -2605,8 +2638,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'); @@ -2680,8 +2713,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(); @@ -2716,8 +2749,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' ); @@ -2753,8 +2786,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(); }); @@ -2788,8 +2821,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(); }); @@ -2823,8 +2856,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' ); @@ -2870,8 +2903,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' ); @@ -3362,11 +3395,11 @@ describe('TeamDataService', () => { const fixture = await createResolverBackedLeadFixture(); const service = createResolverBackedService(); - const data = await service.getTeamData(fixture.teamName); + const feed = await service.getMessageFeed(fixture.teamName); const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig; expect( - data.messages.find( + feed.messages.find( (message) => message.source === 'lead_session' && message.text.includes('recovered through the transcript resolver') @@ -3478,8 +3511,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 () => { @@ -3490,10 +3521,6 @@ describe('TeamDataService', () => { order.push('inboxNames:start'); return []; }, - getMessages: async () => { - order.push('messages:start'); - return messagesDeferred.promise; - }, getMembers: async () => { order.push('meta:start'); return []; @@ -3502,10 +3529,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 []; @@ -3527,39 +3550,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; @@ -3569,7 +3574,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')); }); @@ -3614,47 +3619,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', ]); @@ -3698,9 +3720,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 () => { @@ -3749,11 +3771,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, @@ -3763,10 +3784,6 @@ describe('TeamDataService', () => { id: 'task-1', subject: 'Investigate rollout', }), - ], - [ - expect.objectContaining({ messageId: 'sent-1' }), - expect.objectContaining({ messageId: 'lead-1' }), ] ); }); @@ -3805,16 +3822,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 []; @@ -3832,14 +3844,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'); }); @@ -3977,7 +3984,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 @@ -4011,6 +4018,41 @@ describe('TeamDataService', () => { 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); + }); + it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => { const fillerMessages = Array.from({ length: 55 }, (_, index) => ({ from: 'alice', @@ -4089,7 +4131,7 @@ describe('TeamDataService', () => { const page2 = await service.getMessagesPage('my-team', { limit: 10, - beforeTimestamp: page1.nextCursor!, + cursor: page1.nextCursor!, }); expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']); 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/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e7c5f475..e110fea8 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -175,6 +175,65 @@ function writeLaunchState( ); } +function createMemberSpawnStatusEntry( + overrides: Record = {} +): Record { + return { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + error: undefined, + updatedAt: new Date().toISOString(), + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + firstSpawnAcceptedAt: new Date().toISOString(), + lastHeartbeatAt: undefined, + ...overrides, + }; +} + +function createMemberSpawnRun(params?: { + runId?: string; + teamName?: string; + startedAt?: string; + expectedMembers?: string[]; + memberSpawnStatuses?: Map>; + memberSpawnLeadInboxCursorByMember?: Map; +}) { + const teamName = params?.teamName ?? 'member-spawn-team'; + const expectedMembers = params?.expectedMembers ?? ['alice']; + const memberSpawnStatuses = + params?.memberSpawnStatuses ?? + new Map([ + [ + expectedMembers[0]!, + createMemberSpawnStatusEntry({ + firstSpawnAcceptedAt: new Date(Date.now() - 5_000).toISOString(), + }), + ], + ]); + + return { + runId: params?.runId ?? 'run-member-spawn-1', + teamName, + startedAt: params?.startedAt ?? new Date(Date.now() - 60_000).toISOString(), + request: { + members: [], + }, + expectedMembers, + memberSpawnStatuses, + memberSpawnToolUseIds: new Map(), + memberSpawnLeadInboxCursorByMember: + params?.memberSpawnLeadInboxCursorByMember ?? new Map(), + provisioningOutputParts: [], + activeToolCalls: new Map(), + isLaunch: false, + provisioningComplete: false, + } as any; +} + describe('TeamProvisioningService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1181,4 +1240,311 @@ describe('TeamProvisioningService', () => { expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available'); expect(result.teamLaunchState).toBe('partial_failure'); }); + + it('does not reprocess already-seen teammate lead inbox messages', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + startedAt: '2026-04-16T09:00:00.000Z', + memberSpawnLeadInboxCursorByMember: new Map([ + [ + 'alice', + { + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-2', + }, + ], + ]), + }); + + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice', + text: 'heartbeat', + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-1', + read: false, + }, + { + from: 'alice', + text: 'heartbeat', + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-2', + read: false, + }, + ]); + + const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal'); + + await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run); + + expect(applySignalSpy).not.toHaveBeenCalled(); + }); + + it('processes an unseen teammate heartbeat on the first refresh', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + startedAt: '2026-04-16T09:00:00.000Z', + }); + + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice', + text: '{"type":"heartbeat","timestamp":"2026-04-16T10:00:00.000Z"}', + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-1', + read: false, + }, + ]); + + await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run); + + expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + hardFailure: false, + lastHeartbeatAt: '2026-04-16T10:00:00.000Z', + }); + expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({ + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-1', + }); + }); + + it('ignores teammate lead inbox signals that predate the current run', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + startedAt: '2026-04-16T10:00:00.000Z', + }); + + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice', + text: '{"type":"heartbeat","timestamp":"2026-04-16T09:59:59.000Z"}', + timestamp: '2026-04-16T09:59:59.000Z', + messageId: 'msg-early', + read: false, + }, + ]); + + const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal'); + + await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run); + + expect(applySignalSpy).not.toHaveBeenCalled(); + expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0); + expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + bootstrapConfirmed: false, + }); + }); + + it('ignores an unseen older lead inbox signal without replaying older state', async () => { + const latestHeartbeatAt = '2026-04-16T10:05:00.000Z'; + const existingEntry = createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + livenessSource: 'heartbeat', + bootstrapConfirmed: true, + lastHeartbeatAt: latestHeartbeatAt, + }); + const run = createMemberSpawnRun({ + startedAt: '2026-04-16T09:00:00.000Z', + memberSpawnStatuses: new Map([['alice', existingEntry]]), + memberSpawnLeadInboxCursorByMember: new Map([ + [ + 'alice', + { + timestamp: latestHeartbeatAt, + messageId: 'msg-3', + }, + ], + ]), + }); + const svc = new TeamProvisioningService(); + + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice', + text: 'Bootstrap failed: unsupported model', + timestamp: '2026-04-16T10:04:00.000Z', + messageId: 'msg-2b', + read: false, + }, + { + from: 'alice', + text: 'heartbeat', + timestamp: latestHeartbeatAt, + messageId: 'msg-3', + read: false, + }, + ]); + + const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal'); + + await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run); + + expect(applySignalSpy).not.toHaveBeenCalled(); + expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry); + expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({ + timestamp: latestHeartbeatAt, + messageId: 'msg-3', + }); + }); + + it('applies an unseen newer failure signal and transitions the member to failed_to_start', async () => { + const latestHeartbeatAt = '2026-04-16T10:00:00.000Z'; + const run = createMemberSpawnRun({ + startedAt: '2026-04-16T09:00:00.000Z', + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + livenessSource: 'heartbeat', + bootstrapConfirmed: true, + lastHeartbeatAt: latestHeartbeatAt, + }), + ], + ]), + memberSpawnLeadInboxCursorByMember: new Map([ + [ + 'alice', + { + timestamp: latestHeartbeatAt, + messageId: 'msg-1', + }, + ], + ]), + }); + const svc = new TeamProvisioningService(); + + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice', + text: 'Bootstrap failed: unsupported model', + timestamp: '2026-04-16T10:01:00.000Z', + messageId: 'msg-2', + read: false, + }, + ]); + + await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run); + + expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'Bootstrap failed: unsupported model', + }); + expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({ + timestamp: '2026-04-16T10:01:00.000Z', + messageId: 'msg-2', + }); + }); + + it('applies an unseen same-timestamp signal with a greater messageId and advances the cursor', async () => { + const run = createMemberSpawnRun({ + startedAt: '2026-04-16T09:00:00.000Z', + memberSpawnLeadInboxCursorByMember: new Map([ + [ + 'alice', + { + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-2', + }, + ], + ]), + }); + const svc = new TeamProvisioningService(); + + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice', + text: 'heartbeat', + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-2', + read: false, + }, + { + from: 'alice', + text: 'heartbeat', + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-3', + read: false, + }, + ]); + + const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal'); + + await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run); + + expect(applySignalSpy).toHaveBeenCalledTimes(1); + expect(applySignalSpy).toHaveBeenCalledWith( + run, + 'alice', + expect.objectContaining({ messageId: 'msg-3' }) + ); + expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({ + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-3', + }); + }); + + it('does not bump lastHeartbeatAt for an equal heartbeat timestamp', () => { + const existingEntry = createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + livenessSource: 'heartbeat', + bootstrapConfirmed: true, + lastHeartbeatAt: '2026-04-16T10:00:00.000Z', + }); + const run = createMemberSpawnRun({ + memberSpawnStatuses: new Map([['alice', existingEntry]]), + }); + const svc = new TeamProvisioningService(); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-04-16T10:00:00.000Z' + ); + + expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry); + }); + + it('does not bump lastHeartbeatAt for an older heartbeat timestamp', () => { + const existingEntry = createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + livenessSource: 'heartbeat', + bootstrapConfirmed: true, + lastHeartbeatAt: '2026-04-16T10:00:00.000Z', + }); + const run = createMemberSpawnRun({ + memberSpawnStatuses: new Map([['alice', existingEntry]]), + }); + const svc = new TeamProvisioningService(); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-04-16T09:59:59.000Z' + ); + + expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry); + }); + }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 64e668fb..dfefce73 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -167,7 +167,12 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); afterEach(() => { - fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(tempRoot, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); }); it('does not create missing directories during prepareForProvisioning', async () => { @@ -401,7 +406,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( new Error( - 'Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' + 'Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' ) ); @@ -417,6 +422,26 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); + it('surfaces preflight timeouts with the orchestrator-cli label', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + warning: + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + }); + + expect(result.ready).toBe(true); + expect(result.warnings).toContain( + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence' + ); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index f465da8b..75adc868 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -381,6 +381,20 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => ); }); + it('add-member spawn prompt explicitly forbids no-task bootstrap chatter', () => { + const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', { + name: 'alice', + role: 'developer', + }); + + expect(prompt).toContain( + 'If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.' + ); + expect(prompt).toContain( + 'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.' + ); + }); + it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => { const teamName = 'forward-live-team'; const teamDir = path.join(tempTeamsBase, teamName); @@ -518,7 +532,6 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain( 'Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.' ); - await svc.cancelProvisioning(runId); }); diff --git a/test/main/services/team/TeammateToolTracker.test.ts b/test/main/services/team/TeammateToolTracker.test.ts index c6ea4f49..a41e0ccc 100644 --- a/test/main/services/team/TeammateToolTracker.test.ts +++ b/test/main/services/team/TeammateToolTracker.test.ts @@ -49,6 +49,13 @@ function createDeferred(): Deferred { return { promise, resolve, reject }; } +function createLogsFinderMock(listAttributedMemberFiles: ReturnType) { + return { + listAttributedMemberFiles, + listAttributedSubagentFiles: listAttributedMemberFiles, + } as never; +} + describe('TeammateToolTracker', () => { const tempDirs: string[] = []; @@ -90,7 +97,7 @@ describe('TeammateToolTracker', () => { const events: TeamChangeEvent[] = []; const tracker = new TeammateToolTracker( - { listAttributedSubagentFiles } as never, + createLogsFinderMock(listAttributedSubagentFiles), { enableTracking, disableTracking } as never, (event) => events.push(event) ); @@ -136,7 +143,7 @@ describe('TeammateToolTracker', () => { ]); const events: TeamChangeEvent[] = []; const tracker = new TeammateToolTracker( - { listAttributedSubagentFiles } as never, + createLogsFinderMock(listAttributedSubagentFiles), { enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })), disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, (event) => events.push(event) ); @@ -193,7 +200,7 @@ describe('TeammateToolTracker', () => { const listAttributedSubagentFiles = vi.fn(async () => [...attributedFiles]); const events: TeamChangeEvent[] = []; const tracker = new TeammateToolTracker( - { listAttributedSubagentFiles } as never, + createLogsFinderMock(listAttributedSubagentFiles), { enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })), disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, (event) => events.push(event) ); @@ -242,7 +249,7 @@ describe('TeammateToolTracker', () => { ]); const events: TeamChangeEvent[] = []; const tracker = new TeammateToolTracker( - { listAttributedSubagentFiles } as never, + createLogsFinderMock(listAttributedSubagentFiles), { enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })), disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, (event) => events.push(event) ); @@ -299,7 +306,7 @@ describe('TeammateToolTracker', () => { ]); const events: TeamChangeEvent[] = []; const tracker = new TeammateToolTracker( - { listAttributedSubagentFiles } as never, + createLogsFinderMock(listAttributedSubagentFiles), { enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })), disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, (event) => events.push(event) ); @@ -353,7 +360,7 @@ describe('TeammateToolTracker', () => { const disableTracking = vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })); const events: TeamChangeEvent[] = []; const tracker = new TeammateToolTracker( - { listAttributedSubagentFiles } as never, + createLogsFinderMock(listAttributedSubagentFiles), { enableTracking, disableTracking } as never, (event) => events.push(event) ); diff --git a/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts index b3643f27..7ec84492 100644 --- a/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts +++ b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts @@ -264,13 +264,15 @@ describe('PluginDetailDialog project context', () => { }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const projectOption = scopeSelect.querySelector( + 'option[value="project"]' + ) as HTMLOptionElement | null; + const localOption = scopeSelect.querySelector( + 'option[value="local"]' + ) as HTMLOptionElement | null; expect(scopeSelect).not.toBeNull(); - expect( - (scopeSelect.querySelector('option[value="project"]') as HTMLOptionElement | null)?.disabled - ).toBe(true); - expect( - (scopeSelect.querySelector('option[value="local"]') as HTMLOptionElement | null)?.disabled - ).toBe(true); + expect(projectOption?.disabled).toBe(true); + expect(localOption?.disabled).toBe(true); await act(async () => { root.unmount(); diff --git a/test/renderer/components/sidebar/SidebarTaskItem.test.ts b/test/renderer/components/sidebar/SidebarTaskItem.test.ts new file mode 100644 index 00000000..8d6e1d92 --- /dev/null +++ b/test/renderer/components/sidebar/SidebarTaskItem.test.ts @@ -0,0 +1,142 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GlobalTask } from '../../../../src/shared/types'; + +const storeState = { + openGlobalTaskDetail: vi.fn(), + teamByName: {} as Record, +}; + +let unreadCountValue = 0; +let isLightValue = false; + +vi.mock('../../../../src/renderer/store', () => ({ + useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +vi.mock('../../../../src/renderer/hooks/useUnreadCommentCount', () => ({ + useUnreadCommentCount: () => unreadCountValue, +})); + +vi.mock('../../../../src/renderer/hooks/useTheme', () => ({ + useTheme: () => ({ + theme: isLightValue ? 'light' : 'dark', + resolvedTheme: isLightValue ? 'light' : 'dark', + isDark: !isLightValue, + isLight: isLightValue, + }), +})); + +vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/constants/teamColors', () => ({ + getTeamColorSet: () => ({ text: '#fff', textLight: '#000' }), +})); + +vi.mock('../../../../src/renderer/utils/memberHelpers', () => ({ + buildMemberColorMap: () => new Map(), + REVIEW_STATE_DISPLAY: { + needsFix: { bg: 'bg-red-500/10', text: 'text-red-300', label: 'Needs fix' }, + }, +})); + +vi.mock('../../../../src/renderer/utils/projectColor', () => ({ + nameColorSet: () => ({ text: '#fff' }), + projectColor: () => ({ text: '#fff' }), +})); + +vi.mock('../../../../src/renderer/utils/taskGrouping', () => ({ + projectLabelFromPath: () => 'hookplex', +})); + +vi.mock('../../../../src/shared/utils/reviewState', () => ({ + getTaskKanbanColumn: () => 'todo', +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + CheckCircle2: Icon, + Circle: Icon, + Eye: Icon, + Loader2: Icon, + ShieldCheck: Icon, + Trash2: Icon, + }; +}); + +import { SidebarTaskItem } from '../../../../src/renderer/components/sidebar/SidebarTaskItem'; + +function makeTask(overrides: Partial = {}): GlobalTask { + return { + id: 'task-1', + displayId: 'task1', + teamName: 'alpha-team', + teamDisplayName: 'Alpha Team', + subject: 'Review docs', + description: '', + status: 'in_progress', + owner: 'alice', + createdAt: '2026-04-18T10:00:00.000Z', + updatedAt: '2026-04-18T10:10:00.000Z', + reviewState: 'none', + reviewNotes: [], + blockedBy: [], + blocks: [], + comments: [], + attachments: [], + workIntervals: [], + kanbanColumnId: null, + projectPath: '/workspace/hookplex', + ...overrides, + } as GlobalTask; +} + +describe('SidebarTaskItem unread styling', () => { + beforeEach(() => { + unreadCountValue = 0; + isLightValue = false; + storeState.openGlobalTaskDetail.mockReset(); + storeState.teamByName = {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('uses the softened unread background tint in dark theme', async () => { + unreadCountValue = 2; + isLightValue = false; + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(SidebarTaskItem, { task: makeTask() })); + await Promise.resolve(); + }); + + const button = host.querySelector('button'); + expect(button?.className).toContain('bg-blue-500/[0.05]'); + expect(button?.className).not.toContain('bg-blue-500/[0.08]'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); 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/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 482a3926..64205bcc 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -7,6 +7,8 @@ vi.mock('@renderer/hooks/useTheme', () => ({ })); vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content), + CompactMarkdownPreview: ({ content, className }: { content: string; className?: string }) => + React.createElement('div', { className }, content), })); vi.mock('@renderer/components/common/CopyButton', () => ({ CopyButton: () => null, @@ -25,6 +27,8 @@ vi.mock('@renderer/components/ui/ExpandableContent', () => ({ React.createElement(React.Fragment, null, children), })); vi.mock('@renderer/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), TooltipTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -45,6 +49,231 @@ import { } from '@renderer/components/team/activity/ActivityItem'; import type { InboxMessage } from '@shared/types'; +describe('ActivityItem compact header preview', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses a two-line clamped preview in compact mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const summary = + 'Делегировал alice длинную задачу с заметно более длинным описанием, чтобы превью занимало больше одной строки в компактном режиме.'; + + const message: InboxMessage = { + from: 'team-lead', + text: summary, + summary, + timestamp: new Date('2026-04-18T16:30:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(summary); + expect(preview?.getAttribute('title')).toBeNull(); + expect(preview?.className).toContain('line-clamp-2'); + expect(preview?.className).toContain('w-full'); + expect(preview?.className).toContain('max-w-full'); + expect(preview?.className).not.toContain('min-h-8'); + expect(preview?.className).not.toContain('truncate'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('prefers full message text over a pre-truncated summary in compact mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const fullText = + 'Делегировал bob ещё один узкий шаг: собрать fix-batch с учётом landing P0 по render->generate и пройтись по оставшимся edge cases.'; + + const message: InboxMessage = { + from: 'team-lead', + text: fullText, + summary: 'Делегировал bob ещё один узкий шаг: собрать fix-batch с у...', + timestamp: new Date('2026-04-18T16:29:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-full-text', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(fullText); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('strips info_for_agent blocks from compact preview text', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const visibleText = 'New task assigned to you: #3fd70e2 Собрать fix-batch'; + const message: InboxMessage = { + from: 'team-lead', + text: `${visibleText}\n\ninternal only\n`, + timestamp: new Date('2026-04-18T16:28:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-strip-agent-block', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toContain('**New task assigned to you:**'); + expect(preview?.textContent).toContain('[#3fd70e2](task://3fd70e2)'); + expect(preview?.textContent).toContain('Собрать fix-batch'); + expect(preview?.textContent).not.toContain('info_for_agent'); + expect(preview?.textContent).not.toContain('internal only'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('reuses markdown display content for compact preview formatting', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const markdownText = '**Важно** проверить `CurrentTaskIndicator` и #abc123'; + + const message: InboxMessage = { + from: 'team-lead', + text: markdownText, + timestamp: new Date('2026-04-18T16:31:00.000Z').toISOString(), + read: true, + source: 'lead_process', + taskRefs: [{ taskId: 'abc123', displayId: '#abc123', teamName: 'my-team' }], + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-markdown-preview', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toContain('**Важно**'); + expect(preview?.textContent).toContain('task://abc123'); + expect(preview?.textContent).toContain('`CurrentTaskIndicator`'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses a two-line preview in collapsed wide mode, not inline one-line summary', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const fullText = + 'Делегировал alice финальную общую сводку и remediation plan по всем findings команды.'; + + const message: InboxMessage = { + from: 'team-lead', + text: fullText, + timestamp: new Date('2026-04-18T16:30:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: false, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-wide-collapsed', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(fullText); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); + describe('ActivityItem slash command rendering', () => { afterEach(() => { document.body.innerHTML = ''; diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index cdb504de..0960a38b 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -1,8 +1,40 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, vi } from 'vitest'; + +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + CompactMarkdownPreview: ({ content, className }: { content: string; className?: string }) => + React.createElement('div', { className }, content), +})); +vi.mock('@renderer/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), +})); +vi.mock('../../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({ + ENTRY_REVEAL_ANIMATION_MS: 220, + ENTRY_REVEAL_EASING: 'ease', + AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})); +vi.mock('../../../../../src/renderer/components/team/activity/ThoughtBodyContent', () => ({ + ThoughtBodyContent: ({ thought }: { thought: { text: string } }) => + React.createElement('div', null, thought.text), +})); +vi.mock('@renderer/utils/memberHelpers', () => ({ + agentAvatarUrl: () => '/avatar.png', +})); import { groupTimelineItems, isLeadThought, + LeadThoughtsGroupRow, } from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup'; import type { InboxMessage } from '../../../../../src/shared/types'; @@ -19,6 +51,29 @@ function makeLeadSessionMsg(text: string, overrides?: Partial): In } describe('LeadThoughtsGroup', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + disconnect() {} + } + ); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + } + ); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + it('does not classify slash command results as lead thoughts', () => { const resultMessage: InboxMessage = { from: 'team-lead', @@ -118,4 +173,192 @@ describe('LeadThoughtsGroup', () => { } }); }); + + it('uses a two-line clamped preview in compact header mode', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const preview = + 'Это длинный preview текста для lead thoughts, который должен занимать до двух строк в compact header, а не одну.'; + + const thought = makeLeadSessionMsg(preview, { + messageId: 'thought-1', + leadSessionId: 'lead-session-1', + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(preview); + expect(previewNode?.getAttribute('title')).toBeNull(); + expect(previewNode?.className).toContain('line-clamp-2'); + expect(previewNode?.className).toContain('w-full'); + expect(previewNode?.className).toContain('max-w-full'); + expect(previewNode?.className).not.toContain('min-h-8'); + expect(previewNode?.className).not.toContain('truncate'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses the normalized full thought text instead of only the first line in compact header mode', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const firstLine = 'Собрать единый remediation plan.'; + const secondLine = 'Проверить remaining edge cases по graph и messages.'; + const preview = `${firstLine} ${secondLine}`; + + const thought = makeLeadSessionMsg(`${firstLine}\n${secondLine}`, { + messageId: 'thought-2', + leadSessionId: 'lead-session-2', + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(preview); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('strips info_for_agent blocks from compact thoughts preview', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const visibleText = 'Собрать единый remediation plan.'; + + const thought = makeLeadSessionMsg( + `${visibleText}\n\ninternal note\n`, + { + messageId: 'thought-3', + leadSessionId: 'lead-session-3', + } + ); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(visibleText); + expect(previewNode?.textContent).not.toContain('info_for_agent'); + expect(previewNode?.textContent).not.toContain('internal note'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses a two-line preview in collapsed wide mode for thought groups', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const preview = + 'Делегировал alice финальную общую сводку и remediation plan по всем findings команды.'; + + const thought = makeLeadSessionMsg(preview, { + messageId: 'thought-4', + leadSessionId: 'lead-session-4', + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: false, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(preview); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('reuses the expanded thought markdown preprocessing for compact preview', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const thought = makeLeadSessionMsg('**Важно** проверить #task123 и ping @alice', { + messageId: 'thought-4', + leadSessionId: 'lead-session-4', + taskRefs: [{ taskId: 'task123', displayId: '#task123', teamName: 'my-team' }], + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + memberColorMap: new Map([['alice', 'blue']]), + teamNames: ['my-team'], + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toContain('**Важно**'); + expect(previewNode?.textContent).toContain('[#task123](task://task123)'); + expect(previewNode?.textContent).toContain('mention://blue/alice'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 7c69269d..931753f9 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -128,4 +128,36 @@ describe('ProvisioningProviderStatusList', () => { await Promise.resolve(); }); }); + + it('normalizes generic preflight timeout notes without depending on a hardcoded CLI name', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'notes', + backendSummary: 'Default adapter', + details: [ + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex (Default adapter): CLI preflight did not complete'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts b/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts new file mode 100644 index 00000000..46a7c78d --- /dev/null +++ b/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProviderPrepareModelCacheKey } from '@renderer/components/team/dialogs/providerPrepareCacheKey'; + +describe('buildProviderPrepareModelCacheKey', () => { + it('separates limit-context variants for the same provider runtime', () => { + const sharedInput = { + cwd: '/tmp/project', + providerId: 'anthropic' as const, + backendSummary: 'Claude Code', + }; + + expect( + buildProviderPrepareModelCacheKey({ + ...sharedInput, + limitContext: false, + }) + ).not.toBe( + buildProviderPrepareModelCacheKey({ + ...sharedInput, + limitContext: true, + }) + ); + }); + + it('still reuses cache for identical runtime conditions', () => { + const input = { + cwd: '/tmp/project', + providerId: 'codex' as const, + backendSummary: 'Default adapter', + limitContext: false, + }; + + expect(buildProviderPrepareModelCacheKey(input)).toBe(buildProviderPrepareModelCacheKey(input)); + }); +}); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index ecd70446..65feb589 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; +import { + buildReusableProviderPrepareModelResults, + runProviderPrepareDiagnostics, +} from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import type { TeamProvisioningPrepareResult } from '@shared/types'; @@ -17,6 +20,39 @@ function createDeferred(): { } describe('runProviderPrepareDiagnostics', () => { + it('does not keep transient note results in the reusable cache', () => { + expect( + buildReusableProviderPrepareModelResults({ + 'gpt-5.4': { + status: 'ready', + line: '5.4 - verified', + warningLine: null, + }, + 'gpt-5.3-codex': { + status: 'notes', + line: '5.3 Codex - check failed - Model verification timed out', + warningLine: '5.3 Codex - check failed - Model verification timed out', + }, + 'gpt-5.2-codex': { + status: 'failed', + line: '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + warningLine: null, + }, + }) + ).toEqual({ + 'gpt-5.4': { + status: 'ready', + line: '5.4 - verified', + warningLine: null, + }, + 'gpt-5.2-codex': { + status: 'failed', + line: '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + warningLine: null, + }, + }); + }); + it('returns a failed provider result immediately when runtime preflight fails', async () => { const prepareProvisioning = vi.fn< ( @@ -42,9 +78,8 @@ describe('runProviderPrepareDiagnostics', () => { expect(prepareProvisioning).toHaveBeenCalledTimes(1); }); - it('emits per-model progress updates and keeps failures scoped to the affected model', async () => { - const deferred54 = createDeferred(); - const deferred52 = createDeferred(); + it('batches uncached model probes per provider and keeps failures scoped to the affected model', async () => { + const deferredBatch = createDeferred(); const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = []; @@ -62,10 +97,8 @@ describe('runProviderPrepareDiagnostics', () => { message: 'CLI is warmed up and ready to launch', }); } - if (selectedModels[0] === 'gpt-5.4') { - return deferred54.promise; - } - return deferred52.promise; + expect(selectedModels).toEqual(['gpt-5.4', 'gpt-5.2-codex']); + return deferredBatch.promise; }); const resultPromise = runProviderPrepareDiagnostics({ @@ -83,24 +116,13 @@ describe('runProviderPrepareDiagnostics', () => { details: ['5.4 - checking...', '5.2 Codex - checking...'], }); - deferred54.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - details: ['Selected model gpt-5.4 verified for launch.'], - }); - await Promise.resolve(); - await Promise.resolve(); - - expect(progressUpdates.at(-1)).toEqual({ - completedCount: 1, - totalCount: 2, - details: ['5.4 - verified', '5.2 Codex - checking...'], - }); - - deferred52.resolve({ + deferredBatch.resolve({ ready: false, - message: + message: 'Some provider runtimes are not ready', + details: ['Selected model gpt-5.4 verified for launch.'], + warnings: [ "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + ], }); const result = await resultPromise; @@ -117,6 +139,7 @@ describe('runProviderPrepareDiagnostics', () => { '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', ], }); + expect(prepareProvisioning).toHaveBeenCalledTimes(2); }); it('normalizes raw Codex API error envelopes into a clean model reason', async () => { @@ -173,7 +196,7 @@ describe('runProviderPrepareDiagnostics', () => { ready: true, message: 'CLI is warmed up and ready to launch', warnings: [ - 'Selected model gpt-5.3-codex could not be verified. Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence', + 'Selected model gpt-5.3-codex could not be verified. Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence', ], }); }); @@ -349,4 +372,45 @@ describe('runProviderPrepareDiagnostics', () => { 'gpt-5.2-codex', ], undefined); }); + + it('suppresses a timed out runtime preflight note when that same model later verifies', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch (see notes)', + warnings: [ + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', + ], + }); + } + + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [ + 'Selected model gpt-5.4-mini verified for launch.', + 'Selected model gpt-5.4 verified for launch.', + ], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4-mini', 'gpt-5.4'], + prepareProvisioning, + }); + + expect(result.status).toBe('ready'); + expect(result.warnings).toEqual([]); + expect(result.details).toEqual(['5.4 Mini - verified', '5.4 - verified']); + }); }); diff --git a/test/renderer/components/team/members/CurrentTaskIndicator.test.ts b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts new file mode 100644 index 00000000..c538792f --- /dev/null +++ b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts @@ -0,0 +1,77 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CurrentTaskIndicator } from '@renderer/components/team/members/CurrentTaskIndicator'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +const task: TeamTaskWithKanban = { + id: 'task-1', + displayId: '9d1915a7', + subject: 'Полный аудит актуальности документации и связанных onboarding заметок', + status: 'in_progress', +} as unknown as TeamTaskWithKanban; + +describe('CurrentTaskIndicator', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('uses all available width for the task pill without early subject truncation', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CurrentTaskIndicator, { + task, + borderColor: '#3b82f6', + }) + ); + await Promise.resolve(); + }); + + const wrapper = host.firstElementChild as HTMLElement | null; + const button = host.querySelector('button'); + + expect(wrapper?.className).toContain('flex-1'); + expect(button?.className).toContain('flex-1'); + expect(button?.className).toContain('text-left'); + expect(button?.textContent).toContain(task.subject); + expect(button?.style.border).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('still supports an explicit subject ceiling when a compact caller requests it', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CurrentTaskIndicator, { + task, + borderColor: '#3b82f6', + maxSubjectLength: 12, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('button'); + expect(button?.textContent).toContain('Полный аудит…'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index ff2426eb..e0058c00 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -61,7 +61,7 @@ describe('MemberCard starting-state visuals', () => { document.body.innerHTML = ''; }); - it('shows starting skeleton treatment even after provisioning is no longer active', async () => { + it('shows runtime summary while keeping the starting treatment after provisioning stops', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -84,8 +84,9 @@ describe('MemberCard starting-state visuals', () => { }); expect(host.textContent).toContain('starting'); + expect(host.textContent).toContain('Anthropic · haiku · Medium'); expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull(); - expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThan(0); + expect(host.querySelectorAll('.skeleton-shimmer').length).toBe(0); await act(async () => { root.unmount(); @@ -173,7 +174,7 @@ describe('MemberCard starting-state visuals', () => { }); }); - it('keeps the starting skeleton visible while a runtime is alive but still joining', async () => { + it('keeps the starting treatment and runtime summary visible while a runtime is still joining', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -197,9 +198,10 @@ describe('MemberCard starting-state visuals', () => { }); expect(host.textContent).toContain('starting'); + expect(host.textContent).toContain('Anthropic · sonnet · Medium'); expect(host.textContent).not.toContain('online'); expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull(); - expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThan(0); + expect(host.querySelectorAll('.skeleton-shimmer').length).toBe(0); await act(async () => { root.unmount(); @@ -238,4 +240,38 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('shows member color on the avatar ring instead of a colored card rail', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + }) + ); + await Promise.resolve(); + }); + + const img = host.querySelector('img'); + const avatarRing = img?.parentElement; + const clickableCard = host.querySelector('[role="button"]') as HTMLElement | null; + + expect(avatarRing).not.toBeNull(); + expect(avatarRing?.style.borderColor).toBe('#3b82f6'); + expect(clickableCard?.style.borderLeft).toBe(''); + expect(clickableCard?.style.background).toBe(''); + expect(clickableCard?.className).not.toContain('px-'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts new file mode 100644 index 00000000..cfc8d56b --- /dev/null +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -0,0 +1,213 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useStore } from '@renderer/store'; + +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; + +vi.mock('@renderer/hooks/useMemberStats', () => ({ + useMemberStats: () => ({ + stats: null, + loading: false, + error: null, + }), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => + React.createElement( + 'button', + { + type: 'button', + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + DialogContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogFooter: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogHeader: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/tabs', () => { + let currentValue = ''; + let currentOnValueChange: ((value: string) => void) | null = null; + + return { + Tabs: ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode; + value: string; + onValueChange?: (value: string) => void; + }) => { + currentValue = value; + currentOnValueChange = onValueChange ?? null; + return React.createElement('div', { 'data-tabs-value': value }, children); + }, + TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + TabsTrigger: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-state': currentValue === value ? 'active' : 'inactive', + onClick: () => currentOnValueChange?.(value), + }, + children + ), + TabsContent: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => (currentValue === value ? React.createElement('div', null, children) : null), + }; +}); + +vi.mock('@renderer/components/team/members/MemberDetailHeader', () => ({ + MemberDetailHeader: () => React.createElement('div', null, 'header'), +})); + +vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({ + MemberDetailStats: ({ activityCount }: { activityCount: number }) => + React.createElement('div', { 'data-testid': 'member-detail-stats' }, `activity-count:${activityCount}`), +})); + +vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({ + MemberTasksTab: () => React.createElement('div', null, 'tasks-tab'), +})); + +vi.mock('@renderer/components/team/members/MemberMessagesTab', () => ({ + MemberMessagesTab: () => React.createElement('div', null, 'activity-tab'), +})); + +vi.mock('@renderer/components/team/members/MemberStatsTab', () => ({ + MemberStatsTab: () => React.createElement('div', null, 'stats-tab'), +})); + +vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({ + MemberLogsTab: () => React.createElement('div', null, 'logs-tab'), +})); + +import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog'; + +describe('MemberDetailDialog activity count', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + 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 = ''; + useStore.setState({ teamMessagesByName: {} } as never); + vi.unstubAllGlobals(); + }); + + it('counts task comments in the Activity badge even when messageCount is zero', async () => { + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }; + const members: ResolvedTeamMember[] = [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + }, + member, + ]; + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-1', + displayId: '#1', + subject: 'Review patch', + owner: 'jack', + status: 'in_progress', + comments: [ + { + id: 'comment-1', + author: 'jack', + text: 'Left a review note', + createdAt: '2026-04-17T10:00:00.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as TeamTaskWithKanban, + ]; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + members, + tasks, + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('activity-count:1'); + expect(host.textContent).toContain('Activity1'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); 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..c8cfad60 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') @@ -160,7 +194,7 @@ describe('MemberMessagesTab', () => { }); }); - it('hides load older messages when the member has no visible activity', async () => { + it('shows load older messages when older pages may still contain this member activity', async () => { getMessagesPage.mockResolvedValue({ messages: [ { @@ -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,8 +281,9 @@ describe('MemberMessagesTab', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('No activity with this member'); - expect(host.textContent).not.toContain('Load older messages'); + expect(getMessagesPage).not.toHaveBeenCalled(); + expect(host.textContent).toContain('No loaded activity for this member yet'); + expect(host.textContent).toContain('Load older messages'); await act(async () => { root.unmount(); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 9ccf72c3..632ced5b 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, pendingRepliesByMember: {}, onPendingReplyChange: vi.fn(), @@ -225,6 +252,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', @@ -232,7 +270,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages, timeWindow: null, pendingRepliesByMember: { alice: pendingSentAtMs }, onPendingReplyChange, @@ -269,6 +306,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', @@ -276,7 +324,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages, timeWindow: null, pendingRepliesByMember: { alice: pendingSentAtMs }, onPendingReplyChange, @@ -306,6 +353,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', @@ -314,7 +372,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages: [makeMessage()], timeWindow: null, pendingRepliesByMember: {}, onPendingReplyChange: vi.fn(), diff --git a/test/renderer/features/agent-graph/GraphActivityHud.test.ts b/test/renderer/features/agent-graph/GraphActivityHud.test.ts index 4bea44a0..dd04d705 100644 --- a/test/renderer/features/agent-graph/GraphActivityHud.test.ts +++ b/test/renderer/features/agent-graph/GraphActivityHud.test.ts @@ -15,11 +15,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', @@ -29,7 +28,6 @@ const teamState = { { name: 'jack', agentType: 'developer' }, ], tasks: [], - messages: [], }, ], ]), @@ -54,6 +52,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/GraphView.test.ts b/test/renderer/features/agent-graph/GraphView.test.ts new file mode 100644 index 00000000..e2f1908c --- /dev/null +++ b/test/renderer/features/agent-graph/GraphView.test.ts @@ -0,0 +1,400 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; + +import { getEdgeMidpoint } from '../../../../packages/agent-graph/src/canvas/hit-detection'; + +const hoisted = vi.hoisted(() => ({ + handlePanStart: vi.fn(), + handlePanMove: vi.fn(), + handlePanEnd: vi.fn(), + zoomToFit: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + updateInertia: vi.fn(), + interaction: { + hoveredNodeId: { current: null as string | null }, + dragNodeId: { current: null as string | null }, + isDragging: { current: false }, + handleMouseDown: vi.fn(), + handleMouseMove: vi.fn(), + handleMouseUp: vi.fn(() => null), + handleDoubleClick: vi.fn(() => null), + }, + simulationState: { + nodes: [] as GraphNode[], + edges: [] as GraphEdge[], + particles: [], + effects: [], + time: 0, + }, + clearTransientOwnerPositions: vi.fn(), +})); + +vi.mock('../../../../packages/agent-graph/src/hooks/useGraphCamera', () => ({ + useGraphCamera: () => ({ + transformRef: { current: { x: 0, y: 0, zoom: 1 } }, + screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }), + worldToScreen: (wx: number, wy: number) => ({ x: wx, y: wy }), + handleWheel: vi.fn(), + handlePanStart: hoisted.handlePanStart, + handlePanMove: hoisted.handlePanMove, + handlePanEnd: hoisted.handlePanEnd, + zoomToFit: hoisted.zoomToFit, + zoomIn: hoisted.zoomIn, + zoomOut: hoisted.zoomOut, + updateInertia: hoisted.updateInertia, + }), +})); + +vi.mock('../../../../packages/agent-graph/src/hooks/useGraphInteraction', () => ({ + useGraphInteraction: () => hoisted.interaction, +})); + +vi.mock('../../../../packages/agent-graph/src/hooks/useGraphSimulation', () => ({ + useGraphSimulation: () => ({ + stateRef: { current: hoisted.simulationState }, + updateData: vi.fn(), + tick: vi.fn(), + getExtraWorldBounds: vi.fn(() => []), + getLaunchAnchorWorldPosition: vi.fn(() => null), + getActivityWorldRect: vi.fn(() => null), + resolveNearestOwnerSlot: vi.fn(() => null), + clearNodePosition: vi.fn(), + clearTransientOwnerPositions: hoisted.clearTransientOwnerPositions, + setNodePosition: vi.fn(), + }), +})); + +vi.mock('../../../../packages/agent-graph/src/ui/GraphControls', () => ({ + GraphControls: () => null, +})); + +vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({ + GraphOverlay: () => null, +})); + +vi.mock('../../../../packages/agent-graph/src/ui/GraphEdgeOverlay', () => ({ + GraphEdgeOverlay: () => null, +})); + +import { GraphView } from '../../../../packages/agent-graph/src/ui/GraphView'; + +describe('GraphView pan interactions', () => { + let container: HTMLDivElement; + let root: Root; + let originalGetBoundingClientRect: typeof HTMLCanvasElement.prototype.getBoundingClientRect; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + hoisted.interaction.hoveredNodeId.current = null; + hoisted.interaction.dragNodeId.current = null; + hoisted.interaction.isDragging.current = false; + hoisted.simulationState.nodes = []; + hoisted.simulationState.edges = []; + vi.stubGlobal( + 'ResizeObserver', + class { + observe(): void {} + disconnect(): void {} + } + ); + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + originalGetBoundingClientRect = HTMLCanvasElement.prototype.getBoundingClientRect; + HTMLCanvasElement.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect { + return DOMRect.fromRect({ x: 0, y: 0, width: 800, height: 600 }); + }; + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + HTMLCanvasElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + vi.unstubAllGlobals(); + }); + + it('starts panning when dragging from a hit-tested edge instead of getting stuck on edge selection', async () => { + const source: GraphNode = { + id: 'member:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 0, + y: 0, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + const target: GraphNode = { + id: 'task:1', + kind: 'task', + label: 'Task 1', + state: 'idle', + x: 160, + y: 90, + domainRef: { kind: 'task', teamName: 'demo-team', taskId: 'task:1' }, + }; + const edge: GraphEdge = { + id: 'edge:blocking', + source: source.id, + target: target.id, + type: 'blocking', + }; + hoisted.simulationState.nodes = [source, target]; + hoisted.simulationState.edges = [edge]; + + const midpoint = getEdgeMidpoint(edge, new Map([ + [source.id, source], + [target.id, target], + ])); + expect(midpoint).not.toBeNull(); + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source, target], + edges: [edge], + particles: [], + }, + config: { animationEnabled: false }, + }) + ); + }); + + const canvas = container.querySelector('canvas'); + expect(canvas).not.toBeNull(); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: midpoint!.x, + clientY: midpoint!.y, + }) + ); + canvas!.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: midpoint!.x + 24, + clientY: midpoint!.y + 4, + }) + ); + }); + + expect(hoisted.handlePanStart).toHaveBeenCalledWith(midpoint!.x, midpoint!.y); + expect(hoisted.handlePanMove).toHaveBeenCalledWith(midpoint!.x + 24, midpoint!.y + 4); + }); + + it('does not clear pan state on the rerender triggered by interaction lock', async () => { + const source: GraphNode = { + id: 'member:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 0, + y: 0, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + hoisted.simulationState.nodes = [source]; + hoisted.simulationState.edges = []; + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + config: { animationEnabled: false }, + }) + ); + }); + + const canvas = container.querySelector('canvas'); + expect(canvas).not.toBeNull(); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 320, + clientY: 220, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: 352, + clientY: 248, + }) + ); + }); + + expect(hoisted.handlePanStart).toHaveBeenCalledWith(320, 220); + expect(hoisted.handlePanMove).toHaveBeenCalledWith(352, 248); + }); + + it('does not force-handleMouseUp when props rerender during an active member drag', async () => { + const source: GraphNode = { + id: 'member:demo-team:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 80, + y: 80, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + hoisted.simulationState.nodes = [source]; + hoisted.simulationState.edges = []; + hoisted.interaction.handleMouseDown.mockImplementation(() => { + hoisted.interaction.dragNodeId.current = source.id; + }); + hoisted.interaction.handleMouseMove.mockImplementation(() => { + hoisted.interaction.isDragging.current = true; + }); + + const firstEvents = {}; + const secondEvents = {}; + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + events: firstEvents, + config: { animationEnabled: false }, + }) + ); + }); + + const canvas = container.querySelector('canvas'); + expect(canvas).not.toBeNull(); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 80, + clientY: 80, + }) + ); + canvas!.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: 95, + clientY: 95, + }) + ); + }); + + expect(hoisted.interaction.isDragging.current).toBe(true); + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + events: secondEvents, + config: { animationEnabled: false }, + }) + ); + }); + + expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled(); + + await act(async () => { + window.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: 112, + clientY: 112, + }) + ); + }); + + expect(hoisted.interaction.handleMouseMove).toHaveBeenCalled(); + expect(hoisted.interaction.isDragging.current).toBe(true); + }); + + it('clears drag state when the graph surface becomes inactive', async () => { + const source: GraphNode = { + id: 'member:demo-team:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 80, + y: 80, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + hoisted.simulationState.nodes = [source]; + hoisted.simulationState.edges = []; + hoisted.interaction.dragNodeId.current = source.id; + hoisted.interaction.isDragging.current = true; + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + config: { animationEnabled: false }, + isSurfaceActive: true, + }) + ); + }); + + expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled(); + expect(hoisted.clearTransientOwnerPositions).not.toHaveBeenCalled(); + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + config: { animationEnabled: false }, + isSurfaceActive: false, + }) + ); + }); + + expect(hoisted.interaction.handleMouseUp).toHaveBeenCalledTimes(1); + expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 78bd54ac..62240baf 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 2d556459..ce409e00 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, }; } @@ -202,11 +194,6 @@ describe('buildInlineActivityEntries', () => { it('routes comment activity to a member lane when task.owner is stored as stable owner id', () => { const data = createBaseTeamData({ - config: { - name: 'My Team', - members: [{ name: 'team-lead', agentId: 'lead-agent' }, { name: 'jack', agentId: 'agent-jack' }], - projectPath: '/repo', - }, tasks: [ { id: 'task-stable-owner', diff --git a/test/renderer/features/agent-graph/useGraphCamera.test.ts b/test/renderer/features/agent-graph/useGraphCamera.test.ts index b4b518d5..ca804fc3 100644 --- a/test/renderer/features/agent-graph/useGraphCamera.test.ts +++ b/test/renderer/features/agent-graph/useGraphCamera.test.ts @@ -7,15 +7,29 @@ import { useGraphCamera, type UseGraphCameraResult } from '../../../../packages/ import type { GraphNode } from '@claude-teams/agent-graph'; let capturedCamera: UseGraphCameraResult | null = null; +let firstCamera: UseGraphCameraResult | null = null; +let secondCamera: UseGraphCameraResult | null = null; function CameraHarness(): React.JSX.Element | null { capturedCamera = useGraphCamera(); return null; } +function CameraIdentityHarness({ pass }: { pass: number }): React.JSX.Element | null { + const camera = useGraphCamera(); + if (pass === 1) { + firstCamera = camera; + } else { + secondCamera = camera; + } + return null; +} + describe('useGraphCamera zoomToFit', () => { afterEach(() => { capturedCamera = null; + firstCamera = null; + secondCamera = null; document.body.innerHTML = ''; }); @@ -71,4 +85,29 @@ describe('useGraphCamera zoomToFit', () => { await Promise.resolve(); }); }); + + it('returns a referentially stable result across rerenders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CameraIdentityHarness, { pass: 1 })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(CameraIdentityHarness, { pass: 2 })); + await Promise.resolve(); + }); + + expect(firstCamera).toBeTruthy(); + expect(secondCamera).toBe(firstCamera); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/useGraphInteraction.test.ts b/test/renderer/features/agent-graph/useGraphInteraction.test.ts new file mode 100644 index 00000000..767ece9d --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphInteraction.test.ts @@ -0,0 +1,51 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphInteraction, type UseGraphInteractionResult } from '../../../../packages/agent-graph/src/hooks/useGraphInteraction'; + +let firstInteraction: UseGraphInteractionResult | null = null; +let secondInteraction: UseGraphInteractionResult | null = null; + +function InteractionHarness({ pass }: { pass: number }): React.JSX.Element | null { + const interaction = useGraphInteraction(); + if (pass === 1) { + firstInteraction = interaction; + } else { + secondInteraction = interaction; + } + return null; +} + +describe('useGraphInteraction', () => { + afterEach(() => { + firstInteraction = null; + secondInteraction = null; + document.body.innerHTML = ''; + }); + + it('returns a referentially stable result across rerenders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(InteractionHarness, { pass: 1 })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(InteractionHarness, { pass: 2 })); + await Promise.resolve(); + }); + + expect(firstInteraction).toBeTruthy(); + expect(secondInteraction).toBe(firstInteraction); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts b/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts new file mode 100644 index 00000000..4bbf58cc --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts @@ -0,0 +1,51 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphSimulation, type UseGraphSimulationResult } from '../../../../packages/agent-graph/src/hooks/useGraphSimulation'; + +let firstSimulation: UseGraphSimulationResult | null = null; +let secondSimulation: UseGraphSimulationResult | null = null; + +function SimulationHarness({ pass }: { pass: number }): React.JSX.Element | null { + const simulation = useGraphSimulation(); + if (pass === 1) { + firstSimulation = simulation; + } else { + secondSimulation = simulation; + } + return null; +} + +describe('useGraphSimulation', () => { + afterEach(() => { + firstSimulation = null; + secondSimulation = null; + document.body.innerHTML = ''; + }); + + it('returns a referentially stable result across rerenders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(SimulationHarness, { pass: 1 })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(SimulationHarness, { pass: 2 })); + await Promise.resolve(); + }); + + expect(firstSimulation).toBeTruthy(); + expect(secondSimulation).toBe(firstSimulation); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 9d7fa351..47fd6c9c 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', 'tab-hidden', 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', 'tab-hidden', 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 5005bc7c..20f77cc5 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -2,15 +2,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { + __getTeamScopedTransientStateForTests, __resetTeamSliceModuleStateForTests, createTeamSlice, + getActiveTeamPendingReplyWaits, + hasActiveTeamPendingReplyWait, getCurrentProvisioningProgressForTeam, + selectMemberMessagesForTeamMember, + selectResolvedMemberForTeamName, + 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(), + launchTeam: vi.fn(), getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), getTeamAgentRuntime: vi.fn(), @@ -31,7 +40,10 @@ vi.mock('@renderer/api', () => ({ teams: { list: hoisted.list, getData: hoisted.getData, + getMessagesPage: hoisted.getMessagesPage, + getMemberActivityMeta: hoisted.getMemberActivityMeta, createTeam: hoisted.createTeam, + launchTeam: hoisted.launchTeam, getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, getTeamAgentRuntime: hoisted.getTeamAgentRuntime, @@ -91,6 +103,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', @@ -129,6 +163,16 @@ 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 }; +} + function createRuntimeSnapshot(overrides: Record = {}) { return { teamName: 'my-team', @@ -155,19 +199,24 @@ describe('teamSlice actions', () => { 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); hoisted.updateKanban.mockResolvedValue(undefined); hoisted.createTeam.mockResolvedValue({ runId: 'run-1' }); + hoisted.launchTeam.mockResolvedValue({ runId: 'run-1' }); hoisted.invalidateTaskChangeSummaries.mockResolvedValue(undefined); hoisted.getProvisioningStatus.mockResolvedValue({ runId: 'run-1', @@ -561,6 +610,899 @@ 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('drops a queued head refresh behind an older-page load when launch invalidates the team epoch', 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); + + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + const queuedHeadPromise = store.getState().refreshTeamMessagesHead('my-team'); + + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + 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 expect(queuedHeadPromise).resolves.toEqual({ + feedChanged: false, + headChanged: false, + feedRevision: null, + }); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-1'); + }); + + it('does not continue an older-page fetch with a stale cursor after launch invalidates while waiting for head refresh', async () => { + const store = createSliceStore(); + const headRequest = 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', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-older', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockImplementationOnce(() => headRequest.promise); + + const headPromise = store.getState().refreshTeamMessagesHead('my-team'); + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + headRequest.resolve({ + messages: [ + { + from: 'team-lead', + text: 'Fresh head', + timestamp: '2026-03-20T08:00:03.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-4', + }, + ], + nextCursor: 'cursor-head', + hasMore: true, + feedRevision: 'rev-2', + }); + + await headPromise; + await olderPromise; + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-1'); + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false); + }); + + 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', 'tab-a', 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', 'tab-a', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', false); + + await vi.advanceTimersByTimeAsync(1_000); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps pending-reply refresh ownership active while another source still waits for the same team', () => { + const store = createSliceStore(); + + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-b', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-b', false); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team'])); + + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', false); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); + + 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', + }, + }, + leadActivityByTeam: { + 'my-team': 'active', + }, + leadContextByTeam: { + 'my-team': { + currentTokens: 12, + contextWindow: 100, + percent: 12, + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + memberSpawnStatusesByTeam: { + 'my-team': { + alice: createMemberSpawnStatus(), + }, + }, + memberSpawnSnapshotsByTeam: { + 'my-team': createMemberSpawnSnapshot(), + }, + }); + + 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({ @@ -570,7 +1512,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -580,7 +1521,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -589,7 +1529,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -612,7 +1551,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -622,7 +1560,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -646,7 +1583,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -659,6 +1595,787 @@ describe('teamSlice actions', () => { expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); }); + it('clears team-scoped selector and transient caches on delete and restore flows', async () => { + const store = createSliceStore(); + const message = { + from: 'alice', + to: 'team-lead', + text: 'hello', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'm-1', + source: 'inbox' as const, + }; + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: createTeamSnapshot({ + members: [ + { + name: 'alice', + role: 'developer', + currentTaskId: null, + }, + ], + }), + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [ + { + name: 'alice', + role: 'developer', + currentTaskId: null, + }, + ], + }), + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [message], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + 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: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }, + }, + }); + + selectResolvedMembersForTeamName(store.getState(), 'my-team'); + selectResolvedMemberForTeamName(store.getState(), 'my-team', 'alice'); + selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + + await store.getState().refreshTeamData('my-team', { withDedup: false }); + store.getState().syncTeamPendingReplyRefresh('my-team', 'test-source', true); + + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 1, + hasMergedMessagesSelector: true, + memberMessagesSelectorCount: 1, + hasLastResolvedTeamDataRefresh: true, + }); + + await store.getState().deleteTeam('my-team'); + + expect(__getTeamScopedTransientStateForTests('my-team')).toEqual({ + hasResolvedMembersSelector: false, + resolvedMemberSelectorCount: 0, + hasMergedMessagesSelector: false, + memberMessagesSelectorCount: 0, + hasPendingFreshTeamDataRefresh: false, + hasQueuedHeadRefreshAfterOlder: false, + hasPendingFreshMessagesHeadRefresh: false, + hasPendingFreshMemberActivityMetaRefresh: false, + hasLastResolvedTeamDataRefresh: false, + hasCurrentLocalStateEpoch: true, + hasMemberSpawnStatusesIpcBackoff: false, + hasTeamRefreshBurstDiagnostics: false, + hasMemberSpawnUiEqualLastWarn: false, + }); + expect(store.getState().leadActivityByTeam['my-team']).toBeUndefined(); + expect(store.getState().leadContextByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined(); + + store.setState({ + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [ + { + name: 'alice', + role: 'developer', + currentTaskId: null, + }, + ], + }), + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [message], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + 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: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }, + }, + leadActivityByTeam: { + 'my-team': 'active', + }, + leadContextByTeam: { + 'my-team': { + currentTokens: 12, + contextWindow: 100, + percent: 12, + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + memberSpawnStatusesByTeam: { + 'my-team': { + alice: createMemberSpawnStatus(), + }, + }, + memberSpawnSnapshotsByTeam: { + 'my-team': createMemberSpawnSnapshot(), + }, + }); + selectResolvedMembersForTeamName(store.getState(), 'my-team'); + selectResolvedMemberForTeamName(store.getState(), 'my-team', 'alice'); + selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 1, + hasMergedMessagesSelector: true, + memberMessagesSelectorCount: 1, + }); + + await store.getState().restoreTeam('my-team'); + + expect(__getTeamScopedTransientStateForTests('my-team')).toEqual({ + hasResolvedMembersSelector: false, + resolvedMemberSelectorCount: 0, + hasMergedMessagesSelector: false, + memberMessagesSelectorCount: 0, + hasPendingFreshTeamDataRefresh: false, + hasQueuedHeadRefreshAfterOlder: false, + hasPendingFreshMessagesHeadRefresh: false, + hasPendingFreshMemberActivityMetaRefresh: false, + hasLastResolvedTeamDataRefresh: false, + hasCurrentLocalStateEpoch: true, + hasMemberSpawnStatusesIpcBackoff: false, + hasTeamRefreshBurstDiagnostics: false, + hasMemberSpawnUiEqualLastWarn: false, + }); + expect(store.getState().leadActivityByTeam['my-team']).toBeUndefined(); + expect(store.getState().leadContextByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined(); + }); + + it('ignores stale async team snapshot and message refreshes after delete invalidates the team', async () => { + const store = createSliceStore(); + const deferredData = createDeferredPromise>(); + const deferredMessages = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + const deferredMeta = createDeferredPromise<{ + teamName: string; + computedAt: string; + feedRevision: string; + members: Record< + string, + { + memberName: string; + lastAuthoredMessageAt: string | null; + messageCountExact: number; + latestAuthoredMessageSignalsTermination: boolean; + } + >; + }>(); + + hoisted.getData.mockImplementation(() => deferredData.promise); + hoisted.getMessagesPage.mockImplementation(() => deferredMessages.promise); + hoisted.getMemberActivityMeta.mockImplementation(() => deferredMeta.promise); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-0', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + const refreshDataPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + const refreshMessagesPromise = store.getState().refreshTeamMessagesHead('my-team'); + const refreshMetaPromise = store.getState().refreshMemberActivityMeta('my-team'); + + await Promise.resolve(); + await store.getState().deleteTeam('my-team'); + + deferredData.resolve( + createTeamSnapshot({ + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }) + ); + deferredMessages.resolve({ + messages: [ + { + from: 'alice', + text: 'late-message', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'late-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-late', + }); + deferredMeta.resolve({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-late', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }); + + await Promise.all([refreshDataPromise, refreshMessagesPromise, refreshMetaPromise]); + + expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); + expect(store.getState().teamMessagesByName['my-team']).toBeUndefined(); + expect(store.getState().memberActivityMetaByTeam['my-team']).toBeUndefined(); + }); + + it('ignores stale async team refreshes after launch starts a new local epoch for the same team', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + config: { name: 'My Team Before Launch' }, + members: [{ name: 'lead', role: 'lead', currentTaskId: null }], + }); + const existingMeta: { + teamName: string; + computedAt: string; + feedRevision: string; + members: Record< + string, + { + memberName: string; + lastAuthoredMessageAt: string | null; + messageCountExact: number; + latestAuthoredMessageSignalsTermination: boolean; + } + >; + } = { + teamName: 'my-team', + computedAt: '2026-03-12T09:59:00.000Z', + feedRevision: 'rev-0', + members: { + lead: { + memberName: 'lead', + lastAuthoredMessageAt: '2026-03-12T09:59:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }; + const deferredData = createDeferredPromise>(); + const deferredMessages = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + const deferredMeta = createDeferredPromise(); + + hoisted.getData.mockImplementation(() => deferredData.promise); + hoisted.getMessagesPage.mockImplementation(() => deferredMessages.promise); + hoisted.getMemberActivityMeta.mockImplementation(() => deferredMeta.promise); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: { + 'my-team': existingData, + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-0', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: { + 'my-team': existingMeta, + }, + }); + + const refreshDataPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + const refreshMessagesPromise = store.getState().refreshTeamMessagesHead('my-team'); + const refreshMetaPromise = store.getState().refreshMemberActivityMeta('my-team'); + + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + expect(store.getState().teamMessagesByName['my-team']?.loadingHead).toBe(false); + + deferredData.resolve( + createTeamSnapshot({ + config: { name: 'My Team Stale After Launch' }, + members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }], + }) + ); + deferredMessages.resolve({ + messages: [ + { + from: 'alice', + text: 'stale-after-launch', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'stale-after-launch-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-stale-after-launch', + }); + deferredMeta.resolve({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-stale-after-launch', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }); + + await Promise.all([refreshDataPromise, refreshMessagesPromise, refreshMetaPromise]); + + expect(store.getState().selectedTeamData).toBe(existingData); + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-0'); + expect(store.getState().memberActivityMetaByTeam['my-team']).toEqual(existingMeta); + }); + + it('clears stale selectedTeamLoading when launch invalidates an in-flight selectTeam request', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + config: { name: 'My Team Cached' }, + members: [{ name: 'lead', role: 'lead', currentTaskId: null }], + }); + const deferredData = createDeferredPromise>(); + + hoisted.getData.mockImplementationOnce(() => deferredData.promise); + + store.setState({ + teamDataCacheByName: { + 'my-team': existingData, + }, + }); + + const selectPromise = store.getState().selectTeam('my-team'); + await Promise.resolve(); + + expect(store.getState().selectedTeamLoading).toBe(true); + expect(store.getState().selectedTeamData).toEqual(existingData); + + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + expect(store.getState().selectedTeamLoading).toBe(false); + expect(store.getState().selectedTeamError).toBeNull(); + expect(store.getState().selectedTeamData).toEqual(existingData); + + deferredData.resolve( + createTeamSnapshot({ + config: { name: 'My Team Stale Select' }, + members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }], + }) + ); + await selectPromise; + + expect(store.getState().selectedTeamLoading).toBe(false); + expect(store.getState().selectedTeamData).toEqual(existingData); + }); + + it('clears stale loadingOlder when launch invalidates an in-flight older messages request', 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: [], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-older', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockImplementationOnce(() => olderRequest.promise); + + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + await Promise.resolve(); + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(true); + + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false); + + 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; + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false); + }); + + it('ignores stale refreshTeamData failures after launch starts a new local epoch', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + config: { name: 'My Team Stable' }, + members: [{ name: 'lead', role: 'lead', currentTaskId: null }], + }); + const deferredData = createDeferredPromise>(); + + hoisted.getData.mockImplementation(() => deferredData.promise); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: { + 'my-team': existingData, + }, + selectedTeamError: null, + }); + + const refreshPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + deferredData.reject(new Error('TEAM_DRAFT')); + await refreshPromise; + + expect(store.getState().selectedTeamData).toBe(existingData); + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().selectedTeamError).toBeNull(); + }); + + it('keeps the newer messages-head request pinned when a stale pre-launch request settles', async () => { + const store = createSliceStore(); + const deferredOld = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + const deferredNew = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + + hoisted.getMessagesPage + .mockImplementationOnce(() => deferredOld.promise) + .mockImplementationOnce(() => deferredNew.promise); + + const firstPromise = store.getState().refreshTeamMessagesHead('my-team'); + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + const secondPromise = store.getState().refreshTeamMessagesHead('my-team'); + await Promise.resolve(); + + deferredOld.reject(new Error('stale head failed')); + await expect(firstPromise).resolves.toEqual({ + feedChanged: false, + headChanged: false, + feedRevision: null, + }); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + + deferredNew.resolve({ + messages: [ + { + from: 'bob', + text: 'fresh-after-launch', + timestamp: '2026-03-12T10:00:01.000Z', + messageId: 'fresh-after-launch-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-fresh-after-launch', + }); + + await secondPromise; + + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe( + 'rev-fresh-after-launch' + ); + }); + + it('does not reuse a pre-delete in-flight team snapshot request after the same team is reselected', async () => { + const store = createSliceStore(); + const deferredOld = createDeferredPromise>(); + const freshSnapshot = createTeamSnapshot({ + config: { name: 'My Team Reloaded' }, + members: [{ name: 'bob', role: 'developer', currentTaskId: null }], + }); + + hoisted.getData + .mockImplementationOnce(() => deferredOld.promise) + .mockResolvedValueOnce(freshSnapshot); + + const firstSelectPromise = store.getState().selectTeam('my-team'); + await Promise.resolve(); + await store.getState().deleteTeam('my-team'); + + const secondSelectPromise = store.getState().selectTeam('my-team'); + await secondSelectPromise; + + expect(hoisted.getData).toHaveBeenCalledTimes(2); + expect(store.getState().selectedTeamData).toEqual(freshSnapshot); + + deferredOld.resolve( + createTeamSnapshot({ + config: { name: 'My Team Stale' }, + members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }], + }) + ); + await firstSelectPromise; + + expect(store.getState().selectedTeamData).toEqual(freshSnapshot); + }); + + it('does not reuse a pre-delete in-flight messages head request after the same team is reselected', async () => { + const store = createSliceStore(); + const deferredOld = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + + hoisted.getMessagesPage + .mockImplementationOnce(() => deferredOld.promise) + .mockResolvedValueOnce({ + messages: [ + { + from: 'bob', + text: 'fresh-message', + timestamp: '2026-03-12T10:00:01.000Z', + messageId: 'fresh-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-fresh', + }); + + const firstHeadPromise = store.getState().refreshTeamMessagesHead('my-team'); + await Promise.resolve(); + await store.getState().deleteTeam('my-team'); + + const secondHeadPromise = store.getState().refreshTeamMessagesHead('my-team'); + await secondHeadPromise; + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-fresh'); + expect(store.getState().teamMessagesByName['my-team']?.canonicalMessages).toEqual([ + { + from: 'bob', + text: 'fresh-message', + timestamp: '2026-03-12T10:00:01.000Z', + messageId: 'fresh-1', + source: 'inbox', + }, + ]); + + deferredOld.resolve({ + messages: [ + { + from: 'alice', + text: 'stale-message', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'stale-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-stale', + }); + await firstHeadPromise; + + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-fresh'); + }); + + it('tombstones current progress runs when delete clears a team so late progress cannot resurrect it', async () => { + const store = createSliceStore(); + store.setState({ + provisioningRuns: { + 'run-live': { + runId: 'run-live', + teamName: 'my-team', + state: 'assembling', + message: 'Live run', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + currentProvisioningRunIdByTeam: { + 'my-team': 'run-live', + }, + currentRuntimeRunIdByTeam: { + 'my-team': 'run-live', + }, + provisioningStartedAtFloorByTeam: { + 'my-team': '2026-03-12T10:00:00.000Z', + }, + }); + + await store.getState().deleteTeam('my-team'); + + expect(store.getState().ignoredProvisioningRunIds['run-live']).toBe('my-team'); + expect(store.getState().ignoredRuntimeRunIds['run-live']).toBe('my-team'); + expect(store.getState().provisioningStartedAtFloorByTeam['my-team']).toBeTruthy(); + + store.getState().onProvisioningProgress({ + runId: 'run-live', + teamName: 'my-team', + state: 'ready', + message: 'Late zombie progress', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:05.000Z', + }); + + expect(store.getState().provisioningRuns['run-live']).toBeUndefined(); + expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined(); + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined(); + }); + it('stores runtime snapshots and suppresses semantic no-op refreshes', async () => { const store = createSliceStore(); const snapshot = createRuntimeSnapshot(); @@ -728,7 +2445,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }, selectedTeamError: null, @@ -753,7 +2469,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({ @@ -771,6 +2486,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({ @@ -780,7 +2651,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -790,7 +2660,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -814,7 +2683,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -824,7 +2692,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -848,7 +2715,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }, selectedTeamError: 'Previous failure', @@ -871,7 +2737,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }; store.setState({ @@ -951,7 +2816,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -1001,7 +2865,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -1036,7 +2899,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -1061,7 +2923,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: [], }); @@ -1099,7 +2960,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -1145,7 +3005,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -1624,7 +3483,7 @@ describe('teamSlice actions', () => { await store.getState().fetchMemberSpawnStatuses('my-team'); expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('runtime-run'); - expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined(); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses); expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot); }); @@ -1711,12 +3570,73 @@ describe('teamSlice actions', () => { }); expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-1'); - expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined(); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); expect(store.getState().activeToolsByTeam['my-team']).toBeUndefined(); expect(store.getState().finishedVisibleByTeam['my-team']).toBeUndefined(); expect(store.getState().toolHistoryByTeam['my-team']).toBeUndefined(); }); + it('keeps tombstoned runtime ids ignored during createTeam startup before the new run is pinned', async () => { + const store = createSliceStore(); + const createDeferred = createDeferredPromise<{ runId: string }>(); + hoisted.createTeam.mockImplementation(() => createDeferred.promise); + store.setState({ + currentRuntimeRunIdByTeam: { + 'my-team': 'runtime-live', + }, + ignoredRuntimeRunIds: { + 'runtime-old': 'my-team', + }, + }); + + const createPromise = store.getState().createTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + members: [], + }); + + await Promise.resolve(); + + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined(); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); + expect(store.getState().ignoredRuntimeRunIds['runtime-live']).toBe('my-team'); + + hoisted.getMemberSpawnStatuses.mockResolvedValue( + createMemberSpawnSnapshot({ + runId: 'runtime-old', + }) + ); + + await store.getState().fetchMemberSpawnStatuses('my-team'); + + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined(); + + createDeferred.resolve({ runId: 'run-1' }); + await createPromise; + }); + + it('keeps older tombstoned runtime ids after canonical provisioning progress arrives', () => { + const store = createSliceStore(); + store.setState({ + ignoredRuntimeRunIds: { + 'runtime-old': 'my-team', + }, + }); + + store.getState().onProvisioningProgress({ + runId: 'run-current', + teamName: 'my-team', + state: 'assembling', + message: 'Current run', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:01.000Z', + }); + + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-current'); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); + }); + it('ignores tombstoned runtime spawn-status snapshots', async () => { const store = createSliceStore(); store.setState({ diff --git a/test/renderer/utils/multimodelProviderVisibility.test.ts b/test/renderer/utils/multimodelProviderVisibility.test.ts index 4ee98ca3..6202bc94 100644 --- a/test/renderer/utils/multimodelProviderVisibility.test.ts +++ b/test/renderer/utils/multimodelProviderVisibility.test.ts @@ -5,7 +5,6 @@ import { getVisibleMultimodelProviders, isMultimodelRuntimeStatus, } from '@renderer/utils/multimodelProviderVisibility'; - import type { CliInstallationStatus, CliProviderStatus } from '@shared/types'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 723e8e85..7423826d 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: {},