fix(team): harden opencode delivery recovery
This commit is contained in:
parent
874123c773
commit
6e8f938da2
25 changed files with 2516 additions and 508 deletions
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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/`
|
||||
|
|
|
|||
108
src/features/recent-projects/README.md
Normal file
108
src/features/recent-projects/README.md
Normal 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.
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue