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
Интерфейс для управления командами 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
- Видеть состав команды и роли участников
- Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED
- Отправка сообщений тиммейтам через inbox-файлы и runtime-aware delivery для OpenCode
- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE
- Live updates через file watcher
- Shows team members and their roles.
- Provides a Kanban board with 5 columns: TODO, IN PROGRESS, REVIEW, DONE, APPROVED.
- Sends messages to teammates through inbox files and runtime-aware delivery for OpenCode.
- Supports review flow: review requests, manual review, and direct manual approval from DONE.
- Provides live updates through the file watcher.
## Документация
## Documentation
| Файл | Содержание |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений |
| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ |
| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox |
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, 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 |
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из 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 |
| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, включая `CLAUDE_TEAM_TEAMMATE_MODE=tmux` для 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 |
| File | Contents |
| ---- | -------- |
| [research-inbox.md](./research-inbox.md) | Inbox file format, race conditions, atomic writes, message delivery |
| [research-tasks.md](./research-tasks.md) | Task file format, .lock, .highwatermark, concurrent access |
| [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, columns, review mechanism, kanban-state.json |
| [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 |
| [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) | 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, 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 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).
- Источник метаданных участников (role/color/agentType): `members.meta.json`.
- Источник runtime-состава и адресации сообщений: `inboxes/{member}.json`.
- `config.json` is not used as the complete member registry. It may contain only the team lead and CLI service fields.
- Member metadata source (role/color/agentType): `members.meta.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
- Есть ревьюверы в команде → автоматическое назначение через inbox
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
- Reviewers exist in the team -> automatic assignment through inbox.
- The user can also manually approve a task directly from `DONE` without entering `REVIEW`.
- No reviewers -> manual user review (Approve / Request Changes in the UI).
- Request Changes -> the user optionally describes the issue -> the task returns to its owner in `pending` with `needsFix`.
### 4. Atomic Write
Все записи через tmp + rename для предотвращения corrupted JSON.
All writes use tmp + rename to prevent corrupted JSON.
### 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
- После записи читаем файл обратно и проверяем наличие нашего `messageId`
- Полный CAS/retry-цикл — не нужен на MVP: проверка при следующем read достаточна
- Риск race condition с агентом реален, но вероятность низкая
- Atomic write (tmp + rename) prevents corrupted JSON.
- After writing, read the file back and verify that our `messageId` is present.
- A full CAS/retry loop is not needed for MVP. Verification on the next read is enough.
- 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
- Иначе при startup возможна race condition: GC удаляет запись до того как task-файл прочитан
- Stale `kanban-state` entries are garbage-collected only after all tasks are fully loaded.
- Otherwise, startup can race: GC may delete an entry before the task file has been read.
### Review Flow: Approve / Request Changes
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
- Комментарий при Request Changes — опционален
- Manual UI допускает два valid path:
- Buttons were renamed: **Approve** instead of OK, and **Request Changes** instead of Error.
- Request Changes comment is optional.
- Manual UI allows two valid paths:
- `DONE -> REVIEW -> APPROVED`
- `DONE -> APPROVED` как быстрый manual approval
- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix`
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP
- `DONE -> APPROVED` as fast manual approval
- `Request Changes` removes the kanban-state entry and returns the task to `pending` with `needsFix`.
- `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)` - единственный способ получить полный список
- `owner` в task-файлах — опционален (агент может не иметь owner до назначения)
- `union(members.meta.json + config members + inbox filenames + task owners)` is the only way to get the complete member list.
- `owner` in task files is optional. An agent may not have an owner before assignment.
### Graceful Degradation
- `try/catch` везде в TeamDataService — при ошибке чтения возвращаем безопасные дефолты
- 3 состояния участника: `ACTIVE` / `IDLE` / `TERMINATED`
- `ACTIVE`: idle < 5 минут
- `IDLE`: idle > 5 минут
- `TERMINATED`: получен `shutdown_response` с `approve: true`
- `try/catch` is used throughout `TeamDataService`; read errors return safe defaults.
- Member has 3 states: `ACTIVE` / `IDLE` / `TERMINATED`.
- `ACTIVE`: idle < 5 minutes
- `IDLE`: idle > 5 minutes
- `TERMINATED`: received `shutdown_response` with `approve: true`
### @dnd-kit and review transitions
### @dnd-kit and Review Transitions
- Переходы между review-колонками делаются через card actions в UI
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
- Phase 2: полноценный D&D через `@dnd-kit`
- Transitions between review columns happen through card actions in the UI.
- `@dnd-kit` is currently used primarily for reordering tasks inside a column.
- Phase 2: full drag-and-drop through `@dnd-kit`.
---
## Открытые вопросы
## Open Questions
- **FileWatcher расширение**: FileWatcher.ts уже 1243 строк — добавление teams/tasks watchers нетривиально, требует отдельного спайка
- **Windows atomic rename**: `fs.renameSync` на Windows бросает `EXDEV`/`EBUSY` при кросс-устройственном rename — нужна обёртка
- **leadSessionId интеграция**: config.json содержит `leadSessionId`, но интеграция с session viewer (переход к сессии лида) — открытый вопрос
- **Hard Interrupt**: сообщения доставляются между turns (1-30с задержка). В будущем нужен способ прервать mid-turn
- **Архивация**: inbox не чистится автоматически, нужна кнопка "Архивировать"
- **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` on Windows can throw `EXDEV`/`EBUSY` for cross-device rename. A wrapper is needed.
- **leadSessionId integration**: config.json contains `leadSessionId`, but integration with the session viewer (navigating to the lead session) remains open.
- **Hard Interrupt**: messages are delivered between turns with a 1-30 second delay. A future mechanism is needed to interrupt mid-turn.
- **Archival**: inbox is not cleaned automatically. An "Archive" button is needed.
## Файловая структура Claude Code
## Claude Code File Structure
```
```text
~/.claude/
├── teams/{teamName}/
│ ├── config.json # Конфиг команды (lead + служебные поля)
│ ├── members.meta.json # Роли/цвета/типы участников (teammates)
│ └── inboxes/{memberName}.json # Inbox каждого участника
│ ├── config.json # Team config (lead + service fields)
│ ├── members.meta.json # Member roles/colors/types (teammates)
│ └── inboxes/{memberName}.json # Inbox for each member
└── tasks/{teamName}/
├── {id}.json # Файл задачи
├── .lock # Lock-файл (0 байт)
└── .highwatermark # Последний ID задачи
├── {id}.json # Task file
├── .lock # Lock file (0 bytes)
└── .highwatermark # Latest task ID
```
**ВАЖНО**:
**Important**:
- `config.json` не является source-of-truth для полного roster.
- Полный roster для UI формируется как `members.meta.json + inbox filenames (+ lead из config)`.
- `config.json` is not the source of truth for the complete roster.
- 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 |
|----------|:-----------:|:---------:|:--------------:|
| Скорость | ~5ms | ~12с | 10-15с |
| Стоимость | $0 | $0.01-0.08/msg | токены |
| Работает с запущенными | **YES** | NO | NO |
| Прерывает mid-turn | NO | NO | NO |
| Требует API ключ | NO | YES | NO |
| Расход памяти | 0 | 0 | 100-320MB |
| Criterion | Inbox files | Agent SDK | CLI subprocess |
| --------- | :---------: | :-------: | :------------: |
| Speed | ~5ms | ~12s | 10-15s |
| Cost | $0 | $0.01-0.08/msg | tokens |
| Works with running teammates | **YES** | NO | NO |
| Interrupts mid-turn | NO | NO | NO |
| Requires API key | NO | YES | NO |
| 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)
- **$0** — никаких API вызовов
- **Единственный** способ общаться с запущенными тиммейтами
- Работает с idle и active агентами (но доставка между turns)
- **Instant write** (~5ms)
- **$0** - no API calls
- **Only** way to communicate with already-running teammates
- Works with idle and active agents, although delivery still happens between turns
### Минусы
### Cons
- Race condition при одновременной записи (см. [research-inbox.md](./research-inbox.md))
- Формат недокументирован (internal API)
- Доставка между turns, не real-time
- Race condition during concurrent writes (see [research-inbox.md](./research-inbox.md))
- Undocumented format (internal API)
- Delivery happens between turns, not in real time
### Формат сообщения
### Message Format
```json
{
"from": "user",
"text": "Не трогай файл auth.ts, я его сам изменю",
"text": "Do not touch auth.ts, I will change it myself",
"timestamp": "2026-02-17T15:30:00.000Z",
"read": false,
"summary": "Do not modify auth.ts",
@ -47,9 +47,9 @@
---
## 2. Agent SDK (ОТВЕРГНУТ)
## 2. Agent SDK (Rejected)
### Как работает
### How It Works
```typescript
import Anthropic from '@anthropic-ai/sdk';
@ -57,156 +57,159 @@ const client = new Anthropic();
const response = await client.messages.create({
model: 'claude-opus-4-7',
messages: [{ role: 'user', content: 'Send message to teammate...' }],
tools: [/* SendMessage, TaskUpdate, etc. */]
tools: [/* SendMessage, TaskUpdate, etc. */],
});
```
### Почему отвергнут
### Why It Was Rejected
1. **Создаёт НОВУЮ сессию** — не подключается к работающему тиммейту. SendMessage и TaskCreate — это инструменты модели, не программные вызовы
2. **~12 секунд** на каждый вызов (полный API round-trip)
3. **Стоит токены** — $0.01-0.08 за сообщение
4. **Нужен API ключ** — отдельная оплата, а не подписка Claude
1. **Creates a new session** - does not attach to a running teammate. SendMessage and TaskCreate are model tools, not programmatic calls.
2. **~12 seconds** per call because of the full API round trip.
3. **Costs tokens** - $0.01-0.08 per message.
4. **Requires an API key** - separate billing, not a Claude subscription.
### Когда может пригодиться
### When It May Be Useful
- Создание новых команд программно
- Автоматизация workflow (вне real-time UI)
- Creating new teams programmatically.
- Workflow automation outside the real-time UI path.
---
## 3. CLI subprocess (ОТВЕРГНУТ)
## 3. CLI Subprocess (Rejected)
### Как работает
### How It Works
```bash
claude --message "Send message to teammate-1: stop working on X"
```
### Почему отвергнут
### Why It Was Rejected
1. **Новый процесс** — не инжектится в работающего тиммейта
2. **10-15 секунд** холодный старт
3. **100-320MB памяти** на процесс
4. Каждый вызов стоит токены
1. **New process** - does not inject into a running teammate.
2. **10-15 second** cold start.
3. **100-320MB** of memory per process.
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
```
User → [UI] → TeamInboxWriter → inboxes/{member}.json (read: false)
Teammate CLI (fs.watch) → читает → обрабатывает
Teammate → inboxes/user.json (ответ)
[UI] ← TeamInboxReader ← читает user.json
```text
User -> [UI] -> TeamInboxWriter -> inboxes/{member}.json (read: false)
|
Teammate CLI (fs.watch) -> reads -> handles
|
Teammate -> inboxes/user.json (response)
|
[UI] <- TeamInboxReader <- reads user.json
```
Лид в этой цепочке НЕ участвует. Сообщение доставляется напрямую.
The lead is not part of this path. The message is delivered directly.
### Поток сообщений: Юзер → Лид
### Message Flow: User -> Lead
```
User → [UI] → stdin (stream-json) → Lead CLI
Lead sentMessages.json / liveLeadProcessMessages
[UI] ← читает и отображает
```text
User -> [UI] -> stdin (stream-json) -> Lead CLI
|
Lead -> sentMessages.json / liveLeadProcessMessages
|
[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):
- Тиммейт получает сообщение
- Тиммейт корректно определяет что это от юзера
- Тиммейт отвечает в `inboxes/user.json`
- Fallback на `from: "team-lead"` не нужен
`from: "user"` works correctly, confirmed empirically on 2026-03-23:
### Почему 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-инструкцию как обращение к себе и отвечал юзеру напрямую
2. **Дубликаты сообщений**`markInboxMessagesRead()` записывал в файл → FileWatcher срабатывал → relay запускался повторно → цикл
3. **Тиммейт не отвечал юзеру** — relay-промпт содержал "Do NOT send to user", что тиммейт тоже видел через лида
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:
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:
1. Читает inbox → видит новые (read: false)
2. Обрабатывает сообщения/задачи
3. Вызывает инструменты
1. Reads inbox -> sees new messages with read: false
2. Handles messages/tasks
3. Calls tools
4. Reasoning
5. Output
→ idle_notification → IDLE
-> idle_notification -> IDLE
... ожидание ...
... wait ...
Turn N+1:
1. Пробуждение (новое сообщение в inbox / назначение задачи)
2. Читает inbox → видит новые
1. Wake-up (new inbox message / assigned task)
2. Reads inbox -> sees new messages
...
```
### Задержка
### Delay
- **Idle agent**: получит при следующем пробуждении (доли секунды если inbox-change triggers)
- **Active agent (mid-turn)**: получит только после завершения текущего turn (1-30 секунд)
- **Idle agent**: receives the message on the next wake-up, usually a fraction of a second if inbox-change triggers.
- **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.
**Пример**:
```
17:12:30 — Agent начинает Edit на auth.ts
17:12:31 — Мы шлём "Не трогай auth.ts"
17:12:32 — Agent завершает Edit (auth.ts изменён)
17:12:33 — Agent читает inbox, видит наше сообщение
→ Поздно, файл уже изменён
**Example**:
```text
17:12:30 - Agent starts Edit on auth.ts
17:12:31 - We send "Do not touch auth.ts"
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)
Возможные подходы:
1. **kill -SIGINT** процесса тиммейта (жёсткое прерывание, потеря контекста)
2. **Файловый flag** `.interrupt-{member}` (нужна поддержка в Claude Code)
3. **API от Anthropic** (если появится)
Possible approaches:
Текущее решение: задержка приемлема, 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
{
@ -219,18 +222,18 @@ Turn N+1:
}
```
### Verify: проверка сразу после записи
### Verify Immediately After Write
- После atomic write читаем inbox и ищем наш `messageId`
- Если не найден — потеря обнаружена → warning в UI (не silent fail)
- Не автоматический retry на MVP
- After atomic write, read the inbox and look for our `messageId`.
- If missing, message loss was detected -> show a warning in the UI instead of failing silently.
- No automatic retry in MVP.
### 3 состояния offline-участника
### 3 States For Offline Members
| Состояние | Условие | Отображение |
|-----------|---------|-------------|
| `ACTIVE` | idle < 5 минут | Зелёный dot |
| `IDLE` | idle > 5 минут | Жёлтый dot |
| `TERMINATED` | Получен `shutdown_response` с `approve: true` | Серый dot, "Завершён" |
| State | Condition | Display |
| ----- | --------- | ------- |
| `ACTIVE` | idle < 5 minutes | Green dot |
| `IDLE` | idle > 5 minutes | Yellow 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>/`
Before adding or moving code:
Before adding a medium or large feature:
- decide whether the feature is full, thin, or process-limited
- add only the layers the feature actually owns
- expose production callers through public entrypoints only
- keep tests close to the layer they verify under `test/features/<feature>/` or
feature-local `__tests__` when that is the established local pattern
- start with the layer set the feature actually owns; do not add placeholder
folders just to match the full template
- create explicit public entrypoints for every layer production callers need
- 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
[../../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.
Before creating or refactoring a feature, read:
- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md)
- [Feature-local agent guidance](./CLAUDE.md)
Reference implementation:
- `src/features/recent-projects`
- `src/features/agent-graph`
Reference examples:
- [`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:
- a new use case or business policy
- transport wiring
- more than one process boundary
- 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.
Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md).
Rule of thumb:
- `recent-projects` is the full slice example with process-aware outer layers
- `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 { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
@ -20,18 +20,11 @@ export const RecentProjectCard = ({
onOpenPath,
}: Readonly<RecentProjectCardProps>): React.JSX.Element => {
const color = useMemo(() => projectColor(card.name), [card.name]);
const [isHovered, setIsHovered] = useState(false);
return (
<button
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
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,
}}
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"
>
{card.activeTeams && card.activeTeams.length > 0 && (
<ActivePulseIndicator className="absolute right-3 top-3" />

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { TeamTaskStatusSummary } from '@renderer/components/team/TeamTaskStatusSummary';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
import { FolderOpen } from 'lucide-react';
import { FolderOpen, UsersRound } from 'lucide-react';
import { useRunningTeamsSection } from '../hooks/useRunningTeamsSection';
@ -40,11 +40,13 @@ export function RunningTeamsSection({
key={row.id}
type="button"
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"
style={{ borderLeftColor: row.accentColor }}
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"
title={getRowTitle(row)}
>
<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="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium text-text">{row.displayName}</span>

View file

@ -3351,12 +3351,6 @@ export class TeamDataService {
teamName: string,
config: TeamConfig
): 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);
if (knownLeadSessionIds.length === 0) {
return [];
@ -3365,11 +3359,36 @@ export class TeamDataService {
if (sessionIds.length === 0) {
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) {
return [];
}
const leadName =
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
const texts: InboxMessage[] = [];
for (const sessionId of sessionIds) {
if (texts.length >= MAX_LEAD_TEXTS) break;

View file

@ -5651,6 +5651,7 @@ export class TeamProvisioningService {
Promise<OpenCodeMemberInboxRelayResult>
>();
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 openCodeRuntimeDeliveryAdvisoryEventSentAt = new Map<string, number>();
private readonly openCodeRuntimeDeliveryLeadNoticeSentAt = new Map<string, number>();
@ -7125,14 +7126,15 @@ export class TeamProvisioningService {
return this.hasAlivePersistedTeamProcess(teamName);
}
private hasAlivePersistedTeamProcess(teamName: string): boolean {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(processesPath, 'utf8')) as unknown;
} catch {
return false;
private canAttemptCommittedOpenCodeSessionRecovery(teamName: string): boolean {
if (this.canDeliverToOpenCodeRuntimeForTeam(teamName)) {
return true;
}
return !this.hasOnlyExplicitlyStoppedPersistedTeamProcesses(teamName);
}
private hasAlivePersistedTeamProcess(teamName: string): boolean {
const parsed = this.readPersistedTeamProcessRows(teamName);
if (!Array.isArray(parsed)) {
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 {
void this.stopOpenCodeRuntimeLanesForStoppedTeam(teamName).catch((error) => {
logger.warn(
@ -8659,18 +8688,31 @@ export class TeamProvisioningService {
memberName: input.memberName,
messageId,
});
const delayMs = Math.max(500, Math.min(input.delayMs, 60_000));
const deadlineMs = Date.now() + delayMs;
const existing = this.openCodePromptDeliveryWatchdogTimers.get(key);
if (existing) {
const existingDeadlineMs = this.openCodePromptDeliveryWatchdogDeadlines.get(key);
if (typeof existingDeadlineMs === 'number' && existingDeadlineMs <= deadlineMs + 25) {
return;
}
clearTimeout(existing);
}
const delayMs = Math.max(500, Math.min(input.delayMs, 60_000));
const timer = setTimeout(() => {
this.openCodePromptDeliveryWatchdogTimers.delete(key);
this.openCodePromptDeliveryWatchdogDeadlines.delete(key);
this.enqueueOpenCodePromptDeliveryWatchdogJob({
teamName: input.teamName,
run: async () => {
if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) {
return;
const recovered =
await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery({
teamName: input.teamName,
memberName: input.memberName,
});
if (!recovered) {
return;
}
}
try {
await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, {
@ -8697,6 +8739,7 @@ export class TeamProvisioningService {
});
}, delayMs);
this.openCodePromptDeliveryWatchdogTimers.set(key, timer);
this.openCodePromptDeliveryWatchdogDeadlines.set(key, deadlineMs);
}
private getOpenCodeDeliveryNextDelayMs(input: {
@ -9269,25 +9312,27 @@ export class TeamProvisioningService {
if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) {
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);
return 0;
}
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(
() => null
);
if (!laneIndex) {
if (!laneIndex && recoveredLaneIds.length === 0) {
return 0;
}
let activeLaneIds = Object.values(laneIndex.lanes)
.filter((lane) => lane.state === 'active')
.map((lane) => lane.laneId);
activeLaneIds = [
...new Set([
...activeLaneIds,
...(await this.tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog(teamName)),
]),
];
let activeLaneIds = canDeliverToTeamRuntime
? Object.values(laneIndex?.lanes ?? {})
.filter((lane) => lane.state === 'active')
.map((lane) => lane.laneId)
: [];
activeLaneIds = [...new Set([...activeLaneIds, ...recoveredLaneIds])];
return await this.scanOpenCodePromptDeliveryWatchdogForActiveLanes(teamName, activeLaneIds);
}
@ -9523,7 +9568,7 @@ export class TeamProvisioningService {
laneIdentity.laneKind === 'secondary' &&
laneIdentity.laneOwnerProviderId === 'opencode'
) {
const recovered = await this.tryRecoverOpenCodeRuntimeLaneBeforeDelivery({
let recovered = await this.tryRecoverOpenCodeRuntimeLaneBeforeDelivery({
teamName,
laneId: laneIdentity.laneId,
member: {
@ -9540,6 +9585,25 @@ export class TeamProvisioningService {
},
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) {
runtimeRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId);
runtimeActive = true;
@ -10049,23 +10113,81 @@ export class TeamProvisioningService {
pendingReason: retryPendingReason,
}),
});
const result = await adapter.sendMessageToMember({
...(runtimeRunId ? { runId: runtimeRunId } : {}),
teamName,
laneId: laneIdentity.laneId,
memberName: canonicalMemberName,
cwd,
text: deliveryText,
messageId: input.messageId,
deliveryAttemptId,
fileParts: openCodeFileParts,
replyRecipient: input.replyRecipient,
actionMode: input.actionMode,
messageKind: input.messageKind,
workSyncIntent: input.workSyncIntent,
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
taskRefs: input.taskRefs,
});
let result: OpenCodeTeamRuntimeMessageResult;
try {
result = await adapter.sendMessageToMember({
...(runtimeRunId ? { runId: runtimeRunId } : {}),
teamName,
laneId: laneIdentity.laneId,
memberName: canonicalMemberName,
cwd,
text: deliveryText,
messageId: input.messageId,
deliveryAttemptId,
fileParts: openCodeFileParts,
replyRecipient: input.replyRecipient,
actionMode: input.actionMode,
messageKind: input.messageKind,
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({
teamName,
memberName: canonicalMemberName,
@ -10624,20 +10746,172 @@ export class TeamProvisioningService {
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(
teamName: string
teamName: string,
options: { allowCommittedSessionRecoveryWithoutTeamRuntime?: boolean } = {}
): 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);
return [];
}
const snapshot = await this.launchStateStore.read(teamName).catch(() => null);
const candidates = Object.values(snapshot?.members ?? {}).filter(
isRecoverablePersistedOpenCodeRuntimeCandidate
);
if (candidates.length === 0) {
return [];
}
const candidates = canDeliverToTeamRuntime
? Object.values(snapshot?.members ?? {}).filter(
isRecoverablePersistedOpenCodeRuntimeCandidate
)
: [];
const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([
this.readConfigForObservation(teamName).catch(() => null),
@ -10697,6 +10971,48 @@ export class TeamProvisioningService {
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)];
}
@ -10964,6 +11280,7 @@ export class TeamProvisioningService {
const timer = this.openCodePromptDeliveryWatchdogTimers.get(key);
if (timer) clearTimeout(timer);
this.openCodePromptDeliveryWatchdogTimers.delete(key);
this.openCodePromptDeliveryWatchdogDeadlines.delete(key);
}
}
for (let index = this.openCodePromptDeliveryWatchdogQueue.length - 1; index >= 0; index -= 1) {
@ -12896,6 +13213,68 @@ export class TeamProvisioningService {
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: {
teamName: string;
memberName: string;
@ -12957,6 +13336,24 @@ export class TeamProvisioningService {
this.hasStableMessageId(message)
);
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 {
busy: true,
reason: 'opencode_foreground_inbox_unread',
@ -13016,13 +13413,12 @@ export class TeamProvisioningService {
};
}
if (activeRecord) {
return {
busy: true,
reason: `opencode_prompt_delivery_active:${activeRecord.messageKind ?? 'default'}`,
retryAfterIso: activeRecord.nextAttemptAt ?? retryAfterIso,
activeMessageId: activeRecord.inboxMessageId,
activeMessageKind: activeRecord.messageKind,
};
return this.buildOpenCodePromptDeliveryActiveBusyStatus({
teamName: input.teamName,
memberName: input.memberName,
retryAfterIso,
activeRecord,
});
}
return { busy: false };
@ -32263,6 +32659,7 @@ export class TeamProvisioningService {
const timer = this.openCodePromptDeliveryWatchdogTimers.get(key);
if (timer) clearTimeout(timer);
this.openCodePromptDeliveryWatchdogTimers.delete(key);
this.openCodePromptDeliveryWatchdogDeadlines.delete(key);
}
}
for (

View file

@ -175,6 +175,7 @@ interface CreateTaskDialogState {
}
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
const MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200;
function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number {
if (!summary) {
@ -1720,6 +1721,7 @@ export const TeamDetailView = memo(function TeamDetailView({
const tabId = useTabIdOptional();
const isThisTabActive = isActive;
const wasInteractiveRef = useRef(false);
const memberRosterHydrationRetryRef = useRef<string | null>(null);
const loadingHeaderColorSet = useMemo(
() =>
teamSummaryColor
@ -2081,13 +2083,77 @@ export const TeamDetailView = memo(function TeamDetailView({
return filterKanbanTasks(filteredTasks, kanbanSearchQuery);
}, [filteredTasks, kanbanSearchQuery]);
const resolvedActiveTeammateCount = useMemo(
() => activeMembers.filter((m) => !isLeadMember(m)).length,
[activeMembers]
);
const activeTeammateCount = useMemo(() => {
const resolvedCount = activeMembers.filter((m) => !isLeadMember(m)).length;
if (membersWithLiveBranches.some((m) => m.removedAt)) {
return resolvedCount;
return resolvedActiveTeammateCount;
}
return resolvedCount > 0 ? resolvedCount : summaryKnownTeammateCount;
}, [activeMembers, membersWithLiveBranches, summaryKnownTeammateCount]);
return resolvedActiveTeammateCount > 0
? 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 activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId;
if (activeLeadProviderId) return activeLeadProviderId;

View file

@ -12,7 +12,12 @@ import {
TooltipProvider,
TooltipTrigger,
} 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 { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
@ -25,13 +30,27 @@ import {
getWorktreeNavigationState,
} from '@renderer/store/utils/stateResetHelpers';
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 { nameColorSet } from '@renderer/utils/projectColor';
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus';
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 { TeamEmptyState } from './TeamEmptyState';
@ -133,23 +152,28 @@ function renderTeamRecentPaths(
team: TeamSummary,
status: TeamStatus,
matchesCurrentProject: boolean,
isLight: boolean
isLight: boolean,
selectedProjectPath: string | null
): React.JSX.Element | null {
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 (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{matchesCurrentProject ? (
{matchesCurrentProject && !selectedProjectPath ? (
<span
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'
}`}
>
<FolderOpen size={12} className="shrink-0" />
{recentPaths.map((p, i) => (
{visibleRecentPaths.map((p, i) => (
<span key={p} title={p}>
{folderName(p)}
{i < recentPaths.length - 1 ? ', ' : ''}
{i < visibleRecentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
@ -157,14 +181,14 @@ function renderTeamRecentPaths(
<>
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
{visibleRecentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && (status === 'active' || status === 'idle') ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
{i < visibleRecentPaths.length - 1 ? ', ' : ''}
</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 {
const { isLight } = useTheme();
const electronMode = isElectronMode();
@ -862,186 +1093,88 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const activeFiltered = 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 (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{activeFiltered.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 (
<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>
{activeSections.map((section, sectionIndex) => (
<section key={section.key} className={sectionIndex > 0 ? 'mt-6' : undefined}>
{section.title ? (
<div className="mb-2 flex items-center gap-2">
<h3 className="text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
{section.title}
</h3>
<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}
</span>
</div>
);
})}
</div>
) : null}
<div className="team-row-zebra-grid grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{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 && (
<>
@ -1105,7 +1238,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
{team.description || 'No description'}
</p>
{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)}
</div>
)}

View file

@ -15,7 +15,7 @@ import {
} from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Layers } from 'lucide-react';
import { Layers, Loader2 } from 'lucide-react';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext';
@ -126,6 +126,8 @@ interface ActivityTimelineProps {
onExpandItem?: (key: string) => void;
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
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
* 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,
};
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(
rootElement: HTMLElement,
scrollElement: HTMLElement
@ -419,6 +446,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
onTeamClick,
onExpandItem,
onExpandContent,
loading = false,
viewport,
}: ActivityTimelineProps): React.JSX.Element {
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
@ -858,9 +886,8 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
if (messages.length === 0) {
return (
<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 ref={rootRef} className="space-y-1">
{loading ? <TimelineLoadingState /> : <TimelineEmptyState />}
</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({
teamName = '__unknown_team__',
members,
@ -657,11 +729,7 @@ export const MemberList = memo(function MemberList({
if (members.length === 0 || hasOnlyLeadWhileTeammatesLoad) {
if (expectsTeammates) {
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Team members are loading
</div>
);
return <MemberListLoadingSkeleton expectedTeammateCount={expectedTeammateCount} />;
}
return (

View file

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

View file

@ -1435,6 +1435,64 @@ a[href],
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 {
overflow: visible;
}

View file

@ -4327,10 +4327,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
const isProvisioning = isTeamProvisioningActive(currentState, teamName);
const existingSelectedTeamData =
currentState.selectedTeamData?.teamName === teamName ? currentState.selectedTeamData : null;
const msg = error instanceof Error ? error.message : String(error);
// IPC can report provisioning state explicitly.
if (msg === 'TEAM_PROVISIONING' || (msg.includes('TEAM_PROVISIONING') && isProvisioning)) {
if (existingSelectedTeamData) {
set({
selectedTeamLoading: false,
selectedTeamData: existingSelectedTeamData,
selectedTeamError: null,
});
return;
}
set({
selectedTeamLoading: true,
selectedTeamData: null,
@ -4355,6 +4365,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
: error instanceof Error
? error.message
: 'Failed to fetch team data';
if (existingSelectedTeamData) {
set({
selectedTeamLoading: false,
selectedTeamData: existingSelectedTeamData,
selectedTeamError: null,
});
return;
}
set({
selectedTeamLoading: false,
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 () => {
const teamName = 'mixed-opencode-watchdog-recovers-one-missing-lane-safe-e2e';
await writeMixedTeamConfig({ teamName, projectPath });
@ -12628,6 +12752,53 @@ describe('Team agent launch matrix safe e2e', () => {
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 () => {
const teamName = 'mixed-opencode-direct-message-removed-stale-index-safe-e2e';
await writeMixedTeamConfig({ teamName, projectPath, removedMembers: ['bob'] });
@ -17327,6 +17498,9 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
providerId: 'opencode',
memberName: input.memberName,
sessionId: `session-${input.memberName}`,
runtimePromptMessageId: input.messageId
? `prompt-${input.messageId}`
: `prompt-${input.memberName}-${this.messageInputs.length}`,
runtimePid: 12_000 + this.messageInputs.length,
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 {
// Windows uses taskkill.exe for process-tree termination, so fake child.kill is not called.
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(
{
listTeams: vi.fn(),
@ -191,6 +193,7 @@ function createLeadSessionCachingService(): TeamDataService {
name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }],
leadSessionId: 'lead-1',
...configOverrides,
})),
} as never,
{
@ -4709,6 +4712,247 @@ describe('TeamDataService', () => {
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 () => {
const fixture = await createResolverBackedLeadFixture();
const service = createResolverBackedService();
@ -4885,9 +5129,7 @@ describe('TeamDataService', () => {
expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath);
expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe(
'feature/alice'
);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe('feature/alice');
});
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(aliceRepoPath);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe(
'feature/alice'
);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe('feature/alice');
});
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 * 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 { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
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 () => {
vi.useFakeTimers();
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 () => {
vi.useFakeTimers();
const service = new TeamProvisioningService();
@ -3201,6 +3467,9 @@ Messages:
const service = new TeamProvisioningService();
const teamName = 'my-team';
const teamsBasePath = getTeamsBasePath();
const wakeSpy = vi
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
.mockImplementation(() => undefined);
hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({
@ -3238,6 +3507,193 @@ Messages:
reason: 'opencode_foreground_inbox_unread',
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 () => {

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 () => {
const root = createRoot(container);
const messages: InboxMessage[] = [
@ -348,9 +384,7 @@ describe('ActivityTimeline session separators', () => {
describe('ActivityTimeline viewport observerRoot', () => {
let container: HTMLDivElement;
let capturedRoots: Array<Element | Document | null>;
let originalIntersectionObserver:
| typeof globalThis.IntersectionObserver
| undefined;
let originalIntersectionObserver: typeof globalThis.IntersectionObserver | undefined;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
@ -363,10 +397,7 @@ describe('ActivityTimeline viewport observerRoot', () => {
public readonly root: Element | Document | null;
public readonly rootMargin: string;
public readonly thresholds: ReadonlyArray<number>;
constructor(
_callback: IntersectionObserverCallback,
options?: IntersectionObserverInit
) {
constructor(_callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.root = options?.root ?? null;
this.rootMargin = options?.rootMargin ?? '0px';
this.thresholds = Array.isArray(options?.threshold)
@ -514,7 +545,9 @@ describe('ActivityTimeline virtualization threshold', () => {
// Virtualized path wraps items in an absolute-position container; the
// 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();
// Sanity check: direct render still emits at least one activity item.
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(container.textContent).toContain('message 0');
@ -569,8 +604,8 @@ describe('ActivityTimeline virtualization threshold', () => {
// Default pagination caps visible rows at 30, which stays below the
// threshold, so the direct render path is in effect here. Click "show
// all" to expose every message — that pushes row count past the gate.
const showAllButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent?.toLowerCase().includes('show all')
const showAllButton = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.toLowerCase().includes('show all')
);
expect(showAllButton).toBeDefined();

View file

@ -140,7 +140,8 @@ describe('MemberList spawn-status memoization', () => {
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');
await act(async () => {
@ -173,7 +174,8 @@ describe('MemberList spawn-status memoization', () => {
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.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', () => ({
ActivityTimeline: ({ messages }: { messages: InboxMessage[] }) =>
ActivityTimeline: ({ messages, loading }: { messages: InboxMessage[]; loading?: boolean }) =>
React.createElement(
'div',
{ 'data-testid': 'activity-timeline' },
loading ? React.createElement('div', null, 'timeline-loading') : null,
messages.map((message) =>
React.createElement(
'div',
@ -225,6 +226,78 @@ describe('MessagesPanel idle summary invariants', () => {
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 () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -1537,6 +1537,50 @@ describe('teamSlice actions', () => {
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 () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();