fix(team): harden opencode delivery recovery

This commit is contained in:
777genius 2026-05-14 15:11:40 +03:00
parent 874123c773
commit 6e8f938da2
25 changed files with 2516 additions and 508 deletions

View file

@ -1,135 +1,135 @@
# Team Management Feature # Team Management Feature
Интерфейс для управления командами AI-тиммейтов внутри Agent Teams (Electron), включая Claude, Codex и OpenCode runtime paths. UI for managing AI teammate teams inside Agent Teams (Electron), including Claude, Codex, and OpenCode runtime paths.
## Что делает ## What It Does
- Видеть состав команды и роли участников - Shows team members and their roles.
- Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED - Provides a Kanban board with 5 columns: TODO, IN PROGRESS, REVIEW, DONE, APPROVED.
- Отправка сообщений тиммейтам через inbox-файлы и runtime-aware delivery для OpenCode - Sends messages to teammates through inbox files and runtime-aware delivery for OpenCode.
- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE - Supports review flow: review requests, manual review, and direct manual approval from DONE.
- Live updates через file watcher - Provides live updates through the file watcher.
## Документация ## Documentation
| Файл | Содержание | | File | Contents |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | ---- | -------- |
| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений | | [research-inbox.md](./research-inbox.md) | Inbox file format, race conditions, atomic writes, message delivery |
| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ | | [research-tasks.md](./research-tasks.md) | Task file format, .lock, .highwatermark, concurrent access |
| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox | | [research-messaging.md](./research-messaging.md) | Comparison of inbox, SDK, and CLI approaches, and why inbox was chosen |
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json | | [kanban-design.md](./kanban-design.md) | Kanban flow, columns, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification | | [implementation.md](./implementation.md) | Technical plan: files, steps, verification |
| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API | | [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API |
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) | | [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, launching Claude processes from the UI (Phase 2) |
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync | | [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Detailed rollout plan for queue/inventory split, derived actionOwner, and phased agenda/delta sync |
| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, включая `CLAUDE_TEAM_TEAMMATE_MODE=tmux` для pane-backed teammate debug | | [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, including `CLAUDE_TEAM_TEAMMATE_MODE=tmux` for pane-backed teammate debug |
| [adaptive-task-graphs-research-note.md](./adaptive-task-graphs-research-note.md) | Research note по LATTE/AgentConductor: dynamic task graphs, frontier scheduling, selective verify, release stragglers | | [adaptive-task-graphs-research-note.md](./adaptive-task-graphs-research-note.md) | Research note on LATTE/AgentConductor: dynamic task graphs, frontier scheduling, selective verify, release stragglers |
## Ключевые решения ## Key Decisions
⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md). Warning: `docs/iterations/*` contains historical planning notes. These files are useful for context, but they are not the source of truth for current product behavior. The current review-flow contract is documented here and in [kanban-design.md](./kanban-design.md).
⚠️ `agent-attachments-*.md` (architecture plan + phase 1-5 plans) - это исторические дизайн-документы для feature attachments. Фактическая реализация в `src/features/agent-attachments/` может отличаться от описанной архитектуры. Для актуального состояния см. код в `src/features/agent-attachments/core/domain/` и тесты. Warning: `agent-attachments-*.md` files (architecture plan + phase 1-5 plans) are historical design documents for feature attachments. The actual implementation in `src/features/agent-attachments/` may differ from that architecture. For current behavior, see the code in `src/features/agent-attachments/core/domain/` and the tests.
### 1. Messaging: inbox + runtime delivery ### 1. Messaging: Inbox + Runtime Delivery
Для native Claude/Codex-style тиммейтов основной путь - durable inbox-файлы. Lead inbox доставляется через `relayLeadInboxMessages()`, потому что lead читает stdin. OpenCode secondary lanes не читают `inboxes/{member}.json` напрямую, поэтому UI сначала сохраняет сообщение в inbox, затем доставляет его через runtime bridge с delivery proof. Подробности: [research-messaging.md](./research-messaging.md) и [debugging-agent-teams.md](./debugging-agent-teams.md) For native Claude/Codex-style teammates, the primary path is durable inbox files. Lead inbox delivery uses `relayLeadInboxMessages()` because the lead reads stdin. OpenCode secondary lanes do not read `inboxes/{member}.json` directly, so the UI first persists the message to the inbox and then delivers it through the runtime bridge with delivery proof. Details: [research-messaging.md](./research-messaging.md) and [debugging-agent-teams.md](./debugging-agent-teams.md).
### 1.1 Roster source: members.meta.json + inboxes ### 1.1 Roster Source: members.meta.json + inboxes
- `config.json` не используется как полный реестр участников (он может содержать только team-lead и служебные поля CLI). - `config.json` is not used as the complete member registry. It may contain only the team lead and CLI service fields.
- Источник метаданных участников (role/color/agentType): `members.meta.json`. - Member metadata source (role/color/agentType): `members.meta.json`.
- Источник runtime-состава и адресации сообщений: `inboxes/{member}.json`. - Runtime membership and message-addressing source: `inboxes/{member}.json`.
### 2. Kanban Storage: Собственный файл ### 2. Kanban Storage: Dedicated File
Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`, а не в task metadata. Причина: metadata может быть перезаписан агентом при TaskUpdate. Подробности: [kanban-design.md](./kanban-design.md) Kanban position (REVIEW, APPROVED) is stored in `kanban-state.json`, not task metadata. Reason: task metadata may be overwritten by an agent during TaskUpdate. Details: [kanban-design.md](./kanban-design.md).
### 3. Review Flow: Approve / Request Changes ### 3. Review Flow: Approve / Request Changes
- Есть ревьюверы в команде → автоматическое назначение через inbox - Reviewers exist in the team -> automatic assignment through inbox.
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW` - The user can also manually approve a task directly from `DONE` without entering `REVIEW`.
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI) - No reviewers -> manual user review (Approve / Request Changes in the UI).
- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix` - Request Changes -> the user optionally describes the issue -> the task returns to its owner in `pending` with `needsFix`.
### 4. Atomic Write ### 4. Atomic Write
Все записи через tmp + rename для предотвращения corrupted JSON. All writes use tmp + rename to prevent corrupted JSON.
### 5. Sender Identity ### 5. Sender Identity
Отправляем `from: "user"`. Fallback на `from: "team-lead"` если не работает. Messages are sent with `from: "user"`. Fallback to `from: "team-lead"` exists only if needed.
## Финальные решения после ревью ## Final Decisions After Review
По итогам 3 раундов ревью (13 экспертов) приняты следующие решения: After 3 review rounds with 13 experts, the following decisions were accepted.
### Inbox: Atomic write + messageId verify ### Inbox: Atomic Write + messageId Verify
- Atomic write (tmp + rename) предотвращает corrupted JSON - Atomic write (tmp + rename) prevents corrupted JSON.
- После записи читаем файл обратно и проверяем наличие нашего `messageId` - After writing, read the file back and verify that our `messageId` is present.
- Полный CAS/retry-цикл — не нужен на MVP: проверка при следующем read достаточна - A full CAS/retry loop is not needed for MVP. Verification on the next read is enough.
- Риск race condition с агентом реален, но вероятность низкая - Race condition risk with an agent is real, but probability is low.
### Kanban: kanban-state.json с безопасным GC ### Kanban: kanban-state.json With Safe GC
- GC устаревших записей kanban-state выполняется ТОЛЬКО ПОСЛЕ полной загрузки tasks - Stale `kanban-state` entries are garbage-collected only after all tasks are fully loaded.
- Иначе при startup возможна race condition: GC удаляет запись до того как task-файл прочитан - Otherwise, startup can race: GC may delete an entry before the task file has been read.
### Review Flow: Approve / Request Changes ### Review Flow: Approve / Request Changes
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error) - Buttons were renamed: **Approve** instead of OK, and **Request Changes** instead of Error.
- Комментарий при Request Changes — опционален - Request Changes comment is optional.
- Manual UI допускает два valid path: - Manual UI allows two valid paths:
- `DONE -> REVIEW -> APPROVED` - `DONE -> REVIEW -> APPROVED`
- `DONE -> APPROVED` как быстрый manual approval - `DONE -> APPROVED` as fast manual approval
- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix` - `Request Changes` removes the kanban-state entry and returns the task to `pending` with `needsFix`.
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP - `reviewHistory` and round-robin balancing are Phase 2, not MVP.
### Members: полный список через union ### Members: Complete List Through Union
- `union(members.meta.json + config members + inbox filenames + task owners)` - единственный способ получить полный список - `union(members.meta.json + config members + inbox filenames + task owners)` is the only way to get the complete member list.
- `owner` в task-файлах — опционален (агент может не иметь owner до назначения) - `owner` in task files is optional. An agent may not have an owner before assignment.
### Graceful Degradation ### Graceful Degradation
- `try/catch` везде в TeamDataService — при ошибке чтения возвращаем безопасные дефолты - `try/catch` is used throughout `TeamDataService`; read errors return safe defaults.
- 3 состояния участника: `ACTIVE` / `IDLE` / `TERMINATED` - Member has 3 states: `ACTIVE` / `IDLE` / `TERMINATED`.
- `ACTIVE`: idle < 5 минут - `ACTIVE`: idle < 5 minutes
- `IDLE`: idle > 5 минут - `IDLE`: idle > 5 minutes
- `TERMINATED`: получен `shutdown_response` с `approve: true` - `TERMINATED`: received `shutdown_response` with `approve: true`
### @dnd-kit and review transitions ### @dnd-kit and Review Transitions
- Переходы между review-колонками делаются через card actions в UI - Transitions between review columns happen through card actions in the UI.
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки - `@dnd-kit` is currently used primarily for reordering tasks inside a column.
- Phase 2: полноценный D&D через `@dnd-kit` - Phase 2: full drag-and-drop through `@dnd-kit`.
--- ---
## Открытые вопросы ## Open Questions
- **FileWatcher расширение**: FileWatcher.ts уже 1243 строк — добавление teams/tasks watchers нетривиально, требует отдельного спайка - **FileWatcher extension**: FileWatcher.ts is already 1243 lines. Adding teams/tasks watchers is non-trivial and needs a separate spike.
- **Windows atomic rename**: `fs.renameSync` на Windows бросает `EXDEV`/`EBUSY` при кросс-устройственном rename — нужна обёртка - **Windows atomic rename**: `fs.renameSync` on Windows can throw `EXDEV`/`EBUSY` for cross-device rename. A wrapper is needed.
- **leadSessionId интеграция**: config.json содержит `leadSessionId`, но интеграция с session viewer (переход к сессии лида) — открытый вопрос - **leadSessionId integration**: config.json contains `leadSessionId`, but integration with the session viewer (navigating to the lead session) remains open.
- **Hard Interrupt**: сообщения доставляются между turns (1-30с задержка). В будущем нужен способ прервать mid-turn - **Hard Interrupt**: messages are delivered between turns with a 1-30 second delay. A future mechanism is needed to interrupt mid-turn.
- **Архивация**: inbox не чистится автоматически, нужна кнопка "Архивировать" - **Archival**: inbox is not cleaned automatically. An "Archive" button is needed.
## Файловая структура Claude Code ## Claude Code File Structure
``` ```text
~/.claude/ ~/.claude/
├── teams/{teamName}/ ├── teams/{teamName}/
│ ├── config.json # Конфиг команды (lead + служебные поля) │ ├── config.json # Team config (lead + service fields)
│ ├── members.meta.json # Роли/цвета/типы участников (teammates) │ ├── members.meta.json # Member roles/colors/types (teammates)
│ └── inboxes/{memberName}.json # Inbox каждого участника │ └── inboxes/{memberName}.json # Inbox for each member
└── tasks/{teamName}/ └── tasks/{teamName}/
├── {id}.json # Файл задачи ├── {id}.json # Task file
├── .lock # Lock-файл (0 байт) ├── .lock # Lock file (0 bytes)
└── .highwatermark # Последний ID задачи └── .highwatermark # Latest task ID
``` ```
**ВАЖНО**: **Important**:
- `config.json` не является source-of-truth для полного roster. - `config.json` is not the source of truth for the complete roster.
- Полный roster для UI формируется как `members.meta.json + inbox filenames (+ lead из config)`. - The UI builds the complete roster from `members.meta.json + inbox filenames (+ lead from config)`.

View file

@ -1,43 +1,43 @@
# Research: Подходы к отправке сообщений тиммейтам # Research: Teammate Message Delivery Approaches
## Сравнение 3 подходов ## Comparison of 3 Approaches
| Критерий | Inbox-файлы | Agent SDK | CLI subprocess | | Criterion | Inbox files | Agent SDK | CLI subprocess |
|----------|:-----------:|:---------:|:--------------:| | --------- | :---------: | :-------: | :------------: |
| Скорость | ~5ms | ~12с | 10-15с | | Speed | ~5ms | ~12s | 10-15s |
| Стоимость | $0 | $0.01-0.08/msg | токены | | Cost | $0 | $0.01-0.08/msg | tokens |
| Работает с запущенными | **YES** | NO | NO | | Works with running teammates | **YES** | NO | NO |
| Прерывает mid-turn | NO | NO | NO | | Interrupts mid-turn | NO | NO | NO |
| Требует API ключ | NO | YES | NO | | Requires API key | NO | YES | NO |
| Расход памяти | 0 | 0 | 100-320MB | | Memory usage | 0 | 0 | 100-320MB |
--- ---
## 1. Inbox-файлы (ВЫБРАНО) ## 1. Inbox Files (Chosen)
### Как работает ### How It Works
Прямая запись JSON в файл `~/.claude/teams/{team}/inboxes/{member}.json`. Claude Code мониторит эти файлы через fs.watch и доставляет сообщения агентам между turns. The app writes JSON directly to `~/.claude/teams/{team}/inboxes/{member}.json`. Claude Code watches these files through fs.watch and delivers messages to agents between turns.
### Плюсы ### Pros
- **Мгновенная запись** (~5ms) - **Instant write** (~5ms)
- **$0** — никаких API вызовов - **$0** - no API calls
- **Единственный** способ общаться с запущенными тиммейтами - **Only** way to communicate with already-running teammates
- Работает с idle и active агентами (но доставка между turns) - Works with idle and active agents, although delivery still happens between turns
### Минусы ### Cons
- Race condition при одновременной записи (см. [research-inbox.md](./research-inbox.md)) - Race condition during concurrent writes (see [research-inbox.md](./research-inbox.md))
- Формат недокументирован (internal API) - Undocumented format (internal API)
- Доставка между turns, не real-time - Delivery happens between turns, not in real time
### Формат сообщения ### Message Format
```json ```json
{ {
"from": "user", "from": "user",
"text": "Не трогай файл auth.ts, я его сам изменю", "text": "Do not touch auth.ts, I will change it myself",
"timestamp": "2026-02-17T15:30:00.000Z", "timestamp": "2026-02-17T15:30:00.000Z",
"read": false, "read": false,
"summary": "Do not modify auth.ts", "summary": "Do not modify auth.ts",
@ -47,9 +47,9 @@
--- ---
## 2. Agent SDK (ОТВЕРГНУТ) ## 2. Agent SDK (Rejected)
### Как работает ### How It Works
```typescript ```typescript
import Anthropic from '@anthropic-ai/sdk'; import Anthropic from '@anthropic-ai/sdk';
@ -57,156 +57,159 @@ const client = new Anthropic();
const response = await client.messages.create({ const response = await client.messages.create({
model: 'claude-opus-4-7', model: 'claude-opus-4-7',
messages: [{ role: 'user', content: 'Send message to teammate...' }], messages: [{ role: 'user', content: 'Send message to teammate...' }],
tools: [/* SendMessage, TaskUpdate, etc. */] tools: [/* SendMessage, TaskUpdate, etc. */],
}); });
``` ```
### Почему отвергнут ### Why It Was Rejected
1. **Создаёт НОВУЮ сессию** — не подключается к работающему тиммейту. SendMessage и TaskCreate — это инструменты модели, не программные вызовы 1. **Creates a new session** - does not attach to a running teammate. SendMessage and TaskCreate are model tools, not programmatic calls.
2. **~12 секунд** на каждый вызов (полный API round-trip) 2. **~12 seconds** per call because of the full API round trip.
3. **Стоит токены** — $0.01-0.08 за сообщение 3. **Costs tokens** - $0.01-0.08 per message.
4. **Нужен API ключ** — отдельная оплата, а не подписка Claude 4. **Requires an API key** - separate billing, not a Claude subscription.
### Когда может пригодиться ### When It May Be Useful
- Создание новых команд программно - Creating new teams programmatically.
- Автоматизация workflow (вне real-time UI) - Workflow automation outside the real-time UI path.
--- ---
## 3. CLI subprocess (ОТВЕРГНУТ) ## 3. CLI Subprocess (Rejected)
### Как работает ### How It Works
```bash ```bash
claude --message "Send message to teammate-1: stop working on X" claude --message "Send message to teammate-1: stop working on X"
``` ```
### Почему отвергнут ### Why It Was Rejected
1. **Новый процесс** — не инжектится в работающего тиммейта 1. **New process** - does not inject into a running teammate.
2. **10-15 секунд** холодный старт 2. **10-15 second** cold start.
3. **100-320MB памяти** на процесс 3. **100-320MB** of memory per process.
4. Каждый вызов стоит токены 4. Each call costs tokens.
--- ---
## Архитектура доставки (обновлено 2026-03-23) ## Delivery Architecture (Updated 2026-03-23)
### Два разных механизма: лид vs тиммейты ### Two Different Mechanisms: Lead vs Teammates
**Лид** читает ТОЛЬКО stdin (stream-json). Для доставки сообщений лиду используется `relayLeadInboxMessages()` — конвертирует inbox-записи в stream-json на stdin. Без relay лид не видит inbox. **Lead** reads ONLY stdin (stream-json). Messages to the lead are delivered with `relayLeadInboxMessages()`, which converts inbox entries into stream-json on stdin. Without relay, the lead does not see inbox messages.
**Тиммейты** — полноценные независимые Claude Code процессы. Каждый мониторит свой inbox файл через fs.watch и читает сообщения напрямую. Relay через лида НЕ нужен. **Teammates** are fully independent Claude Code processes. Each teammate watches its own inbox file through fs.watch and reads messages directly. Relay through the lead is not needed.
### Поток сообщений: Юзер → Тиммейт ### Message Flow: User -> Teammate
``` ```text
User → [UI] → TeamInboxWriter → inboxes/{member}.json (read: false) User -> [UI] -> TeamInboxWriter -> inboxes/{member}.json (read: false)
|
Teammate CLI (fs.watch) → читает → обрабатывает Teammate CLI (fs.watch) -> reads -> handles
|
Teammate → inboxes/user.json (ответ) Teammate -> inboxes/user.json (response)
|
[UI] ← TeamInboxReader ← читает user.json [UI] <- TeamInboxReader <- reads user.json
``` ```
Лид в этой цепочке НЕ участвует. Сообщение доставляется напрямую. The lead is not part of this path. The message is delivered directly.
### Поток сообщений: Юзер → Лид ### Message Flow: User -> Lead
``` ```text
User → [UI] → stdin (stream-json) → Lead CLI User -> [UI] -> stdin (stream-json) -> Lead CLI
|
Lead sentMessages.json / liveLeadProcessMessages Lead -> sentMessages.json / liveLeadProcessMessages
|
[UI] ← читает и отображает [UI] <- reads and renders
``` ```
Для лида дополнительно работает `relayLeadInboxMessages()` при изменении `inboxes/{lead}.json`. For the lead, `relayLeadInboxMessages()` additionally runs when `inboxes/{lead}.json` changes.
### Ответы тиммейтов ### Teammate Responses
Тиммейт отвечает юзеру через `SendMessage(to="user")`, что записывается в `inboxes/user.json`. UI читает этот файл через `TeamInboxReader.getMessages()` (читает ВСЕ inbox файлы в директории). A teammate responds to the user through `SendMessage(to="user")`, which writes to `inboxes/user.json`. The UI reads this file through `TeamInboxReader.getMessages()`, which reads all inbox files in the directory.
Сообщения в `user.json` могут не содержать `messageId``TeamInboxReader` генерирует детерминированный ID из sha256(from + timestamp + text). Messages in `user.json` may not contain `messageId`; `TeamInboxReader` generates a deterministic ID from sha256(from + timestamp + text).
### from: "user" — подтверждено работает ### from: "user" Is Confirmed To Work
`from: "user"` работает корректно (подтверждено эмпирически 2026-03-23): `from: "user"` works correctly, confirmed empirically on 2026-03-23:
- Тиммейт получает сообщение
- Тиммейт корректно определяет что это от юзера
- Тиммейт отвечает в `inboxes/user.json`
- Fallback на `from: "team-lead"` не нужен
### Почему relay через лида был ОТКЛЮЧЁН (2026-03-23) - Teammate receives the message.
- Teammate correctly identifies that it came from the user.
- Teammate responds in `inboxes/user.json`.
- Fallback to `from: "team-lead"` is not needed.
Ранее при отправке DM тиммейту, помимо записи в inbox, вызывался `relayMemberInboxMessages()` — инструкция лиду переслать сообщение через `SendMessage(to=member)`. Это вызывало 3 бага: ### Why Relay Through the Lead Was Disabled (2026-03-23)
1. **Лид отвечал вместо тиммейта** — LLM интерпретировал relay-инструкцию как обращение к себе и отвечал юзеру напрямую Previously, when sending a DM to a teammate, the app called `relayMemberInboxMessages()` in addition to writing to the inbox. This instructed the lead to forward the message through `SendMessage(to=member)`. It caused 3 bugs:
2. **Дубликаты сообщений**`markInboxMessagesRead()` записывал в файл → FileWatcher срабатывал → relay запускался повторно → цикл
3. **Тиммейт не отвечал юзеру** — relay-промпт содержал "Do NOT send to user", что тиммейт тоже видел через лида
Relay отключён в `teams.ts` (handleSendMessage) и `index.ts` (FileWatcher). Код закомментирован, не удалён. Relay для лида (`relayLeadInboxMessages`) не затронут. 1. **Lead replied instead of the teammate** - the LLM interpreted the relay instruction as addressed to itself and answered the user directly.
2. **Duplicate messages** - `markInboxMessagesRead()` wrote to the file, triggering FileWatcher, which re-ran relay and created a loop.
3. **Teammate did not reply to the user** - the relay prompt contained "Do NOT send to user", which the teammate also saw through the lead.
Relay is disabled in `teams.ts` (`handleSendMessage`) and `index.ts` (FileWatcher). The code is commented out, not deleted. Lead relay (`relayLeadInboxMessages`) is unaffected.
--- ---
## Доставка: Timing и ограничения ## Delivery: Timing and Constraints
### Цикл тиммейта ### Teammate Turn Cycle
``` ```text
Turn N: Turn N:
1. Читает inbox → видит новые (read: false) 1. Reads inbox -> sees new messages with read: false
2. Обрабатывает сообщения/задачи 2. Handles messages/tasks
3. Вызывает инструменты 3. Calls tools
4. Reasoning 4. Reasoning
5. Output 5. Output
→ idle_notification → IDLE -> idle_notification -> IDLE
... ожидание ... ... wait ...
Turn N+1: Turn N+1:
1. Пробуждение (новое сообщение в inbox / назначение задачи) 1. Wake-up (new inbox message / assigned task)
2. Читает inbox → видит новые 2. Reads inbox -> sees new messages
... ...
``` ```
### Задержка ### Delay
- **Idle agent**: получит при следующем пробуждении (доли секунды если inbox-change triggers) - **Idle agent**: receives the message on the next wake-up, usually a fraction of a second if inbox-change triggers.
- **Active agent (mid-turn)**: получит только после завершения текущего turn (1-30 секунд) - **Active agent (mid-turn)**: receives the message only after the current turn completes, usually 1-30 seconds.
### Нельзя прервать ### No Mid-Turn Interrupt
Если агент уже вызвал Edit/Bash — инструмент выполнится. Наше сообщение придёт ПОСЛЕ. If an agent has already called Edit/Bash, the tool will complete. Our message arrives after that.
**Пример**: **Example**:
```
17:12:30 — Agent начинает Edit на auth.ts ```text
17:12:31 — Мы шлём "Не трогай auth.ts" 17:12:30 - Agent starts Edit on auth.ts
17:12:32 — Agent завершает Edit (auth.ts изменён) 17:12:31 - We send "Do not touch auth.ts"
17:12:33 — Agent читает inbox, видит наше сообщение 17:12:32 - Agent completes Edit (auth.ts changed)
→ Поздно, файл уже изменён 17:12:33 - Agent reads inbox and sees our message
-> Too late, the file was already changed
``` ```
### Hard Interrupt (будущее) ### Hard Interrupt (Future)
Возможные подходы: Possible approaches:
1. **kill -SIGINT** процесса тиммейта (жёсткое прерывание, потеря контекста)
2. **Файловый flag** `.interrupt-{member}` (нужна поддержка в Claude Code)
3. **API от Anthropic** (если появится)
Текущее решение: задержка приемлема, hard interrupt — в будущем. 1. **kill -SIGINT** the teammate process: hard interrupt, context loss.
2. **File flag** `.interrupt-{member}`: needs Claude Code support.
3. **Anthropic API**: if it becomes available.
Current decision: the delay is acceptable; hard interrupt is future work.
--- ---
## Финальное решение ## Final Decision
### messageId — обязателен в каждом исходящем сообщении ### messageId Is Required In Every Outgoing Message
Каждое исходящее сообщение включает `messageId: crypto.randomUUID()`: Every outgoing message includes `messageId: crypto.randomUUID()`:
```json ```json
{ {
@ -219,18 +222,18 @@ Turn N+1:
} }
``` ```
### Verify: проверка сразу после записи ### Verify Immediately After Write
- После atomic write читаем inbox и ищем наш `messageId` - After atomic write, read the inbox and look for our `messageId`.
- Если не найден — потеря обнаружена → warning в UI (не silent fail) - If missing, message loss was detected -> show a warning in the UI instead of failing silently.
- Не автоматический retry на MVP - No automatic retry in MVP.
### 3 состояния offline-участника ### 3 States For Offline Members
| Состояние | Условие | Отображение | | State | Condition | Display |
|-----------|---------|-------------| | ----- | --------- | ------- |
| `ACTIVE` | idle < 5 минут | Зелёный dot | | `ACTIVE` | idle < 5 minutes | Green dot |
| `IDLE` | idle > 5 минут | Жёлтый dot | | `IDLE` | idle > 5 minutes | Yellow dot |
| `TERMINATED` | Получен `shutdown_response` с `approve: true` | Серый dot, "Завершён" | | `TERMINATED` | Received `shutdown_response` with `approve: true` | Gray dot, "Terminated" |
Определение состояния по timestamp последнего события в inbox (idle_notification, любое сообщение). TERMINATED — исключительно по явному `shutdown_response`. State is determined from the timestamp of the latest event in the inbox (`idle_notification` or any message). `TERMINATED` is based only on an explicit `shutdown_response`.

View file

@ -19,13 +19,60 @@ Default location for new feature work:
- `src/features/<feature-name>/` - `src/features/<feature-name>/`
Before adding or moving code: Before adding a medium or large feature:
- decide whether the feature is full, thin, or process-limited - decide whether the feature is full, thin, or process-limited
- add only the layers the feature actually owns - start with the layer set the feature actually owns; do not add placeholder
- expose production callers through public entrypoints only folders just to match the full template
- keep tests close to the layer they verify under `test/features/<feature>/` or - create explicit public entrypoints for every layer production callers need
feature-local `__tests__` when that is the established local pattern - put shared DTOs, channel names, and API fragments in `contracts/`
- keep business policy in `core/domain` and use-case orchestration in
`core/application`
- keep Electron, IPC, HTTP, file system, process, and provider specifics outside
`core/`
- wire runtime dependencies from `main/composition/` when the feature owns main
process behavior
- expose preload bridges through `preload/index.ts` and renderer surfaces
through `renderer/index.ts`
- add focused tests for the layers that carry behavior
When modifying an existing feature:
- preserve the feature's current shape unless the change introduces a real new
boundary
- route app shell and cross-feature imports through public entrypoints
- move duplicated rules toward `core/domain` before adding another adapter copy
- keep transport validation and normalization close to the boundary that receives
the data
- update the feature README or local notes when the public surface or intended
shape changes
- keep local README examples concrete and file-based; link back to the standard
for architecture rules instead of restating them
Public entrypoint expectations:
- `contracts/index.ts` exports only browser-safe contracts, constants, and
normalizers intended for cross-process use
- `main/index.ts` exports composition and registration surfaces for main-process
callers
- `preload/index.ts` exports bridge creation only
- `renderer/index.ts` exports reusable renderer components, hooks, or utilities
that are intentionally consumed outside the feature
- root `index.ts` is optional; use it only when the feature deliberately owns a
stable public barrel
Testing expectations:
- test pure domain rules directly and keep those tests independent of runtime
services
- test application use cases with ports or fakes, not Electron or real provider
processes
- test adapter mapping, boundary normalization, and renderer utilities where they
can regress user-visible behavior
- prefer `test/features/<feature>/...` for cross-layer coverage; feature-local
`__tests__` are fine when the surrounding feature already uses that pattern
- for docs-only changes, verify links and examples instead of running broad test
suites
Do not duplicate architecture rules here. Keep architecture rules centralized in Do not duplicate architecture rules here. Keep architecture rules centralized in
[../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md). [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md).

View file

@ -3,22 +3,38 @@
This directory contains the canonical home for medium and large feature slices. This directory contains the canonical home for medium and large feature slices.
Before creating or refactoring a feature, read: Before creating or refactoring a feature, read:
- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) - [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md)
- [Feature-local agent guidance](./CLAUDE.md) - [Feature-local agent guidance](./CLAUDE.md)
Reference implementation: Reference examples:
- `src/features/recent-projects`
- `src/features/agent-graph` - [`recent-projects`](./recent-projects/README.md) - full cross-process feature
with contracts, core, main, preload, renderer, and focused tests
- [`agent-graph`](./agent-graph/README.md) - thin feature with `core/domain` and
renderer integration only
- `codex-model-catalog` and `team-runtime-lanes` - process-limited features
that omit renderer or preload layers when they do not own those boundaries
Use `src/features/<feature-name>/` by default when the work introduces: Use `src/features/<feature-name>/` by default when the work introduces:
- a new use case or business policy - a new use case or business policy
- transport wiring - transport wiring
- more than one process boundary - more than one process boundary
- more than one adapter or provider - more than one adapter or provider
Feature-local docs should answer navigation questions:
- which shape the feature uses
- which entrypoints are public
- where new adapters, rules, bridges, or renderer surfaces belong
- what tests protect the behavior
- which local files are the best examples for future changes
Do not duplicate architecture rules in feature folders. Do not duplicate architecture rules in feature folders.
Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md). Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md).
Rule of thumb: Rule of thumb:
- `recent-projects` is the full slice example with process-aware outer layers - `recent-projects` is the full slice example with process-aware outer layers
- `agent-graph` is the thin slice example built around `core/` plus `renderer/` - `agent-graph` is the thin slice example built around `core/` plus `renderer/`

View file

@ -0,0 +1,108 @@
# Recent Projects Feature
`recent-projects` is the full cross-process reference for
[`docs/FEATURE_ARCHITECTURE_STANDARD.md`](../../../docs/FEATURE_ARCHITECTURE_STANDARD.md).
Use it as the local example when a feature owns contracts, pure business rules,
runtime composition, transport adapters, preload bridging, and renderer UI.
Read this with:
- [Feature root guide](../README.md)
- [Feature-local agent guidance](../CLAUDE.md)
## Feature Shape
```text
src/features/recent-projects/
contracts/
core/
domain/
application/
main/
composition/
adapters/
input/
output/
infrastructure/
preload/
renderer/
```
This feature intentionally does not have a root `index.ts`. Production callers
enter through the layer-specific public entrypoints:
- `contracts/index.ts` for DTOs, channels, API fragments, and payload
normalization
- `main/index.ts` for main-process registration and composition
- `preload/index.ts` for bridge creation
- `renderer/index.ts` for renderer-owned UI and public renderer utilities
## Layer Examples
- `core/domain/policies/mergeRecentProjectCandidates.ts` owns provider-agnostic
merge policy and stays pure
- `core/application/use-cases/ListDashboardRecentProjectsUseCase.ts`
orchestrates ports and response models without importing runtime details
- `main/composition/createRecentProjectsFeature.ts` wires infrastructure,
adapters, ports, and use cases for the main process
- `main/adapters/input/ipc/registerRecentProjectsIpc.ts` and
`main/adapters/input/http/registerRecentProjectsHttp.ts` translate transport
requests into feature calls
- `main/adapters/output/sources/*` adapts provider/runtime data into the core
model
- `main/infrastructure/cache/InMemoryRecentProjectsCache.ts` and
`main/infrastructure/identity/*` keep runtime-specific helpers out of `core/`
- `preload/createRecentProjectsBridge.ts` exposes the feature API fragment to
the renderer
- `renderer/hooks/useRecentProjectsSection.ts` coordinates renderer interaction
and data access
- `renderer/ui/RecentProjectsSection.tsx` keeps the visual component focused on
rendering and callbacks
## How To Extend It
When adding another source or provider:
- add or reuse a port in `core/application/ports/`
- keep provider-specific parsing in `main/adapters/output/` or
`main/infrastructure/`
- keep merge, ordering, dedupe, and selection rules in `core/domain/`
- wire the new dependency in `main/composition/createRecentProjectsFeature.ts`
- add focused tests beside the layer that owns the behavior
When adding another transport:
- put shared request/response shape in `contracts/`
- implement the input adapter under `main/adapters/input/`
- keep handler registration out of `core/`
- expose only the stable surface from `main/index.ts`
When changing renderer behavior:
- keep data fetching and app API calls in hooks or renderer adapters
- keep UI components presentational
- transform DTOs into view models before they reach reusable UI where practical
- update renderer utility tests when sorting, navigation, active-team state, or
client cache behavior changes
When updating this reference:
- keep examples tied to real files in this feature
- update this README when public entrypoints or intended extension paths change
- leave cross-feature architecture wording in the shared standard
## Test Map
Reference tests live under `test/features/recent-projects/`:
- `contracts/` covers payload normalization
- `core/domain/` covers merge policy
- `core/application/` covers use-case orchestration through ports
- `main/adapters/output/` and `main/infrastructure/` cover provider and runtime
integration boundaries with fakes
- `renderer/adapters/` and `renderer/utils/` cover view-model mapping and
interaction helpers
For new medium or large features, this test shape is a good starting point:
domain rules first, application use cases second, then focused adapter and
renderer utility coverage for behavior that can break user workflows.

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
@ -20,18 +20,11 @@ export const RecentProjectCard = ({
onOpenPath, onOpenPath,
}: Readonly<RecentProjectCardProps>): React.JSX.Element => { }: Readonly<RecentProjectCardProps>): React.JSX.Element => {
const color = useMemo(() => projectColor(card.name), [card.name]); const color = useMemo(() => projectColor(card.name), [card.name]);
const [isHovered, setIsHovered] = useState(false);
return ( return (
<button <button
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsHovered(true)} className="project-row-zebra-card group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis"
onMouseLeave={() => setIsHovered(false)}
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
style={{
borderLeftColor: color.border,
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
}}
> >
{card.activeTeams && card.activeTeams.length > 0 && ( {card.activeTeams && card.activeTeams.length > 0 && (
<ActivePulseIndicator className="absolute right-3 top-3" /> <ActivePulseIndicator className="absolute right-3 top-3" />

View file

@ -141,10 +141,12 @@ export const RecentProjectsSection = ({
); );
} }
const hasSelectFolderCard = !searchQuery.trim() && isElectron;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4"> <div className="project-row-zebra-grid grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{!searchQuery.trim() && isElectron && ( {hasSelectFolderCard && (
<SelectProjectFolderCard onClick={() => void selectProjectFolder()} /> <SelectProjectFolderCard onClick={() => void selectProjectFolder()} />
)} )}
{cards.map((card) => ( {cards.map((card) => (

View file

@ -13,7 +13,7 @@ export interface RunningTeamRowModel {
projectLabel: string; projectLabel: string;
status: RunningTeamDashboardEntry['status']; status: RunningTeamDashboardEntry['status'];
statusLabel: string; statusLabel: string;
accentColor: string; iconColor: string;
taskCounts?: TaskStatusCounts; taskCounts?: TaskStatusCounts;
} }
@ -47,7 +47,7 @@ export function adaptRunningTeamsSection(
projectLabel: getProjectLabel(team.projectPath), projectLabel: getProjectLabel(team.projectPath),
status: team.status, status: team.status,
statusLabel: getStatusLabel(team.status), statusLabel: getStatusLabel(team.status),
accentColor: team.color iconColor: team.color
? getTeamColorSet(team.color).border ? getTeamColorSet(team.color).border
: nameColorSet(team.displayName).border, : nameColorSet(team.displayName).border,
taskCounts: team.taskCounts, taskCounts: team.taskCounts,

View file

@ -1,6 +1,6 @@
import { TeamTaskStatusSummary } from '@renderer/components/team/TeamTaskStatusSummary'; import { TeamTaskStatusSummary } from '@renderer/components/team/TeamTaskStatusSummary';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
import { FolderOpen } from 'lucide-react'; import { FolderOpen, UsersRound } from 'lucide-react';
import { useRunningTeamsSection } from '../hooks/useRunningTeamsSection'; import { useRunningTeamsSection } from '../hooks/useRunningTeamsSection';
@ -40,11 +40,13 @@ export function RunningTeamsSection({
key={row.id} key={row.id}
type="button" type="button"
onClick={() => openRunningTeam(row)} onClick={() => openRunningTeam(row)}
className="bg-surface/50 group relative flex min-w-0 items-start overflow-hidden rounded-lg border border-border px-3 py-2.5 pr-8 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised" className="bg-surface/50 group relative flex min-w-0 items-start gap-2.5 overflow-hidden rounded-lg border border-border px-3 py-2.5 pr-8 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised"
style={{ borderLeftColor: row.accentColor }}
title={getRowTitle(row)} title={getRowTitle(row)}
> >
<ActivePulseIndicator className="absolute right-3 top-3" /> <ActivePulseIndicator className="absolute right-3 top-3" />
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors group-hover:border-border-emphasis">
<UsersRound className="size-4 transition-colors" style={{ color: row.iconColor }} />
</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium text-text">{row.displayName}</span> <span className="truncate text-sm font-medium text-text">{row.displayName}</span>

View file

@ -3351,12 +3351,6 @@ export class TeamDataService {
teamName: string, teamName: string,
config: TeamConfig config: TeamConfig
): Promise<InboxMessage[]> { ): Promise<InboxMessage[]> {
const transcriptContext = await this.projectResolver.getContext(teamName);
if (!transcriptContext) {
return [];
}
const leadName =
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
const knownLeadSessionIds = this.getRecentLeadSessionIds(config); const knownLeadSessionIds = this.getRecentLeadSessionIds(config);
if (knownLeadSessionIds.length === 0) { if (knownLeadSessionIds.length === 0) {
return []; return [];
@ -3365,11 +3359,36 @@ export class TeamDataService {
if (sessionIds.length === 0) { if (sessionIds.length === 0) {
return []; return [];
} }
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
let transcriptContext = await this.projectResolver.getLiveBaseContext(teamName);
if (!transcriptContext) {
transcriptContext = await this.projectResolver.getContext(teamName, {
includeTeamSubagentSessionDiscovery: false,
});
}
if (!transcriptContext) {
return [];
}
let availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
const primaryLeadSessionId = sessionIds[0];
const hasPrimaryLeadSessionPath = (): boolean =>
Boolean(primaryLeadSessionId && availableJsonlPaths.has(primaryLeadSessionId));
if (!hasPrimaryLeadSessionPath()) {
const fallbackContext = await this.projectResolver.getContext(teamName, {
includeTeamSubagentSessionDiscovery: false,
});
if (fallbackContext) {
transcriptContext = fallbackContext;
availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
}
}
if (availableJsonlPaths.size === 0) { if (availableJsonlPaths.size === 0) {
return []; return [];
} }
const leadName =
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
const texts: InboxMessage[] = []; const texts: InboxMessage[] = [];
for (const sessionId of sessionIds) { for (const sessionId of sessionIds) {
if (texts.length >= MAX_LEAD_TEXTS) break; if (texts.length >= MAX_LEAD_TEXTS) break;

View file

@ -5651,6 +5651,7 @@ export class TeamProvisioningService {
Promise<OpenCodeMemberInboxRelayResult> Promise<OpenCodeMemberInboxRelayResult>
>(); >();
private readonly openCodePromptDeliveryWatchdogTimers = new Map<string, NodeJS.Timeout>(); private readonly openCodePromptDeliveryWatchdogTimers = new Map<string, NodeJS.Timeout>();
private readonly openCodePromptDeliveryWatchdogDeadlines = new Map<string, number>();
private readonly openCodeRuntimeDeliveryAdvisoryReviewTimers = new Map<string, NodeJS.Timeout>(); private readonly openCodeRuntimeDeliveryAdvisoryReviewTimers = new Map<string, NodeJS.Timeout>();
private readonly openCodeRuntimeDeliveryAdvisoryEventSentAt = new Map<string, number>(); private readonly openCodeRuntimeDeliveryAdvisoryEventSentAt = new Map<string, number>();
private readonly openCodeRuntimeDeliveryLeadNoticeSentAt = new Map<string, number>(); private readonly openCodeRuntimeDeliveryLeadNoticeSentAt = new Map<string, number>();
@ -7125,14 +7126,15 @@ export class TeamProvisioningService {
return this.hasAlivePersistedTeamProcess(teamName); return this.hasAlivePersistedTeamProcess(teamName);
} }
private hasAlivePersistedTeamProcess(teamName: string): boolean { private canAttemptCommittedOpenCodeSessionRecovery(teamName: string): boolean {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); if (this.canDeliverToOpenCodeRuntimeForTeam(teamName)) {
let parsed: unknown; return true;
try {
parsed = JSON.parse(fs.readFileSync(processesPath, 'utf8')) as unknown;
} catch {
return false;
} }
return !this.hasOnlyExplicitlyStoppedPersistedTeamProcesses(teamName);
}
private hasAlivePersistedTeamProcess(teamName: string): boolean {
const parsed = this.readPersistedTeamProcessRows(teamName);
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
return false; return false;
} }
@ -7150,6 +7152,33 @@ export class TeamProvisioningService {
}); });
} }
private hasOnlyExplicitlyStoppedPersistedTeamProcesses(teamName: string): boolean {
const parsed = this.readPersistedTeamProcessRows(teamName);
if (!Array.isArray(parsed) || parsed.length === 0) {
return false;
}
return parsed.every((row) => {
if (!row || typeof row !== 'object') {
return false;
}
return (row as { stoppedAt?: unknown }).stoppedAt != null;
});
}
private readPersistedTeamProcessRows(teamName: string): unknown[] | null {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(processesPath, 'utf8')) as unknown;
} catch {
return null;
}
if (!Array.isArray(parsed)) {
return null;
}
return parsed;
}
private cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName: string): void { private cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName: string): void {
void this.stopOpenCodeRuntimeLanesForStoppedTeam(teamName).catch((error) => { void this.stopOpenCodeRuntimeLanesForStoppedTeam(teamName).catch((error) => {
logger.warn( logger.warn(
@ -8659,18 +8688,31 @@ export class TeamProvisioningService {
memberName: input.memberName, memberName: input.memberName,
messageId, messageId,
}); });
const delayMs = Math.max(500, Math.min(input.delayMs, 60_000));
const deadlineMs = Date.now() + delayMs;
const existing = this.openCodePromptDeliveryWatchdogTimers.get(key); const existing = this.openCodePromptDeliveryWatchdogTimers.get(key);
if (existing) { if (existing) {
const existingDeadlineMs = this.openCodePromptDeliveryWatchdogDeadlines.get(key);
if (typeof existingDeadlineMs === 'number' && existingDeadlineMs <= deadlineMs + 25) {
return;
}
clearTimeout(existing); clearTimeout(existing);
} }
const delayMs = Math.max(500, Math.min(input.delayMs, 60_000));
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.openCodePromptDeliveryWatchdogTimers.delete(key); this.openCodePromptDeliveryWatchdogTimers.delete(key);
this.openCodePromptDeliveryWatchdogDeadlines.delete(key);
this.enqueueOpenCodePromptDeliveryWatchdogJob({ this.enqueueOpenCodePromptDeliveryWatchdogJob({
teamName: input.teamName, teamName: input.teamName,
run: async () => { run: async () => {
if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) { if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) {
return; const recovered =
await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery({
teamName: input.teamName,
memberName: input.memberName,
});
if (!recovered) {
return;
}
} }
try { try {
await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, { await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, {
@ -8697,6 +8739,7 @@ export class TeamProvisioningService {
}); });
}, delayMs); }, delayMs);
this.openCodePromptDeliveryWatchdogTimers.set(key, timer); this.openCodePromptDeliveryWatchdogTimers.set(key, timer);
this.openCodePromptDeliveryWatchdogDeadlines.set(key, deadlineMs);
} }
private getOpenCodeDeliveryNextDelayMs(input: { private getOpenCodeDeliveryNextDelayMs(input: {
@ -9269,25 +9312,27 @@ export class TeamProvisioningService {
if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) {
return 0; return 0;
} }
if (!this.canDeliverToOpenCodeRuntimeForTeam(teamName)) { const canDeliverToTeamRuntime = this.canDeliverToOpenCodeRuntimeForTeam(teamName);
const recoveredLaneIds = await this.tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog(
teamName,
{ allowCommittedSessionRecoveryWithoutTeamRuntime: !canDeliverToTeamRuntime }
);
if (!canDeliverToTeamRuntime && recoveredLaneIds.length === 0) {
await this.stopOpenCodeRuntimeLanesForStoppedTeam(teamName); await this.stopOpenCodeRuntimeLanesForStoppedTeam(teamName);
return 0; return 0;
} }
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(
() => null () => null
); );
if (!laneIndex) { if (!laneIndex && recoveredLaneIds.length === 0) {
return 0; return 0;
} }
let activeLaneIds = Object.values(laneIndex.lanes) let activeLaneIds = canDeliverToTeamRuntime
.filter((lane) => lane.state === 'active') ? Object.values(laneIndex?.lanes ?? {})
.map((lane) => lane.laneId); .filter((lane) => lane.state === 'active')
activeLaneIds = [ .map((lane) => lane.laneId)
...new Set([ : [];
...activeLaneIds, activeLaneIds = [...new Set([...activeLaneIds, ...recoveredLaneIds])];
...(await this.tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog(teamName)),
]),
];
return await this.scanOpenCodePromptDeliveryWatchdogForActiveLanes(teamName, activeLaneIds); return await this.scanOpenCodePromptDeliveryWatchdogForActiveLanes(teamName, activeLaneIds);
} }
@ -9523,7 +9568,7 @@ export class TeamProvisioningService {
laneIdentity.laneKind === 'secondary' && laneIdentity.laneKind === 'secondary' &&
laneIdentity.laneOwnerProviderId === 'opencode' laneIdentity.laneOwnerProviderId === 'opencode'
) { ) {
const recovered = await this.tryRecoverOpenCodeRuntimeLaneBeforeDelivery({ let recovered = await this.tryRecoverOpenCodeRuntimeLaneBeforeDelivery({
teamName, teamName,
laneId: laneIdentity.laneId, laneId: laneIdentity.laneId,
member: { member: {
@ -9540,6 +9585,25 @@ export class TeamProvisioningService {
}, },
projectPath: config?.projectPath?.trim() || this.readPersistedTeamProjectPath(teamName), projectPath: config?.projectPath?.trim() || this.readPersistedTeamProjectPath(teamName),
}); });
if (!recovered) {
recovered = await this.tryRecoverOpenCodeRuntimeLaneFromCommittedSessionBeforeDelivery({
teamName,
laneId: laneIdentity.laneId,
member: {
...(configMember ?? {}),
...(metaMember ?? {}),
name: canonicalMemberName,
providerId: 'opencode',
model: metaMember?.model ?? configMember?.model,
role: metaMember?.role ?? configMember?.role,
workflow: metaMember?.workflow ?? configMember?.workflow,
effort: metaMember?.effort ?? configMember?.effort,
cwd: memberRuntimeCwd || undefined,
isolation: metaMember?.isolation ?? configMember?.isolation,
},
projectPath: config?.projectPath?.trim() || this.readPersistedTeamProjectPath(teamName),
});
}
if (recovered) { if (recovered) {
runtimeRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId); runtimeRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId);
runtimeActive = true; runtimeActive = true;
@ -10049,23 +10113,81 @@ export class TeamProvisioningService {
pendingReason: retryPendingReason, pendingReason: retryPendingReason,
}), }),
}); });
const result = await adapter.sendMessageToMember({ let result: OpenCodeTeamRuntimeMessageResult;
...(runtimeRunId ? { runId: runtimeRunId } : {}), try {
teamName, result = await adapter.sendMessageToMember({
laneId: laneIdentity.laneId, ...(runtimeRunId ? { runId: runtimeRunId } : {}),
memberName: canonicalMemberName, teamName,
cwd, laneId: laneIdentity.laneId,
text: deliveryText, memberName: canonicalMemberName,
messageId: input.messageId, cwd,
deliveryAttemptId, text: deliveryText,
fileParts: openCodeFileParts, messageId: input.messageId,
replyRecipient: input.replyRecipient, deliveryAttemptId,
actionMode: input.actionMode, fileParts: openCodeFileParts,
messageKind: input.messageKind, replyRecipient: input.replyRecipient,
workSyncIntent: input.workSyncIntent, actionMode: input.actionMode,
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, messageKind: input.messageKind,
taskRefs: input.taskRefs, workSyncIntent: input.workSyncIntent,
}); workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
taskRefs: input.taskRefs,
});
} catch (error) {
const diagnostic = `opencode_message_delivery_exception: ${getErrorMessage(error)}`;
if (ledgerRecord && ledger) {
ledgerRecord = await ledger.applyDeliveryResult({
id: ledgerRecord.id,
accepted: false,
attempted: true,
responseObservation: {
state: 'reconcile_failed',
deliveredUserMessageId: null,
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: diagnostic,
},
deliveryAttemptId,
prePromptCursor: ledgerRecord.prePromptCursor,
diagnostics: [diagnostic],
reason: diagnostic,
now: nowIso(),
});
this.emitOpenCodePromptDeliveryTaskLogChange(
ledgerRecord,
'opencode-prompt-delivery-send-exception'
);
ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({
ledger,
ledgerRecord,
teamName,
memberName: canonicalMemberName,
retry: true,
reason: diagnostic,
});
return {
delivered: false,
accepted: false,
responsePending: true,
responseState: ledgerRecord.responseState,
ledgerStatus: ledgerRecord.status,
ledgerRecordId: ledgerRecord.id,
laneId: laneIdentity.laneId,
reason: diagnostic,
diagnostics: ledgerRecord.diagnostics.length ? ledgerRecord.diagnostics : [diagnostic],
};
}
return {
delivered: false,
accepted: false,
responsePending: false,
reason: diagnostic,
diagnostics: [diagnostic],
};
}
await this.rememberOpenCodeRuntimePidFromBridge({ await this.rememberOpenCodeRuntimePidFromBridge({
teamName, teamName,
memberName: canonicalMemberName, memberName: canonicalMemberName,
@ -10624,20 +10746,172 @@ export class TeamProvisioningService {
return true; return true;
} }
private async tryRecoverOpenCodeRuntimeLaneFromCommittedSessionBeforeDelivery(input: {
teamName: string;
laneId: string;
member: TeamMember;
projectPath: string | null;
previousLaunchState?: PersistedTeamLaunchSnapshot | null;
}): Promise<boolean> {
if (!this.canAttemptCommittedOpenCodeSessionRecovery(input.teamName)) {
this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(input.teamName);
return false;
}
const currentLaneIndex = await readOpenCodeRuntimeLaneIndex(
getTeamsBasePath(),
input.teamName
).catch(() => null);
const currentEntry = currentLaneIndex?.lanes[input.laneId];
if (currentEntry?.state === 'active') {
return true;
}
if (currentEntry?.state === 'degraded' || currentEntry?.state === 'stopped') {
return false;
}
const committedSessionEvidence = await readCommittedOpenCodeBootstrapSessionEvidence({
teamsBasePath: getTeamsBasePath(),
teamName: input.teamName,
laneId: input.laneId,
}).catch(() => null);
if (!committedSessionEvidence?.committed || committedSessionEvidence.sessions.length === 0) {
return false;
}
const expectedMemberName = input.member.name.trim().toLowerCase();
const matchingSession = committedSessionEvidence.sessions.find(
(session) => session.memberName.trim().toLowerCase() === expectedMemberName
);
if (!matchingSession) {
return false;
}
const runtimeEvidence = await this.tryRecoverActiveOpenCodeSecondaryLaneFromRuntime({
teamName: input.teamName,
laneId: input.laneId,
member: input.member,
projectPath: input.projectPath,
previousLaunchState: input.previousLaunchState ?? null,
});
if (!isRecoverableOpenCodeRuntimeEvidence(runtimeEvidence)) {
return false;
}
const diagnostics = Array.from(
new Set([
'Recovered missing OpenCode runtime lane index from committed session evidence.',
...committedSessionEvidence.diagnostics,
...(runtimeEvidence.diagnostics ?? []),
])
);
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName: input.teamName,
laneId: input.laneId,
state: 'active',
diagnostics,
}).catch((error: unknown) => {
logger.warn(
`[${input.teamName}] Failed to recover missing OpenCode lane index ${input.laneId} from committed session evidence: ${getErrorMessage(error)}`
);
});
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: getTeamsBasePath(),
teamName: input.teamName,
laneId: input.laneId,
runId: committedSessionEvidence.activeRunId ?? matchingSession.runId ?? null,
}).catch((error: unknown) => {
logger.warn(
`[${input.teamName}] Failed to materialize committed-session recovered OpenCode lane manifest ${input.laneId}: ${getErrorMessage(error)}`
);
});
logger.info(
`[${input.teamName}] Recovered OpenCode lane ${input.laneId} from committed session evidence before message delivery.`
);
return true;
}
private buildOpenCodeRecoveryMember(input: {
canonicalMemberName: string;
configMember?: TeamMember;
metaMember?: TeamMember;
}): TeamMember {
return {
...(input.configMember ?? {}),
...(input.metaMember ?? {}),
name: input.canonicalMemberName,
providerId: 'opencode',
model: input.metaMember?.model ?? input.configMember?.model,
role: input.metaMember?.role ?? input.configMember?.role,
workflow: input.metaMember?.workflow ?? input.configMember?.workflow,
effort: input.metaMember?.effort ?? input.configMember?.effort,
cwd: input.metaMember?.cwd ?? input.configMember?.cwd,
isolation: input.metaMember?.isolation ?? input.configMember?.isolation,
};
}
private async tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery(input: {
teamName: string;
memberName: string;
}): Promise<boolean> {
const directory = await this.readOpenCodeMemberDirectory(input.teamName).catch(() => null);
if (!directory) {
return false;
}
const identity = this.resolveOpenCodeMemberIdentityFromDirectory(
input.teamName,
input.memberName,
directory
);
if (!identity.ok) {
return false;
}
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), input.teamName).catch(
() => null
);
const currentEntry = laneIndex?.lanes[identity.laneId];
if (currentEntry?.state === 'active') {
return true;
}
if (currentEntry?.state === 'degraded' || currentEntry?.state === 'stopped') {
return false;
}
const previousLaunchState = await this.launchStateStore.read(input.teamName).catch(() => null);
const projectPath =
identity.memberRuntimeCwd ??
directory.config?.projectPath?.trim() ??
this.readPersistedTeamProjectPath(input.teamName);
return this.tryRecoverOpenCodeRuntimeLaneFromCommittedSessionBeforeDelivery({
teamName: input.teamName,
laneId: identity.laneId,
member: this.buildOpenCodeRecoveryMember({
canonicalMemberName: identity.canonicalMemberName,
configMember: identity.configMember,
metaMember: identity.metaMember,
}),
projectPath,
previousLaunchState,
});
}
private async tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog( private async tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog(
teamName: string teamName: string,
options: { allowCommittedSessionRecoveryWithoutTeamRuntime?: boolean } = {}
): Promise<string[]> { ): Promise<string[]> {
if (!this.canDeliverToOpenCodeRuntimeForTeam(teamName)) { const canDeliverToTeamRuntime = this.canDeliverToOpenCodeRuntimeForTeam(teamName);
if (!canDeliverToTeamRuntime && !options.allowCommittedSessionRecoveryWithoutTeamRuntime) {
this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName);
return [];
}
if (!canDeliverToTeamRuntime && !this.canAttemptCommittedOpenCodeSessionRecovery(teamName)) {
this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName); this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName);
return []; return [];
} }
const snapshot = await this.launchStateStore.read(teamName).catch(() => null); const snapshot = await this.launchStateStore.read(teamName).catch(() => null);
const candidates = Object.values(snapshot?.members ?? {}).filter( const candidates = canDeliverToTeamRuntime
isRecoverablePersistedOpenCodeRuntimeCandidate ? Object.values(snapshot?.members ?? {}).filter(
); isRecoverablePersistedOpenCodeRuntimeCandidate
if (candidates.length === 0) { )
return []; : [];
}
const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([ const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([
this.readConfigForObservation(teamName).catch(() => null), this.readConfigForObservation(teamName).catch(() => null),
@ -10697,6 +10971,48 @@ export class TeamProvisioningService {
recoveredLaneIds.push(laneIdentity.laneId); recoveredLaneIds.push(laneIdentity.laneId);
} }
} }
const directory: OpenCodeMemberDirectory = { config, teamMeta, metaMembers };
const configuredNames = new Set<string>();
for (const member of config?.members ?? []) {
if (member.name?.trim()) {
configuredNames.add(member.name.trim());
}
}
for (const member of metaMembers) {
if (member.name?.trim()) {
configuredNames.add(member.name.trim());
}
}
for (const memberName of configuredNames) {
const identity = this.resolveOpenCodeMemberIdentityFromDirectory(
teamName,
memberName,
directory
);
if (!identity.ok) {
continue;
}
if (currentLaneIndex?.lanes[identity.laneId] || recoveredLaneIds.includes(identity.laneId)) {
continue;
}
const recovered = await this.tryRecoverOpenCodeRuntimeLaneFromCommittedSessionBeforeDelivery({
teamName,
laneId: identity.laneId,
member: this.buildOpenCodeRecoveryMember({
canonicalMemberName: identity.canonicalMemberName,
configMember: identity.configMember,
metaMember: identity.metaMember,
}),
projectPath:
identity.memberRuntimeCwd ??
config?.projectPath?.trim() ??
this.readPersistedTeamProjectPath(teamName),
previousLaunchState: snapshot,
});
if (recovered) {
recoveredLaneIds.push(identity.laneId);
}
}
return [...new Set(recoveredLaneIds)]; return [...new Set(recoveredLaneIds)];
} }
@ -10964,6 +11280,7 @@ export class TeamProvisioningService {
const timer = this.openCodePromptDeliveryWatchdogTimers.get(key); const timer = this.openCodePromptDeliveryWatchdogTimers.get(key);
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
this.openCodePromptDeliveryWatchdogTimers.delete(key); this.openCodePromptDeliveryWatchdogTimers.delete(key);
this.openCodePromptDeliveryWatchdogDeadlines.delete(key);
} }
} }
for (let index = this.openCodePromptDeliveryWatchdogQueue.length - 1; index >= 0; index -= 1) { for (let index = this.openCodePromptDeliveryWatchdogQueue.length - 1; index >= 0; index -= 1) {
@ -12896,6 +13213,68 @@ export class TeamProvisioningService {
return null; return null;
} }
private buildOpenCodePromptDeliveryActiveBusyStatus(input: {
teamName: string;
memberName: string;
retryAfterIso: string;
activeRecord: OpenCodePromptDeliveryLedgerRecord;
}): {
busy: true;
reason: string;
retryAfterIso: string;
activeMessageId: string;
activeMessageKind: string | null;
} {
const nextAttemptMs = input.activeRecord.nextAttemptAt
? Date.parse(input.activeRecord.nextAttemptAt)
: NaN;
this.scheduleOpenCodeMemberInboxDeliveryWake({
teamName: input.teamName,
memberName: input.memberName,
messageId: input.activeRecord.inboxMessageId,
delayMs: Number.isFinite(nextAttemptMs) ? Math.max(500, nextAttemptMs - Date.now()) : 500,
});
return {
busy: true,
reason: `opencode_prompt_delivery_active:${input.activeRecord.messageKind ?? 'default'}`,
retryAfterIso: input.activeRecord.nextAttemptAt ?? input.retryAfterIso,
activeMessageId: input.activeRecord.inboxMessageId,
activeMessageKind: input.activeRecord.messageKind,
};
}
private async tryGetActiveOpenCodePromptDeliveryRecord(input: {
teamName: string;
memberName: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord | null> {
const identity = await this.resolveOpenCodeMemberDeliveryIdentity(
input.teamName,
input.memberName
).catch(() => null);
if (!identity?.ok) {
return null;
}
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), input.teamName).catch(
() => null
);
if (laneIndex?.lanes[identity.laneId]?.state !== 'active') {
const recovered = await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery({
teamName: input.teamName,
memberName: identity.canonicalMemberName,
});
if (!recovered) {
return null;
}
}
return await this.createOpenCodePromptDeliveryLedger(input.teamName, identity.laneId)
.getActiveForMember({
teamName: input.teamName,
memberName: identity.canonicalMemberName,
laneId: identity.laneId,
})
.catch(() => null);
}
async getOpenCodeMemberDeliveryBusyStatus(input: { async getOpenCodeMemberDeliveryBusyStatus(input: {
teamName: string; teamName: string;
memberName: string; memberName: string;
@ -12957,6 +13336,24 @@ export class TeamProvisioningService {
this.hasStableMessageId(message) this.hasStableMessageId(message)
); );
if (unreadForeground?.messageId) { if (unreadForeground?.messageId) {
const activeRecord = await this.tryGetActiveOpenCodePromptDeliveryRecord({
teamName: input.teamName,
memberName: input.memberName,
});
if (activeRecord) {
return this.buildOpenCodePromptDeliveryActiveBusyStatus({
teamName: input.teamName,
memberName: input.memberName,
retryAfterIso,
activeRecord,
});
}
this.scheduleOpenCodeMemberInboxDeliveryWake({
teamName: input.teamName,
memberName: input.memberName,
messageId: unreadForeground.messageId,
delayMs: 500,
});
return { return {
busy: true, busy: true,
reason: 'opencode_foreground_inbox_unread', reason: 'opencode_foreground_inbox_unread',
@ -13016,13 +13413,12 @@ export class TeamProvisioningService {
}; };
} }
if (activeRecord) { if (activeRecord) {
return { return this.buildOpenCodePromptDeliveryActiveBusyStatus({
busy: true, teamName: input.teamName,
reason: `opencode_prompt_delivery_active:${activeRecord.messageKind ?? 'default'}`, memberName: input.memberName,
retryAfterIso: activeRecord.nextAttemptAt ?? retryAfterIso, retryAfterIso,
activeMessageId: activeRecord.inboxMessageId, activeRecord,
activeMessageKind: activeRecord.messageKind, });
};
} }
return { busy: false }; return { busy: false };
@ -32263,6 +32659,7 @@ export class TeamProvisioningService {
const timer = this.openCodePromptDeliveryWatchdogTimers.get(key); const timer = this.openCodePromptDeliveryWatchdogTimers.get(key);
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
this.openCodePromptDeliveryWatchdogTimers.delete(key); this.openCodePromptDeliveryWatchdogTimers.delete(key);
this.openCodePromptDeliveryWatchdogDeadlines.delete(key);
} }
} }
for ( for (

View file

@ -175,6 +175,7 @@ interface CreateTaskDialogState {
} }
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000; const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
const MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200;
function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number { function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number {
if (!summary) { if (!summary) {
@ -1720,6 +1721,7 @@ export const TeamDetailView = memo(function TeamDetailView({
const tabId = useTabIdOptional(); const tabId = useTabIdOptional();
const isThisTabActive = isActive; const isThisTabActive = isActive;
const wasInteractiveRef = useRef(false); const wasInteractiveRef = useRef(false);
const memberRosterHydrationRetryRef = useRef<string | null>(null);
const loadingHeaderColorSet = useMemo( const loadingHeaderColorSet = useMemo(
() => () =>
teamSummaryColor teamSummaryColor
@ -2081,13 +2083,77 @@ export const TeamDetailView = memo(function TeamDetailView({
return filterKanbanTasks(filteredTasks, kanbanSearchQuery); return filterKanbanTasks(filteredTasks, kanbanSearchQuery);
}, [filteredTasks, kanbanSearchQuery]); }, [filteredTasks, kanbanSearchQuery]);
const resolvedActiveTeammateCount = useMemo(
() => activeMembers.filter((m) => !isLeadMember(m)).length,
[activeMembers]
);
const activeTeammateCount = useMemo(() => { const activeTeammateCount = useMemo(() => {
const resolvedCount = activeMembers.filter((m) => !isLeadMember(m)).length;
if (membersWithLiveBranches.some((m) => m.removedAt)) { if (membersWithLiveBranches.some((m) => m.removedAt)) {
return resolvedCount; return resolvedActiveTeammateCount;
} }
return resolvedCount > 0 ? resolvedCount : summaryKnownTeammateCount; return resolvedActiveTeammateCount > 0
}, [activeMembers, membersWithLiveBranches, summaryKnownTeammateCount]); ? resolvedActiveTeammateCount
: summaryKnownTeammateCount;
}, [membersWithLiveBranches, resolvedActiveTeammateCount, summaryKnownTeammateCount]);
const memberRosterHydrationRetryKey = useMemo(() => {
if (
!isThisTabActive ||
!teamName ||
!data ||
summaryKnownTeammateCount <= 0 ||
resolvedActiveTeammateCount > 0
) {
return null;
}
return [
teamName,
data.teamName,
data.members.length,
data.config.members?.length ?? 0,
data.config.sessionHistory?.join(',') ?? '',
summaryKnownTeammateCount,
loading ? 'loading' : 'settled',
isTeamProvisioning ? 'provisioning' : 'ready',
].join('|');
}, [
data,
isTeamProvisioning,
isThisTabActive,
loading,
resolvedActiveTeammateCount,
summaryKnownTeammateCount,
teamName,
]);
useEffect(() => {
if (!memberRosterHydrationRetryKey) {
return;
}
if (memberRosterHydrationRetryRef.current === memberRosterHydrationRetryKey) {
return;
}
const timer = window.setTimeout(() => {
const state = useStore.getState();
if (state.selectedTeamName !== teamName) {
return;
}
const currentMembers = selectResolvedMembersForTeamName(state, teamName);
const hasResolvedTeammate = currentMembers.some(
(member) => !member.removedAt && !isLeadMember(member)
);
const expectedTeammateCount = getSummaryKnownTeammateCount(state.teamByName[teamName]);
if (!hasResolvedTeammate && expectedTeammateCount > 0) {
memberRosterHydrationRetryRef.current = memberRosterHydrationRetryKey;
void refreshTeamData(teamName, { withDedup: false });
}
}, MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS);
return () => window.clearTimeout(timer);
}, [memberRosterHydrationRetryKey, refreshTeamData, teamName]);
const leadProviderId = useMemo<TeamProviderId | undefined>(() => { const leadProviderId = useMemo<TeamProviderId | undefined>(() => {
const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId;
if (activeLeadProviderId) return activeLeadProviderId; if (activeLeadProviderId) return activeLeadProviderId;

View file

@ -12,7 +12,12 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import {
getTeamColorSet,
getThemedBadge,
getThemedBorder,
type TeamColorSet,
} from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -25,13 +30,27 @@ import {
getWorktreeNavigationState, getWorktreeNavigationState,
} from '@renderer/store/utils/stateResetHelpers'; } from '@renderer/store/utils/stateResetHelpers';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import {
buildTaskCountsByTeam,
normalizePath,
type TaskStatusCounts,
} from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils'; import { getBaseName } from '@renderer/utils/pathUtils';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy'; import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus'; import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { Copy, FolderOpen, GitBranch, Play, RotateCcw, Search, Square, Trash2 } from 'lucide-react'; import {
Copy,
FolderOpen,
GitBranch,
Play,
RotateCcw,
Search,
Square,
Trash2,
UsersRound,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { TeamEmptyState } from './TeamEmptyState'; import { TeamEmptyState } from './TeamEmptyState';
@ -133,23 +152,28 @@ function renderTeamRecentPaths(
team: TeamSummary, team: TeamSummary,
status: TeamStatus, status: TeamStatus,
matchesCurrentProject: boolean, matchesCurrentProject: boolean,
isLight: boolean isLight: boolean,
selectedProjectPath: string | null
): React.JSX.Element | null { ): React.JSX.Element | null {
const recentPaths = getRecentProjects(team); const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null; const visibleRecentPaths =
matchesCurrentProject && selectedProjectPath
? recentPaths.filter((path) => normalizePath(path) !== normalizePath(selectedProjectPath))
: recentPaths;
if (visibleRecentPaths.length === 0) return null;
return ( return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{matchesCurrentProject ? ( {matchesCurrentProject && !selectedProjectPath ? (
<span <span
className={`inline-flex items-center gap-1 truncate rounded-full px-2 py-0.5 text-[12px] font-medium ${ className={`inline-flex items-center gap-1 truncate rounded-full px-2 py-0.5 text-[12px] font-medium ${
isLight ? 'bg-emerald-100 text-emerald-700' : 'bg-emerald-500/15 text-emerald-400' isLight ? 'bg-emerald-100 text-emerald-700' : 'bg-emerald-500/15 text-emerald-400'
}`} }`}
> >
<FolderOpen size={12} className="shrink-0" /> <FolderOpen size={12} className="shrink-0" />
{recentPaths.map((p, i) => ( {visibleRecentPaths.map((p, i) => (
<span key={p} title={p}> <span key={p} title={p}>
{folderName(p)} {folderName(p)}
{i < recentPaths.length - 1 ? ', ' : ''} {i < visibleRecentPaths.length - 1 ? ', ' : ''}
</span> </span>
))} ))}
</span> </span>
@ -157,14 +181,14 @@ function renderTeamRecentPaths(
<> <>
<FolderOpen size={10} className="shrink-0" /> <FolderOpen size={10} className="shrink-0" />
<span className="truncate"> <span className="truncate">
{recentPaths.map((p, i) => ( {visibleRecentPaths.map((p, i) => (
<span key={p} title={p}> <span key={p} title={p}>
{i === 0 && (status === 'active' || status === 'idle') ? ( {i === 0 && (status === 'active' || status === 'idle') ? (
<span className="text-emerald-400">{folderName(p)}</span> <span className="text-emerald-400">{folderName(p)}</span>
) : ( ) : (
folderName(p) folderName(p)
)} )}
{i < recentPaths.length - 1 ? ', ' : ''} {i < visibleRecentPaths.length - 1 ? ', ' : ''}
</span> </span>
))} ))}
</span> </span>
@ -228,6 +252,213 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
} }
}; };
interface ActiveTeamCardProps {
team: TeamSummary;
status: TeamStatus;
teamColorSet: TeamColorSet;
isLight: boolean;
matchesCurrentProject: boolean;
currentProjectPath: string | null;
branchName?: string;
taskCounts?: TaskStatusCounts;
launchingTeamName: string | null;
stoppingTeamName: string | null;
onOpenTeam: (teamName: string, projectPath?: string) => void;
onLaunchTeam: (
teamName: string,
projectPath: string | undefined,
event: React.MouseEvent
) => void;
onStopTeam: (teamName: string, event: React.MouseEvent) => void;
onCopyTeam: (teamName: string, event: React.MouseEvent) => void;
onDeleteTeam: (teamName: string, pendingCreate: boolean, event: React.MouseEvent) => void;
}
const ActiveTeamCard = ({
team,
status,
teamColorSet,
isLight,
matchesCurrentProject,
currentProjectPath,
branchName,
taskCounts,
launchingTeamName,
stoppingTeamName,
onOpenTeam,
onLaunchTeam,
onStopTeam,
onCopyTeam,
onDeleteTeam,
}: Readonly<ActiveTeamCardProps>): React.JSX.Element => {
const canLaunch =
(status === 'offline' ||
status === 'partial_failure' ||
status === 'partial_skipped' ||
status === 'partial_pending') &&
Boolean(team.projectPath);
return (
<div
role="button"
tabIndex={0}
className="team-row-zebra-card group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-[var(--color-border)] p-4 transition-colors duration-200 hover:border-[var(--color-border-emphasis)]"
onClick={() => onOpenTeam(team.teamName, team.projectPath ?? undefined)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpenTeam(team.teamName, team.projectPath ?? undefined);
}
}}
>
<div className="pointer-events-none absolute right-4 top-4 z-10">
<StatusBadge status={status} />
</div>
<div className="flex flex-1 flex-col">
<div className="space-y-2">
<div className="flex items-start gap-2.5 pr-44">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] transition-colors group-hover:border-[var(--color-border-emphasis)]">
<UsersRound
className="size-4 transition-colors"
style={{ color: getThemedBorder(teamColorSet, isLight) }}
/>
</div>
<h3 className="min-w-0 flex-1 break-words text-sm font-semibold leading-snug text-[var(--color-text)]">
{team.displayName}
</h3>
</div>
<div className="flex min-h-6 items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{branchName ? (
<span
className="flex max-w-full items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branchName}
>
<GitBranch size={10} className="shrink-0" />
<span className="truncate">{branchName}</span>
</span>
) : null}
</div>
<div className="flex shrink-0 gap-1">
{canLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(event) =>
onLaunchTeam(team.teamName, team.projectPath ?? undefined, event)
}
disabled={launchingTeamName === team.teamName}
aria-label="Launch team"
>
<Play size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
</TooltipContent>
</Tooltip>
) : null}
{status === 'active' || status === 'idle' ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(event) => onStopTeam(team.teamName, event)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
) : null}
{!team.pendingCreate ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(event) => onCopyTeam(team.teamName, event)}
>
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(event) => onDeleteTeam(team.teamName, !!team.pendingCreate, event)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div className="mt-2 flex min-h-10 items-start gap-2">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
</div>
{team.teamLaunchState === 'partial_pending' ? (
<p className="mt-2 text-[11px] text-amber-300">
{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
? buildPendingRuntimeSummaryCopy({
confirmedCount: team.confirmedCount,
expectedMemberCount: team.expectedMemberCount,
memberCount: team.memberCount,
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
includePeriod: true,
})
: 'Last launch is still reconciling.'}
</p>
) : team.partialLaunchFailure || team.teamLaunchState === 'partial_failure' ? (
<p className="mt-2 text-[11px] text-amber-400">
{team.missingMembers?.length
? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
: 'Last launch stopped before all teammates joined.'}
</p>
) : team.teamLaunchState === 'partial_skipped' ? (
<p className="mt-2 text-[11px] text-sky-300">
{team.skippedMembers?.length
? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.`
: 'Last launch has skipped teammates.'}
</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-2">
{team.members && team.members.length > 0 ? (
renderMemberChips(team.members, isLight)
) : team.memberCount === 0 ? (
<Badge variant="secondary" className="text-[10px] font-normal">
Solo
</Badge>
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
</div>
<div className="mt-auto">
<TeamTaskStatusSummary counts={taskCounts} />
{renderTeamRecentPaths(team, status, matchesCurrentProject, isLight, currentProjectPath)}
</div>
</div>
</div>
);
};
export const TeamListView = memo(function TeamListView(): React.JSX.Element { export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const { isLight } = useTheme(); const { isLight } = useTheme();
const electronMode = isElectronMode(); const electronMode = isElectronMode();
@ -862,186 +1093,88 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const activeFiltered = filteredTeams.filter((t) => !t.deletedAt); const activeFiltered = filteredTeams.filter((t) => !t.deletedAt);
const deletedFiltered = filteredTeams.filter((t) => t.deletedAt); const deletedFiltered = filteredTeams.filter((t) => t.deletedAt);
const activeSections = currentProjectPath
? [
{
key: 'project',
title: `Teams for ${folderName(currentProjectPath) || 'selected project'}`,
teams: activeFiltered.filter((team) =>
teamMatchesProjectSelection(team, currentProjectPath)
),
},
{
key: 'other',
title: 'Other teams',
teams: activeFiltered.filter(
(team) => !teamMatchesProjectSelection(team, currentProjectPath)
),
},
].filter((section) => section.teams.length > 0)
: [
{
key: 'all',
title: null,
teams: activeFiltered,
},
];
return ( return (
<> <>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> {activeSections.map((section, sectionIndex) => (
{activeFiltered.map((team) => { <section key={section.key} className={sectionIndex > 0 ? 'mt-6' : undefined}>
const status = resolveTeamStatus( {section.title ? (
team, <div className="mb-2 flex items-center gap-2">
team.teamName, <h3 className="text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
aliveTeams, {section.title}
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), </h3>
leadActivityByTeam <span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--color-text-secondary)]">
); {section.teams.length}
const teamColorSet = team.color </span>
? getTeamColorSet(team.color)
: nameColorSet(team.displayName);
const matchesCurrentProject = currentProjectPath
? teamMatchesProjectSelection(team, currentProjectPath)
: false;
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={teamColorSet ? { borderLeftColor: teamColorSet.border } : undefined}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
}
}}
>
<div className="flex flex-1 flex-col">
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
{team.projectPath &&
(() => {
const branch = branchByPath[normalizePath(team.projectPath)];
if (!branch) return null;
return (
<span
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branch}
>
<GitBranch size={10} />
<span className="max-w-24 truncate">{branch}</span>
</span>
);
})()}
</div>
<div className="flex shrink-0 gap-1">
{(status === 'offline' ||
status === 'partial_failure' ||
status === 'partial_skipped' ||
status === 'partial_pending') &&
team.projectPath && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) =>
handleLaunchTeam(team.teamName, team.projectPath, e)
}
disabled={launchingTeamName === team.teamName}
aria-label="Launch team"
>
<Play size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
</TooltipContent>
</Tooltip>
)}
{(status === 'active' || status === 'idle') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
)}
{!team.pendingCreate && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
>
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) =>
handleDeleteTeam(team.teamName, !!team.pendingCreate, e)
}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
<div className="mt-2 flex min-h-10 items-start gap-2">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
</div>
{team.teamLaunchState === 'partial_pending' ? (
<p className="mt-2 text-[11px] text-amber-300">
{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
? buildPendingRuntimeSummaryCopy({
confirmedCount: team.confirmedCount,
expectedMemberCount: team.expectedMemberCount,
memberCount: team.memberCount,
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
includePeriod: true,
})
: 'Last launch is still reconciling.'}
</p>
) : team.partialLaunchFailure || team.teamLaunchState === 'partial_failure' ? (
<p className="mt-2 text-[11px] text-amber-400">
{team.missingMembers?.length
? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
: 'Last launch stopped before all teammates joined.'}
</p>
) : team.teamLaunchState === 'partial_skipped' ? (
<p className="mt-2 text-[11px] text-sky-300">
{team.skippedMembers?.length
? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.`
: 'Last launch has skipped teammates.'}
</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
renderMemberChips(team.members, isLight)
) : team.memberCount === 0 ? (
<Badge variant="secondary" className="text-[10px] font-normal">
Solo
</Badge>
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
</div>
<div className="mt-auto">
<TeamTaskStatusSummary counts={taskCountsByTeam.get(team.teamName)} />
{renderTeamRecentPaths(team, status, matchesCurrentProject, isLight)}
</div>
</div>
</div> </div>
); ) : null}
})} <div className="team-row-zebra-grid grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
</div> {section.teams.map((team) => {
const status = resolveTeamStatus(
team,
team.teamName,
aliveTeams,
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
leadActivityByTeam
);
const teamColorSet = team.color
? getTeamColorSet(team.color)
: nameColorSet(team.displayName);
const matchesCurrentProject = currentProjectPath
? teamMatchesProjectSelection(team, currentProjectPath)
: false;
return (
<ActiveTeamCard
key={team.teamName}
team={team}
status={status}
teamColorSet={teamColorSet}
isLight={isLight}
matchesCurrentProject={matchesCurrentProject}
currentProjectPath={currentProjectPath}
branchName={
team.projectPath
? (branchByPath[normalizePath(team.projectPath)] ?? undefined)
: undefined
}
taskCounts={taskCountsByTeam.get(team.teamName)}
launchingTeamName={launchingTeamName}
stoppingTeamName={stoppingTeamName}
onOpenTeam={openTeamTab}
onLaunchTeam={handleLaunchTeam}
onStopTeam={handleStopTeam}
onCopyTeam={handleCopyTeam}
onDeleteTeam={handleDeleteTeam}
/>
);
})}
</div>
</section>
))}
{deletedFiltered.length > 0 && ( {deletedFiltered.length > 0 && (
<> <>
@ -1105,7 +1238,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
{team.description || 'No description'} {team.description || 'No description'}
</p> </p>
{team.members && team.members.length > 0 && ( {team.members && team.members.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5"> <div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-2">
{renderMemberChips(team.members, isLight)} {renderMemberChips(team.members, isLight)}
</div> </div>
)} )}

View file

@ -15,7 +15,7 @@ import {
} from '@renderer/utils/messageRenderEquality'; } from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Layers } from 'lucide-react'; import { Layers, Loader2 } from 'lucide-react';
import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext'; import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext';
@ -126,6 +126,8 @@ interface ActivityTimelineProps {
onExpandItem?: (key: string) => void; onExpandItem?: (key: string) => void;
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */ /** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
onExpandContent?: () => void; onExpandContent?: () => void;
/** True while the initial message page is loading and no cached rows are available yet. */
loading?: boolean;
/** /**
* Optional viewport contract. When provided, IntersectionObserver uses the * Optional viewport contract. When provided, IntersectionObserver uses the
* passed `observerRoot` instead of the document viewport, which is required * passed `observerRoot` instead of the document viewport, which is required
@ -167,6 +169,31 @@ const ROW_SIZE_ESTIMATES: Record<TimelineRow['kind'], number> = {
'message-row': 140, 'message-row': 140,
}; };
const TimelineLoadingState = (): React.JSX.Element => (
<div
className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]"
aria-busy="true"
aria-live="polite"
>
<div className="flex items-center gap-2">
<Loader2 size={13} className="animate-spin" />
<span>Loading messages...</span>
</div>
<div className="mt-3 space-y-2" aria-hidden="true">
<div className="h-3 w-3/4 animate-pulse rounded bg-[var(--color-surface-raised)]" />
<div className="h-3 w-1/2 animate-pulse rounded bg-[var(--color-surface-raised)]" />
<div className="h-3 w-2/3 animate-pulse rounded bg-[var(--color-surface-raised)]" />
</div>
</div>
);
const TimelineEmptyState = (): React.JSX.Element => (
<div className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]">
<p>No messages</p>
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
</div>
);
function collectScrollMarginObserverTargets( function collectScrollMarginObserverTargets(
rootElement: HTMLElement, rootElement: HTMLElement,
scrollElement: HTMLElement scrollElement: HTMLElement
@ -419,6 +446,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
onTeamClick, onTeamClick,
onExpandItem, onExpandItem,
onExpandContent, onExpandContent,
loading = false,
viewport, viewport,
}: ActivityTimelineProps): React.JSX.Element { }: ActivityTimelineProps): React.JSX.Element {
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef; const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
@ -858,9 +886,8 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]"> <div ref={rootRef} className="space-y-1">
<p>No messages</p> {loading ? <TimelineLoadingState /> : <TimelineEmptyState />}
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
</div> </div>
); );
} }

View file

@ -451,6 +451,78 @@ const MemberCardRow = memo(function MemberCardRow({
); );
}); });
const MEMBER_LOADING_ACCENTS = ['#46d93b', '#3b82f6', '#facc15', '#14b8a6', '#ef4444'];
function getMemberLoadingSkeletonCount(expectedTeammateCount: number | undefined): number {
if (!Number.isFinite(expectedTeammateCount) || !expectedTeammateCount) {
return 3;
}
return Math.min(Math.max(1, Math.floor(expectedTeammateCount)), MEMBER_LOADING_ACCENTS.length);
}
const MemberListLoadingSkeleton = ({
expectedTeammateCount,
}: Readonly<{
expectedTeammateCount?: number;
}>): React.JSX.Element => {
const skeletonCount = getMemberLoadingSkeletonCount(expectedTeammateCount);
return (
<div
className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-sidebar)] p-3"
role="status"
aria-label="Loading team members"
>
<div className="grid grid-cols-1 gap-1">
{Array.from({ length: skeletonCount }, (_, index) => {
const accent = MEMBER_LOADING_ACCENTS[index] ?? MEMBER_LOADING_ACCENTS[0];
return (
<div key={index} className="flex min-h-[52px] min-w-0 items-center gap-3">
<div className="relative size-7 shrink-0">
<div
className="absolute inset-0 rounded-full border-2 bg-[var(--color-surface-raised)]"
style={{ borderColor: accent }}
/>
<div
className="absolute bottom-0 right-0 size-2 rounded-full border border-[var(--color-surface)]"
style={{ backgroundColor: accent }}
/>
</div>
<div className="min-w-0 flex-1">
<div
className="skeleton-shimmer h-4 rounded-sm"
style={{
width: index % 2 === 0 ? '3.5rem' : '4.25rem',
backgroundColor: 'var(--skeleton-base)',
}}
/>
<div
className="skeleton-shimmer mt-1.5 h-2.5 rounded-sm"
style={{
width: index % 3 === 0 ? '13rem' : index % 3 === 1 ? '15rem' : '11rem',
maxWidth: '76%',
backgroundColor: 'var(--skeleton-base-dim)',
}}
/>
</div>
<div className="hidden shrink-0 items-center gap-3 sm:flex">
<div
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border border-[var(--color-border)]"
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
/>
<div
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border border-[var(--color-border)]"
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
/>
</div>
</div>
);
})}
</div>
</div>
);
};
export const MemberList = memo(function MemberList({ export const MemberList = memo(function MemberList({
teamName = '__unknown_team__', teamName = '__unknown_team__',
members, members,
@ -657,11 +729,7 @@ export const MemberList = memo(function MemberList({
if (members.length === 0 || hasOnlyLeadWhileTeammatesLoad) { if (members.length === 0 || hasOnlyLeadWhileTeammatesLoad) {
if (expectsTeammates) { if (expectsTeammates) {
return ( return <MemberListLoadingSkeleton expectedTeammateCount={expectedTeammateCount} />;
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Team members are loading
</div>
);
} }
return ( return (

View file

@ -243,6 +243,7 @@ const MessagesTimelineSection = memo(function MessagesTimelineSection({
expandedItemKey, expandedItemKey,
onExpandDialogChange, onExpandDialogChange,
messages, messages,
loading,
teamName, teamName,
members, members,
readState, readState,
@ -270,6 +271,7 @@ const MessagesTimelineSection = memo(function MessagesTimelineSection({
<> <>
<ActivityTimeline <ActivityTimeline
messages={messages} messages={messages}
loading={loading}
teamName={teamName} teamName={teamName}
members={members} members={members}
readState={readState} readState={readState}
@ -399,6 +401,8 @@ export const MessagesPanel = memo(function MessagesPanel({
const loadingOlderMessages = messagesState?.loadingOlder ?? false; const loadingOlderMessages = messagesState?.loadingOlder ?? false;
const hasMore = messagesState?.hasMore ?? false; const hasMore = messagesState?.hasMore ?? false;
const effectiveMessages = messages; const effectiveMessages = messages;
const loadingInitialMessages =
effectiveMessages.length === 0 && (messagesState === undefined || messagesState.loadingHead);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null); const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null); const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
@ -906,6 +910,7 @@ export const MessagesPanel = memo(function MessagesPanel({
const timelineSection = ( const timelineSection = (
<MessagesTimelineSection <MessagesTimelineSection
messages={activityTimelineMessages} messages={activityTimelineMessages}
loading={loadingInitialMessages}
teamName={teamName} teamName={teamName}
members={members} members={members}
readState={readState} readState={readState}

View file

@ -1435,6 +1435,64 @@ a[href],
inset 0 1px 0 rgba(255, 255, 255, 0.03); inset 0 1px 0 rgba(255, 255, 255, 0.03);
} }
.team-row-zebra-card,
.project-row-zebra-card {
--row-card-bg: color-mix(in srgb, var(--color-surface) 50%, var(--color-surface-raised) 50%);
--row-zebra-bg: color-mix(in srgb, var(--color-surface-raised) 96%, var(--color-text) 4%);
--row-zebra-hover-bg: color-mix(in srgb, var(--row-zebra-bg) 97%, var(--color-text) 3%);
background-color: var(--row-card-bg);
}
.team-row-zebra-card:hover,
.project-row-zebra-card:hover {
background-color: var(--row-zebra-hover-bg) !important;
}
@media (max-width: 767.98px) {
.team-row-zebra-grid > .team-row-zebra-card:nth-child(even) {
background-color: var(--row-zebra-bg);
}
}
@media (min-width: 768px) and (max-width: 1279.98px) {
.team-row-zebra-grid > .team-row-zebra-card:nth-child(4n + 3),
.team-row-zebra-grid > .team-row-zebra-card:nth-child(4n + 4) {
background-color: var(--row-zebra-bg);
}
}
@media (min-width: 1280px) {
.team-row-zebra-grid > .team-row-zebra-card:nth-child(6n + 4),
.team-row-zebra-grid > .team-row-zebra-card:nth-child(6n + 5),
.team-row-zebra-grid > .team-row-zebra-card:nth-child(6n + 6) {
background-color: var(--row-zebra-bg);
}
}
@media (max-width: 1023.98px) {
.project-row-zebra-grid > .project-row-zebra-card:nth-child(4n + 3),
.project-row-zebra-grid > .project-row-zebra-card:nth-child(4n + 4) {
background-color: var(--row-zebra-bg);
}
}
@media (min-width: 1024px) and (max-width: 1279.98px) {
.project-row-zebra-grid > .project-row-zebra-card:nth-child(6n + 4),
.project-row-zebra-grid > .project-row-zebra-card:nth-child(6n + 5),
.project-row-zebra-grid > .project-row-zebra-card:nth-child(6n + 6) {
background-color: var(--row-zebra-bg);
}
}
@media (min-width: 1280px) {
.project-row-zebra-grid > .project-row-zebra-card:nth-child(8n + 5),
.project-row-zebra-grid > .project-row-zebra-card:nth-child(8n + 6),
.project-row-zebra-grid > .project-row-zebra-card:nth-child(8n + 7),
.project-row-zebra-grid > .project-row-zebra-card:nth-child(8n + 8) {
background-color: var(--row-zebra-bg);
}
}
.message-composer-orbit-svg { .message-composer-orbit-svg {
overflow: visible; overflow: visible;
} }

View file

@ -4327,10 +4327,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
queuedFullTeamDataRefreshesAfterThin.delete(teamName); queuedFullTeamDataRefreshesAfterThin.delete(teamName);
const isProvisioning = isTeamProvisioningActive(currentState, teamName); const isProvisioning = isTeamProvisioningActive(currentState, teamName);
const existingSelectedTeamData =
currentState.selectedTeamData?.teamName === teamName ? currentState.selectedTeamData : null;
const msg = error instanceof Error ? error.message : String(error); const msg = error instanceof Error ? error.message : String(error);
// IPC can report provisioning state explicitly. // IPC can report provisioning state explicitly.
if (msg === 'TEAM_PROVISIONING' || (msg.includes('TEAM_PROVISIONING') && isProvisioning)) { if (msg === 'TEAM_PROVISIONING' || (msg.includes('TEAM_PROVISIONING') && isProvisioning)) {
if (existingSelectedTeamData) {
set({
selectedTeamLoading: false,
selectedTeamData: existingSelectedTeamData,
selectedTeamError: null,
});
return;
}
set({ set({
selectedTeamLoading: true, selectedTeamLoading: true,
selectedTeamData: null, selectedTeamData: null,
@ -4355,6 +4365,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
: error instanceof Error : error instanceof Error
? error.message ? error.message
: 'Failed to fetch team data'; : 'Failed to fetch team data';
if (existingSelectedTeamData) {
set({
selectedTeamLoading: false,
selectedTeamData: existingSelectedTeamData,
selectedTeamError: null,
});
return;
}
set({ set({
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamData: null, selectedTeamData: null,

View file

@ -11956,6 +11956,130 @@ describe('Team agent launch matrix safe e2e', () => {
]); ]);
}); });
it('recovers a missing mixed OpenCode lane index from committed session evidence before watchdog scans unread inbox', async () => {
const teamName = 'mixed-opencode-watchdog-recovers-session-evidence-safe-e2e';
const laneId = 'secondary:opencode:bob';
const runId = 'session-evidence-opencode-run';
await writeMixedTeamConfig({ teamName, projectPath });
await writeTeamMeta(teamName, projectPath);
await writeMembersMeta(teamName);
await writeOpenCodeBootstrapSessionEvidenceForTest({
teamName,
laneId,
runId,
memberName: 'bob',
sessionId: 'ses_bob_committed_session_only',
});
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
await fs.mkdir(inboxDir, { recursive: true });
await fs.writeFile(
path.join(inboxDir, 'bob.json'),
`${JSON.stringify(
[
{
from: 'user',
to: 'bob',
text: 'recover this unread OpenCode message from committed session evidence',
timestamp: '2026-04-23T10:01:00.000Z',
read: false,
messageId: 'msg-watchdog-recovers-session-evidence-bob',
},
],
null,
2
)}\n`,
'utf8'
);
const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { bob: 'confirmed' });
const restartedService = new TeamProvisioningService();
restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const scheduledWatchdogJobs: unknown[] = [];
(restartedService as any).scheduleOpenCodePromptDeliveryWatchdog = (input: unknown): void => {
scheduledWatchdogJobs.push(input);
};
await expect(
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
).resolves.toMatchObject({ lanes: {} });
await expect(restartedService.scanOpenCodePromptDeliveryWatchdog(teamName)).resolves.toBe(1);
expect(adapter.reconcileInputs).toHaveLength(1);
expect(adapter.reconcileInputs[0]).toMatchObject({
teamName,
laneId,
reason: 'startup_recovery',
});
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
{
lanes: {
[laneId]: {
state: 'active',
diagnostics: expect.arrayContaining([
'Recovered missing OpenCode runtime lane index from committed session evidence.',
]),
},
},
}
);
expect(scheduledWatchdogJobs).toEqual([
expect.objectContaining({
teamName,
memberName: 'bob',
messageId: 'msg-watchdog-recovers-session-evidence-bob',
delayMs: 500,
}),
]);
});
it('does not recover committed OpenCode session evidence when the parent process registry is explicitly stopped', async () => {
const teamName = 'mixed-opencode-watchdog-stopped-session-evidence-safe-e2e';
const laneId = 'secondary:opencode:bob';
const runId = 'stopped-session-evidence-opencode-run';
await writeMixedTeamConfig({ teamName, projectPath });
await writeTeamMeta(teamName, projectPath);
await writeMembersMeta(teamName);
await writeOpenCodeBootstrapSessionEvidenceForTest({
teamName,
laneId,
runId,
memberName: 'bob',
sessionId: 'ses_bob_stopped_committed_session',
});
await writeStoppedProcessRegistry(teamName);
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
await fs.mkdir(inboxDir, { recursive: true });
await fs.writeFile(
path.join(inboxDir, 'bob.json'),
`${JSON.stringify(
[
{
from: 'user',
to: 'bob',
text: 'must not recover this stopped OpenCode message',
timestamp: '2026-04-23T10:01:00.000Z',
read: false,
messageId: 'msg-watchdog-stopped-session-evidence-bob',
},
],
null,
2
)}\n`,
'utf8'
);
const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { bob: 'confirmed' });
const restartedService = new TeamProvisioningService();
restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
await expect(restartedService.scanOpenCodePromptDeliveryWatchdog(teamName)).resolves.toBe(0);
expect(adapter.reconcileInputs).toEqual([]);
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
{
lanes: {},
}
);
});
it('recovers one missing mixed OpenCode lane before watchdog scans while sibling lane is active', async () => { it('recovers one missing mixed OpenCode lane before watchdog scans while sibling lane is active', async () => {
const teamName = 'mixed-opencode-watchdog-recovers-one-missing-lane-safe-e2e'; const teamName = 'mixed-opencode-watchdog-recovers-one-missing-lane-safe-e2e';
await writeMixedTeamConfig({ teamName, projectPath }); await writeMixedTeamConfig({ teamName, projectPath });
@ -12628,6 +12752,53 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs[0]?.runId).toBeUndefined(); expect(adapter.messageInputs[0]?.runId).toBeUndefined();
}); });
it('delivers direct OpenCode member messages after recovering a missing mixed lane from committed session evidence', async () => {
const teamName = 'mixed-opencode-direct-message-committed-session-recovery-safe-e2e';
const laneId = 'secondary:opencode:bob';
const runId = 'committed-session-direct-opencode-run';
await writeMixedTeamConfig({ teamName, projectPath });
await writeTeamMeta(teamName, projectPath);
await writeMembersMeta(teamName);
await writeOpenCodeBootstrapSessionEvidenceForTest({
teamName,
laneId,
runId,
memberName: 'bob',
sessionId: 'ses_bob_direct_committed_session',
});
const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { bob: 'confirmed' });
const restartedService = new TeamProvisioningService();
restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
await expect(
restartedService.deliverOpenCodeMemberMessage(teamName, {
memberName: 'bob',
text: 'message recovered from committed session evidence',
messageId: 'msg-recovered-committed-session-mixed-opencode',
})
).resolves.toEqual({
delivered: true,
diagnostics: [],
});
expect(adapter.reconcileInputs).toHaveLength(1);
expect(adapter.reconcileInputs[0]).toMatchObject({
teamName,
laneId,
reason: 'startup_recovery',
});
expect(adapter.reconcileInputs[0]?.runId).toEqual(expect.any(String));
expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({
runId,
teamName,
laneId,
memberName: 'bob',
cwd: projectPath,
text: 'message recovered from committed session evidence',
messageId: 'msg-recovered-committed-session-mixed-opencode',
});
});
it('does not deliver direct OpenCode member messages to a removed mixed teammate despite stale active lane index after service restart', async () => { it('does not deliver direct OpenCode member messages to a removed mixed teammate despite stale active lane index after service restart', async () => {
const teamName = 'mixed-opencode-direct-message-removed-stale-index-safe-e2e'; const teamName = 'mixed-opencode-direct-message-removed-stale-index-safe-e2e';
await writeMixedTeamConfig({ teamName, projectPath, removedMembers: ['bob'] }); await writeMixedTeamConfig({ teamName, projectPath, removedMembers: ['bob'] });
@ -17327,6 +17498,9 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
providerId: 'opencode', providerId: 'opencode',
memberName: input.memberName, memberName: input.memberName,
sessionId: `session-${input.memberName}`, sessionId: `session-${input.memberName}`,
runtimePromptMessageId: input.messageId
? `prompt-${input.messageId}`
: `prompt-${input.memberName}-${this.messageInputs.length}`,
runtimePid: 12_000 + this.messageInputs.length, runtimePid: 12_000 + this.messageInputs.length,
diagnostics: [], diagnostics: [],
}; };
@ -18032,6 +18206,28 @@ async function writeAliveProcessRegistry(teamName: string): Promise<void> {
); );
} }
async function writeStoppedProcessRegistry(teamName: string): Promise<void> {
const teamDir = path.join(getTeamsBasePath(), teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'processes.json'),
`${JSON.stringify(
[
{
id: 'lead-process',
label: 'Team Lead',
pid: 987_654,
registeredAt: '2026-04-23T10:00:00.000Z',
stoppedAt: '2026-04-23T10:05:00.000Z',
},
],
null,
2
)}\n`,
'utf8'
);
}
function expectDirectChildKillCount(actual: number, expected: number): void { function expectDirectChildKillCount(actual: number, expected: number): void {
// Windows uses taskkill.exe for process-tree termination, so fake child.kill is not called. // Windows uses taskkill.exe for process-tree termination, so fake child.kill is not called.
expect(actual).toBe(process.platform === 'win32' ? 0 : expected); expect(actual).toBe(process.platform === 'win32' ? 0 : expected);

View file

@ -183,7 +183,9 @@ function createResolverBackedService(): TeamDataService {
); );
} }
function createLeadSessionCachingService(): TeamDataService { function createLeadSessionCachingService(
configOverrides: Partial<TeamConfig> = {}
): TeamDataService {
return new TeamDataService( return new TeamDataService(
{ {
listTeams: vi.fn(), listTeams: vi.fn(),
@ -191,6 +193,7 @@ function createLeadSessionCachingService(): TeamDataService {
name: 'My team', name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }], members: [{ name: 'team-lead', role: 'Lead' }],
leadSessionId: 'lead-1', leadSessionId: 'lead-1',
...configOverrides,
})), })),
} as never, } as never,
{ {
@ -4709,6 +4712,247 @@ describe('TeamDataService', () => {
expect(secondSpy).toHaveBeenCalledTimes(1); expect(secondSpy).toHaveBeenCalledTimes(1);
}); });
it('uses live base context for lead_session messages without full transcript discovery', async () => {
const service = createLeadSessionCachingService();
const projectResolver = {
getLiveBaseContext: vi.fn(() =>
Promise.resolve({
projectDir: '/fast-project',
projectId: 'fast-project',
config: {
name: 'My team',
members: [{ name: 'fast-lead', agentType: 'lead' }],
leadSessionId: 'lead-1',
},
})
),
getContext: vi.fn(() => {
return Promise.reject(new Error('full transcript discovery should not be used'));
}),
};
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockResolvedValue(
new Map([['lead-1', '/fast-project/lead-1.jsonl']])
);
vi.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never).mockResolvedValue([
{
from: 'fast-lead',
text: 'Fast path recovered lead thought from the known lead session.',
timestamp: '2026-04-18T10:00:00.000Z',
read: true,
source: 'lead_session',
leadSessionId: 'lead-1',
messageId: 'lead-fast-1',
},
]);
const feed = await service.getMessageFeed('my-team');
expect(projectResolver.getLiveBaseContext).toHaveBeenCalledWith('my-team');
expect(projectResolver.getContext).not.toHaveBeenCalled();
expect(feed.messages.some((message) => message.messageId === 'lead-fast-1')).toBe(true);
});
it('falls back to lightweight transcript context when live base context lacks the lead session file', async () => {
const service = createLeadSessionCachingService();
const projectResolver = {
getLiveBaseContext: vi.fn(() =>
Promise.resolve({
projectDir: '/stale-project',
projectId: 'stale-project',
config: {
name: 'My team',
members: [{ name: 'stale-lead', agentType: 'lead' }],
leadSessionId: 'lead-1',
},
})
),
getContext: vi.fn(() =>
Promise.resolve({
projectDir: '/actual-project',
projectId: 'actual-project',
config: {
name: 'My team',
members: [{ name: 'actual-lead', agentType: 'lead' }],
leadSessionId: 'lead-1',
},
sessionIds: ['lead-1'],
})
),
};
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockImplementation(
(...args: unknown[]) => {
const [projectDir] = args as [string];
if (projectDir === '/actual-project') {
return Promise.resolve(new Map([['lead-1', '/actual-project/lead-1.jsonl']]));
}
return Promise.resolve(new Map());
}
);
vi.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never).mockResolvedValue([
{
from: 'actual-lead',
text: 'Fallback path recovered lead thought from the repaired context.',
timestamp: '2026-04-18T10:00:00.000Z',
read: true,
source: 'lead_session',
leadSessionId: 'lead-1',
messageId: 'lead-fallback-1',
},
]);
const feed = await service.getMessageFeed('my-team');
expect(projectResolver.getLiveBaseContext).toHaveBeenCalledWith('my-team');
expect(projectResolver.getContext).toHaveBeenCalledWith('my-team', {
includeTeamSubagentSessionDiscovery: false,
});
expect(feed.messages.some((message) => message.messageId === 'lead-fallback-1')).toBe(true);
});
it('falls back when the fast context only contains older sessionHistory but not the current lead session', async () => {
const service = createLeadSessionCachingService({
leadSessionId: 'lead-current',
sessionHistory: ['lead-history'],
});
const projectResolver = {
getLiveBaseContext: vi.fn(() =>
Promise.resolve({
projectDir: '/history-project',
projectId: 'history-project',
config: {
name: 'My team',
members: [{ name: 'history-lead', agentType: 'lead' }],
leadSessionId: 'lead-current',
sessionHistory: ['lead-history'],
},
})
),
getContext: vi.fn(() =>
Promise.resolve({
projectDir: '/current-project',
projectId: 'current-project',
config: {
name: 'My team',
members: [{ name: 'current-lead', agentType: 'lead' }],
leadSessionId: 'lead-current',
sessionHistory: ['lead-history'],
},
sessionIds: ['lead-current', 'lead-history'],
})
),
};
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockImplementation(
(...args: unknown[]) => {
const [projectDir] = args as [string];
if (projectDir === '/current-project') {
return Promise.resolve(
new Map([['lead-current', '/current-project/lead-current.jsonl']])
);
}
return Promise.resolve(new Map([['lead-history', '/history-project/lead-history.jsonl']]));
}
);
const extractSpy = vi
.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never)
.mockResolvedValue([
{
from: 'current-lead',
text: 'Current lead session wins over older session history.',
timestamp: '2026-04-18T10:00:00.000Z',
read: true,
source: 'lead_session',
leadSessionId: 'lead-current',
messageId: 'lead-current-1',
},
]);
const feed = await service.getMessageFeed('my-team');
expect(projectResolver.getContext).toHaveBeenCalledWith('my-team', {
includeTeamSubagentSessionDiscovery: false,
});
expect(extractSpy).toHaveBeenCalledWith(
'/current-project/lead-current.jsonl',
'current-lead',
'lead-current',
150
);
expect(feed.messages.some((message) => message.messageId === 'lead-current-1')).toBe(true);
});
it('refreshes lead jsonl paths when lightweight fallback keeps the same project directory', async () => {
const service = createLeadSessionCachingService({
leadSessionId: 'lead-current',
sessionHistory: ['lead-history'],
});
const projectResolver = {
getLiveBaseContext: vi.fn(() =>
Promise.resolve({
projectDir: '/same-project',
projectId: 'same-project',
config: {
name: 'My team',
members: [{ name: 'history-lead', agentType: 'lead' }],
leadSessionId: 'lead-current',
sessionHistory: ['lead-history'],
},
})
),
getContext: vi.fn(() =>
Promise.resolve({
projectDir: '/same-project',
projectId: 'same-project',
config: {
name: 'My team',
members: [{ name: 'current-lead', agentType: 'lead' }],
leadSessionId: 'lead-current',
sessionHistory: ['lead-history'],
},
sessionIds: ['lead-current', 'lead-history'],
})
),
};
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver;
const getPathsSpy = vi
.spyOn(service as never, 'getLeadSessionJsonlPaths' as never)
.mockResolvedValueOnce(new Map([['lead-history', '/same-project/lead-history.jsonl']]))
.mockResolvedValueOnce(new Map([['lead-current', '/same-project/lead-current.jsonl']]));
const extractSpy = vi
.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never)
.mockResolvedValue([
{
from: 'current-lead',
text: 'Same-directory fallback refreshed the lead session path list.',
timestamp: '2026-04-18T10:00:00.000Z',
read: true,
source: 'lead_session',
leadSessionId: 'lead-current',
messageId: 'lead-same-project-1',
},
]);
const feed = await service.getMessageFeed('my-team');
expect(projectResolver.getContext).toHaveBeenCalledWith('my-team', {
includeTeamSubagentSessionDiscovery: false,
});
expect(getPathsSpy).toHaveBeenCalledTimes(2);
expect(extractSpy).toHaveBeenCalledWith(
'/same-project/lead-current.jsonl',
'current-lead',
'lead-current',
150
);
expect(feed.messages.some((message) => message.messageId === 'lead-same-project-1')).toBe(true);
});
it('loads durable lead_session messages through the transcript resolver when projectPath is stale', async () => { it('loads durable lead_session messages through the transcript resolver when projectPath is stale', async () => {
const fixture = await createResolverBackedLeadFixture(); const fixture = await createResolverBackedLeadFixture();
const service = createResolverBackedService(); const service = createResolverBackedService();
@ -4885,9 +5129,7 @@ describe('TeamDataService', () => {
expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath); expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath);
expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath); expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe('feature/alice');
'feature/alice'
);
}); });
it('keeps member branch enrichment on for explicit full UI team data snapshots', async () => { it('keeps member branch enrichment on for explicit full UI team data snapshots', async () => {
@ -4916,9 +5158,7 @@ describe('TeamDataService', () => {
expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath); expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath);
expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath); expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe('feature/alice');
'feature/alice'
);
}); });
it('uses snapshot config reads for UI message feed snapshots', async () => { it('uses snapshot config reads for UI message feed snapshots', async () => {

View file

@ -157,6 +157,7 @@ vi.mock('agent-teams-controller', () => ({
import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity'; import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity';
import * as OpenCodeRuntimeStore from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import * as OpenCodeRuntimeStore from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime';
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { getTeamsBasePath } from '../../../../src/main/utils/pathDecoder'; import { getTeamsBasePath } from '../../../../src/main/utils/pathDecoder';
@ -2474,6 +2475,214 @@ Messages:
); );
}); });
it('records and schedules a retry when the OpenCode bridge throws during prompt delivery', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const laneId = 'secondary:opencode:jack';
hoisted.files.set(
`/mock/teams/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
const sendMessageToMember = vi.fn(async () => {
throw new Error('bridge crashed');
});
service.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
])
);
vi.spyOn(service as any, 'getCurrentOpenCodeRuntimeRunId').mockReturnValue('opencode-run-1');
vi.spyOn(
service as any,
'hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence'
).mockResolvedValue(true);
vi.spyOn(service as any, 'applyOpenCodeVisibleDestinationProof').mockImplementation(
async (input: any) => ({
ledgerRecord: input.ledgerRecord,
visibleReply: null,
})
);
vi.spyOn(service as any, 'materializeOpenCodePlainTextReplyIfNeeded').mockImplementation(
async (input: any) => ({
ledgerRecord: input.ledgerRecord,
visibleReply: null,
})
);
const watchdogSpy = vi
.spyOn(service as any, 'scheduleOpenCodePromptDeliveryWatchdog')
.mockImplementation(() => undefined);
const records: any[] = [];
const ledger = {
getActiveForMember: vi.fn(async () => null),
ensurePending: vi.fn(async (input: Record<string, unknown>) => {
const record = {
id: 'ledger-send-exception-1',
teamName,
memberName: 'jack',
laneId,
runId: 'opencode-run-1',
runtimeSessionId: null,
runtimePromptMessageId: null,
runtimePromptMessageIds: [],
lastRuntimePromptMessageId: null,
lastDeliveryAttemptIdWithAcceptedPrompt: null,
inboxMessageId: 'opencode-send-exception-1',
inboxTimestamp: '2026-02-23T17:00:00.000Z',
source: 'watcher',
messageKind: 'default',
workSyncIntent: null,
replyRecipient: 'team-lead',
actionMode: 'do',
taskRefs: [],
payloadHash: 'sha256:test',
status: 'pending',
responseState: 'not_observed',
attempts: 0,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: null,
lastObservedAt: null,
acceptedAt: null,
respondedAt: null,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: null,
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: null,
diagnostics: [] as string[],
createdAt: '2026-02-23T17:00:00.000Z',
updatedAt: '2026-02-23T17:00:00.000Z',
...input,
};
records.push(record);
return record;
}),
applyDeliveryResult: vi.fn(async (input: Record<string, unknown>) => {
const record = records[0];
Object.assign(record, {
status: 'failed_retryable',
responseState: 'reconcile_failed',
attempts: 1,
lastAttemptAt: input.now,
lastReason: input.reason,
diagnostics: input.diagnostics,
updatedAt: input.now,
});
return record;
}),
markNextAttemptScheduled: vi.fn(async (input: Record<string, unknown>) => {
const record = records[0];
Object.assign(record, {
status: input.status,
nextAttemptAt: input.nextAttemptAt,
lastReason: input.reason,
updatedAt: input.scheduledAt,
});
return record;
}),
markFailedTerminal: vi.fn(),
};
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue(ledger);
const delivery = await service.deliverOpenCodeMemberMessage(teamName, {
memberName: 'jack',
text: 'Please continue task.',
messageId: 'opencode-send-exception-1',
source: 'watcher',
replyRecipient: 'team-lead',
actionMode: 'do',
inboxTimestamp: '2026-02-23T17:00:00.000Z',
});
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
expect(ledger.applyDeliveryResult).toHaveBeenCalledWith(
expect.objectContaining({
accepted: false,
attempted: true,
reason: expect.stringContaining('bridge crashed'),
})
);
expect(ledger.markNextAttemptScheduled).toHaveBeenCalledWith(
expect.objectContaining({
status: 'retry_scheduled',
reason: expect.stringContaining('bridge crashed'),
})
);
expect(watchdogSpy).toHaveBeenCalledWith(
expect.objectContaining({
teamName,
memberName: 'jack',
messageId: 'opencode-send-exception-1',
})
);
expect(delivery).toMatchObject({
delivered: false,
accepted: false,
responsePending: true,
ledgerStatus: 'retry_scheduled',
ledgerRecordId: 'ledger-send-exception-1',
reason: expect.stringContaining('bridge crashed'),
});
});
it('does not postpone an earlier OpenCode prompt watchdog wake when rescheduled later', async () => {
vi.useFakeTimers();
try {
const service = new TeamProvisioningService();
const relaySpy = vi
.spyOn(service, 'relayOpenCodeMemberInboxMessages')
.mockResolvedValue({ attempted: 1, delivered: 0, failed: 0 } as any);
vi.spyOn(service as any, 'canDeliverToOpenCodeRuntimeForTeam').mockReturnValue(true);
(service as any).scheduleOpenCodePromptDeliveryWatchdog({
teamName: 'my-team',
memberName: 'jack',
messageId: 'message-1',
delayMs: 500,
});
(service as any).scheduleOpenCodePromptDeliveryWatchdog({
teamName: 'my-team',
memberName: 'jack',
messageId: 'message-1',
delayMs: 60_000,
});
await vi.advanceTimersByTimeAsync(501);
expect(relaySpy).toHaveBeenCalledTimes(1);
expect(relaySpy).toHaveBeenCalledWith('my-team', 'jack', {
onlyMessageId: 'message-1',
source: 'watchdog',
});
} finally {
vi.useRealTimers();
}
});
it('ignores stale OpenCode watchdog jobs after the runtime lane is no longer active', async () => { it('ignores stale OpenCode watchdog jobs after the runtime lane is no longer active', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
@ -2807,6 +3016,63 @@ Messages:
} }
}); });
it('schedules existing pending OpenCode prompt ledger rows with no next attempt on startup scan', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
hoisted.files.set(
`/mock/teams/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack');
expect(identity.ok).toBe(true);
const laneId = identity.laneId;
const scheduleSpy = vi
.spyOn(service as any, 'scheduleOpenCodePromptDeliveryWatchdog')
.mockImplementation(() => undefined);
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
pruneTerminalRecords: vi.fn(async () => ({ pruned: 0, remaining: 1 })),
list: vi.fn(async () => [
{
id: 'ledger-existing-pending-1',
teamName,
memberName: 'jack',
laneId,
inboxMessageId: 'opencode-existing-pending-1',
status: 'pending',
responseState: 'not_observed',
attempts: 0,
maxAttempts: 3,
nextAttemptAt: null,
diagnostics: [],
createdAt: '2026-02-23T17:00:00.000Z',
},
]),
getByInboxMessage: vi.fn(async () => null),
});
const scheduled = await (service as any).scanOpenCodePromptDeliveryWatchdogForActiveLanes(
teamName,
[laneId]
);
expect(scheduled).toBe(1);
expect(scheduleSpy).toHaveBeenCalledWith(
expect.objectContaining({
teamName,
memberName: 'jack',
messageId: 'opencode-existing-pending-1',
delayMs: expect.any(Number),
})
);
});
it('queues a specific OpenCode relay behind an active member relay without duplicate prompts', async () => { it('queues a specific OpenCode relay behind an active member relay without duplicate prompts', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const service = new TeamProvisioningService(); const service = new TeamProvisioningService();
@ -3201,6 +3467,9 @@ Messages:
const service = new TeamProvisioningService(); const service = new TeamProvisioningService();
const teamName = 'my-team'; const teamName = 'my-team';
const teamsBasePath = getTeamsBasePath(); const teamsBasePath = getTeamsBasePath();
const wakeSpy = vi
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
.mockImplementation(() => undefined);
hoisted.files.set( hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`, `${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({ JSON.stringify({
@ -3238,6 +3507,193 @@ Messages:
reason: 'opencode_foreground_inbox_unread', reason: 'opencode_foreground_inbox_unread',
activeMessageId: 'foreground-message-1', activeMessageId: 'foreground-message-1',
}); });
expect(wakeSpy).toHaveBeenCalledWith({
teamName,
memberName: 'jack',
messageId: 'foreground-message-1',
delayMs: 500,
});
});
it('wakes an active OpenCode foreground delivery instead of blocking work-sync on unread inbox state', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const laneId = 'secondary:opencode:jack';
const wakeSpy = vi
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
.mockImplementation(() => undefined);
seedOpenCodeBusyStatusFixture({
service,
teamName,
laneId,
inboxMessages: [
{
from: 'team-lead',
to: 'jack',
text: 'New task assigned to you.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: 'foreground-message-1',
messageKind: 'default',
},
],
activeRecord: {
inboxMessageId: 'foreground-message-1',
messageKind: 'default',
nextAttemptAt: '2026-02-23T17:33:00.000Z',
},
});
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName: 'jack',
nowIso: '2026-02-23T17:31:10.000Z',
workSyncIntent: 'agenda_sync',
});
expect(busy).toMatchObject({
busy: true,
reason: 'opencode_prompt_delivery_active:default',
activeMessageId: 'foreground-message-1',
retryAfterIso: '2026-02-23T17:33:00.000Z',
});
expect(wakeSpy).toHaveBeenCalledWith(
expect.objectContaining({
teamName,
memberName: 'jack',
messageId: 'foreground-message-1',
})
);
});
it('prioritizes an active OpenCode prompt ledger over newer unread foreground messages', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const laneId = 'secondary:opencode:jack';
const wakeSpy = vi
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
.mockImplementation(() => undefined);
seedOpenCodeBusyStatusFixture({
service,
teamName,
laneId,
inboxMessages: [
{
from: 'team-lead',
to: 'jack',
text: 'Follow-up comment after assignment.',
timestamp: '2026-02-23T17:32:00.000Z',
read: false,
messageId: 'foreground-comment-1',
messageKind: 'default',
},
],
activeRecord: {
inboxMessageId: 'foreground-assignment-1',
messageKind: 'default',
nextAttemptAt: '2026-02-23T17:33:00.000Z',
},
});
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName: 'jack',
nowIso: '2026-02-23T17:32:10.000Z',
workSyncIntent: 'agenda_sync',
});
expect(busy).toMatchObject({
busy: true,
reason: 'opencode_prompt_delivery_active:default',
activeMessageId: 'foreground-assignment-1',
retryAfterIso: '2026-02-23T17:33:00.000Z',
});
expect(wakeSpy).toHaveBeenCalledWith(
expect.objectContaining({
teamName,
memberName: 'jack',
messageId: 'foreground-assignment-1',
})
);
});
it('recovers a missing OpenCode lane before using an active prompt ledger as busy state', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const memberName = 'jack';
const laneId = 'secondary:opencode:jack';
const teamsBasePath = getTeamsBasePath();
hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
hoisted.files.set(
`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`,
JSON.stringify([
{
from: 'team-lead',
to: memberName,
text: 'New task assigned to you.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: 'foreground-message-1',
messageKind: 'default',
},
])
);
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
ok: true,
canonicalMemberName: memberName,
laneId,
}));
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {},
});
const recoverySpy = vi
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
.mockResolvedValue(true);
const wakeSpy = vi
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
.mockImplementation(() => undefined);
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
getActiveForMember: vi.fn(async () => ({
id: 'ledger-foreground-message-1',
teamName,
memberName,
laneId,
inboxMessageId: 'foreground-message-1',
messageKind: 'default',
status: 'pending',
responseState: 'not_observed',
nextAttemptAt: '2026-02-23T17:33:00.000Z',
})),
});
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName,
nowIso: '2026-02-23T17:31:10.000Z',
workSyncIntent: 'agenda_sync',
});
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName });
expect(busy).toMatchObject({
busy: true,
reason: 'opencode_prompt_delivery_active:default',
activeMessageId: 'foreground-message-1',
});
expect(wakeSpy).toHaveBeenCalledWith(
expect.objectContaining({ teamName, memberName, messageId: 'foreground-message-1' })
);
}); });
it('does not let proof-missing recovery get blocked by its original unread message', async () => { it('does not let proof-missing recovery get blocked by its original unread message', async () => {

View file

@ -86,6 +86,42 @@ describe('ActivityTimeline session separators', () => {
}); });
}); });
it('renders loading state instead of empty state while the initial message page is loading', () => {
const root = createRoot(container);
act(() => {
root.render(
React.createElement(ActivityTimeline, {
messages: [],
loading: true,
teamName: 'demo-team',
})
);
});
expect(container.textContent).toContain('Loading messages...');
expect(container.textContent).not.toContain('No messages');
act(() => {
root.unmount();
});
});
it('renders empty state after loading settles with no messages', () => {
const root = createRoot(container);
act(() => {
root.render(React.createElement(ActivityTimeline, { messages: [], teamName: 'demo-team' }));
});
expect(container.textContent).toContain('No messages');
expect(container.textContent).not.toContain('Loading messages...');
act(() => {
root.unmount();
});
});
it('renders New session between lead thought groups from different sessions', async () => { it('renders New session between lead thought groups from different sessions', async () => {
const root = createRoot(container); const root = createRoot(container);
const messages: InboxMessage[] = [ const messages: InboxMessage[] = [
@ -348,9 +384,7 @@ describe('ActivityTimeline session separators', () => {
describe('ActivityTimeline viewport observerRoot', () => { describe('ActivityTimeline viewport observerRoot', () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let capturedRoots: Array<Element | Document | null>; let capturedRoots: Array<Element | Document | null>;
let originalIntersectionObserver: let originalIntersectionObserver: typeof globalThis.IntersectionObserver | undefined;
| typeof globalThis.IntersectionObserver
| undefined;
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
@ -363,10 +397,7 @@ describe('ActivityTimeline viewport observerRoot', () => {
public readonly root: Element | Document | null; public readonly root: Element | Document | null;
public readonly rootMargin: string; public readonly rootMargin: string;
public readonly thresholds: ReadonlyArray<number>; public readonly thresholds: ReadonlyArray<number>;
constructor( constructor(_callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
_callback: IntersectionObserverCallback,
options?: IntersectionObserverInit
) {
this.root = options?.root ?? null; this.root = options?.root ?? null;
this.rootMargin = options?.rootMargin ?? '0px'; this.rootMargin = options?.rootMargin ?? '0px';
this.thresholds = Array.isArray(options?.threshold) this.thresholds = Array.isArray(options?.threshold)
@ -514,7 +545,9 @@ describe('ActivityTimeline virtualization threshold', () => {
// Virtualized path wraps items in an absolute-position container; the // Virtualized path wraps items in an absolute-position container; the
// direct path does not. Assert the wrapper is absent. // direct path does not. Assert the wrapper is absent.
const absoluteWrapper = container.querySelector<HTMLDivElement>('div[style*="position: relative"]'); const absoluteWrapper = container.querySelector<HTMLDivElement>(
'div[style*="position: relative"]'
);
expect(absoluteWrapper).toBeNull(); expect(absoluteWrapper).toBeNull();
// Sanity check: direct render still emits at least one activity item. // Sanity check: direct render still emits at least one activity item.
expect(container.textContent).toContain('message 0'); expect(container.textContent).toContain('message 0');
@ -536,7 +569,9 @@ describe('ActivityTimeline virtualization threshold', () => {
); );
}); });
const absoluteWrapper = container.querySelector<HTMLDivElement>('div[style*="position: relative"]'); const absoluteWrapper = container.querySelector<HTMLDivElement>(
'div[style*="position: relative"]'
);
expect(absoluteWrapper).toBeNull(); expect(absoluteWrapper).toBeNull();
expect(container.textContent).toContain('message 0'); expect(container.textContent).toContain('message 0');
@ -569,8 +604,8 @@ describe('ActivityTimeline virtualization threshold', () => {
// Default pagination caps visible rows at 30, which stays below the // Default pagination caps visible rows at 30, which stays below the
// threshold, so the direct render path is in effect here. Click "show // threshold, so the direct render path is in effect here. Click "show
// all" to expose every message — that pushes row count past the gate. // all" to expose every message — that pushes row count past the gate.
const showAllButton = [...container.querySelectorAll('button')].find( const showAllButton = [...container.querySelectorAll('button')].find((b) =>
(b) => b.textContent?.toLowerCase().includes('show all') b.textContent?.toLowerCase().includes('show all')
); );
expect(showAllButton).toBeDefined(); expect(showAllButton).toBeDefined();

View file

@ -140,7 +140,8 @@ describe('MemberList spawn-status memoization', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('Team members are loading'); expect(host.querySelector('[aria-label="Loading team members"]')).not.toBeNull();
expect(host.textContent).not.toContain('Team members are loading');
expect(host.textContent).not.toContain('Solo team'); expect(host.textContent).not.toContain('Solo team');
await act(async () => { await act(async () => {
@ -173,7 +174,8 @@ describe('MemberList spawn-status memoization', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('Team members are loading'); expect(host.querySelector('[aria-label="Loading team members"]')).not.toBeNull();
expect(host.textContent).not.toContain('Team members are loading');
expect(host.querySelector('[data-testid="member-team-lead"]')).toBeNull(); expect(host.querySelector('[data-testid="member-team-lead"]')).toBeNull();
expect(host.textContent).not.toContain('Solo team'); expect(host.textContent).not.toContain('Solo team');

View file

@ -135,10 +135,11 @@ vi.mock('@renderer/components/team/sidebar/teamSidebarUiState', () => ({
})); }));
vi.mock('@renderer/components/team/activity/ActivityTimeline', () => ({ vi.mock('@renderer/components/team/activity/ActivityTimeline', () => ({
ActivityTimeline: ({ messages }: { messages: InboxMessage[] }) => ActivityTimeline: ({ messages, loading }: { messages: InboxMessage[]; loading?: boolean }) =>
React.createElement( React.createElement(
'div', 'div',
{ 'data-testid': 'activity-timeline' }, { 'data-testid': 'activity-timeline' },
loading ? React.createElement('div', null, 'timeline-loading') : null,
messages.map((message) => messages.map((message) =>
React.createElement( React.createElement(
'div', 'div',
@ -225,6 +226,78 @@ describe('MessagesPanel idle summary invariants', () => {
sidebarUiState.bottomSheetSnapIndex = 2; sidebarUiState.bottomSheetSnapIndex = 2;
}); });
it('shows timeline loading before the initial message page has a cache entry', 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(MessagesPanel, {
teamName: 'atlas-hq',
position: 'sidebar',
onPositionChange: vi.fn(),
members: [],
tasks: [],
timeWindow: null,
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('timeline-loading');
expect(storeState.refreshTeamMessagesHead).toHaveBeenCalledWith('atlas-hq');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not keep timeline loading forever after an empty failed head attempt settles', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
storeState.teamMessagesByName['atlas-hq'] = {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
};
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
position: 'sidebar',
onPositionChange: vi.fn(),
members: [],
tasks: [],
timeWindow: null,
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('timeline-loading');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('hides passive peer summaries by default while unread badge only counts filtered unread messages', async () => { it('hides passive peer summaries by default while unread badge only counts filtered unread messages', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');

View file

@ -1537,6 +1537,50 @@ describe('teamSlice actions', () => {
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1); expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
}); });
it('does not let a late failed selectTeam request clear members loaded by a full refresh', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().selectedTeamLoading).toBe(true);
thinRequest.reject(new Error('Timeout after 30000ms: team:getData(my-team,mode=thin)'));
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot);
expect(store.getState().selectedTeamLoading).toBe(false);
expect(store.getState().selectedTeamError).toBeNull();
});
it('preserves an earlier full refresh even when the cached baseline had the same member names', async () => { it('preserves an earlier full refresh even when the cached baseline had the same member names', async () => {
const store = createSliceStore(); const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>(); const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();