feat(team-managment): stable MVP

This commit is contained in:
iliya 2026-02-21 09:47:24 +02:00
parent 70915a152a
commit 753ab14917
84 changed files with 12122 additions and 123 deletions

4
.gitignore vendored
View file

@ -46,4 +46,6 @@ temp/
eslint-fix/
remotion/*
remotion/*
.home/

View file

@ -5,6 +5,11 @@
## Список
- [Итерация 01 — Core Foundation + Team List](./iteration-01-core-team-list.md)
- [Итерация 02 — Team Detail + Members](./iteration-02-team-detail-members.md)
- [Итерация 03 — Kanban Board (click-to-move)](./iteration-03-kanban-board.md)
- [Итерация 04 — Messaging + Review](./iteration-04-messaging-review.md)
- [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md)
- [Итерация 06 — Team Provisioning (Create Team из UI)](./iteration-06-team-provisioning.md)
## Принципы

View file

@ -0,0 +1,334 @@
# Итерация 02 — Team Detail + Members
Эта итерация добавляет **детальную страницу команды**: по клику из списка команд открывается вкладка команды, где видно **участников** и **задачи**. Плюс включаем **live refresh** через file watcher для `~/.claude/teams` и `~/.claude/tasks`.
Основание: `docs/team-management/implementation.md` (Iteration 2) + research-доки в `docs/team-management/`.
---
## Цель итерации
- Из `Teams` списка можно кликнуть команду → открывается **Team tab** (тип `team`)
- В Team tab отображаются:
- **участники** (минимум: имя + текущая задача/статус-заглушка)
- **задачи** (список задач из `~/.claude/tasks/{team}`)
- При изменении файлов в `~/.claude/teams/**` или `~/.claude/tasks/**` UI автоматически обновляется (с coalesce/throttle)
---
## Не-цели (строго вне scope)
- Kanban и `kanban-state.json` (это итерация 03)
- Чтение/рендер сообщений inbox, compose message, review flow (итерация 04)
- Любые write-path (в inbox, в task status, в kanban state)
- Полный refactor Tab-types в discriminated union (это отдельная тех-итерация, не часть MVP)
- Полное покрытие тестами для Team Management (итерация 05). В этой итерации пишем тесты **только если** что-то ломается и требует стабилизации.
---
## Контракт итерации (Main → Preload → Renderer)
### IPC каналы (flat `export const`)
В `src/preload/constants/ipcChannels.ts`:
- `TEAM_GET_DATA = 'team:getData'`
- `TEAM_CHANGE = 'team:change'` (event main → renderer)
### Нейминг событий (без двусмысленностей)
- **IPC (Electron)**: канал `TEAM_CHANGE = 'team:change'` (строка с двоеточием)
- **SSE (HTTP sidecar)**: событие `team-change` (строка с дефисом), потому что в проекте уже используется этот стиль для SSE (`file-change`, `todo-change`, `notification:new`)
Требование: main process форвардит **одно и то же payload-содержимое** в оба транспорта.
### Shared types
В `src/shared/types/team.ts` вводим/расширяем типы (для итерации 02 — только нужный минимум):
- `TeamSummary` (обновляем контракт из итерации 01, см. ниже)
- `TeamTask`
- `ResolvedTeamMember`
- `TeamData` (без kanban/inbox messages)
- `TeamChangeEvent`
#### TeamSummary (обязательный migration)
В итерации 01 поле `TeamSummary.name` фактически использовалось как “display name”. Для итерации 02 нам нужен стабильный идентификатор для доступа к диску.
**Новый контракт TeamSummary:**
- `teamName: string`**directory name** (`~/.claude/teams/{teamName}`)
- `displayName: string``config.json.name` (человеческое имя)
- `description: string`
- `memberCount: number`
- `taskCount: number` (в итерации 02 можно оставить 0, либо заполнить после чтения задач)
- `lastActivity: string | null` (в итерации 02 остаётся `null`)
**Обязательная правка в этой итерации:** обновить реализацию `team:list` и UI, чтобы клик открывал Team tab по `teamName`, а отображение было по `displayName`.
#### TeamTask (читает Claude Code task file)
- `id: string`
- `subject: string`
- `description?: string`
- `activeForm?: string`
- `owner?: string`
- `status: 'pending' | 'in_progress' | 'completed' | 'deleted'`
- `blocks?: string[]`
- `blockedBy?: string[]`
#### ResolvedTeamMember (итерация 02)
- `name: string`
- `status: 'unknown'` (в итерации 02 **всегда** unknown; статусы по inbox — итерация 04)
- `currentTaskId: string | null` (берём первую `in_progress` задачу по `owner === name`)
- `taskCount: number` (кол-во задач по `owner === name`, исключая `deleted`)
#### TeamData (итерация 02)
- `teamName: string` (канонический идентификатор — **имя директории** в `~/.claude/teams/{teamName}`)
- `config: TeamConfig`
- `tasks: TeamTask[]`
- `members: ResolvedTeamMember[]`
- `warnings?: string[]` (например, “tasks failed to load”)
#### TeamChangeEvent
- `type: 'config' | 'inbox' | 'task'`
- `teamName: string`
- `detail?: string` (например `inboxes/alice.json` или `12.json`)
### TeamsAPI (shared `src/shared/types/api.ts`)
Расширяем интерфейс `TeamsAPI`:
- `getData: (teamName: string) => Promise<TeamData>`
- `onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void`
`list()` остаётся как есть.
---
## Важное решение без двусмысленностей: идентификатор = teamName (directory name)
Вся адресация на диске идёт по `teamName`, который равен имени директории:
- `~/.claude/teams/{teamName}/config.json`
- `~/.claude/teams/{teamName}/inboxes/*.json`
- `~/.claude/tasks/{teamName}/*.json`
**Следствие:** в `team:list` должен быть явный `teamName` (см. новый `TeamSummary`), и все навигации/IPC используют именно его.
---
## Definition of Done (DoD)
- **UI**
- В `Teams` списке клик по карточке открывает Team tab
- Team tab показывает MemberList и TasksList (или таблицу)
- Есть пустые/ошибочные состояния: “нет задач”, “не удалось загрузить”
- **IPC**
- `TEAM_GET_DATA` работает, валидирует аргументы, возвращает `TeamData`
- `TEAM_CHANGE` событие приходит в renderer при изменениях teams/tasks
- **FileWatcher**
- Добавлены `teamsWatcher` и `tasksWatcher` (v7 fix #35)
- В local режиме используют `fs.watch(..., { recursive: true })`
- В SSH режиме эти watchers **не запускаются**
- **Store**
- Есть `selectTeam(teamName)` и `refreshTeamData(teamName)`
- Есть coalesce/throttle 300ms на `TEAM_CHANGE` (v7 fix #46)
- **Качество**
- `pnpm typecheck` проходит
- `pnpm test` проходит (регрессия не допущена)
---
## Выходные изменения (файлы) — что добавляем/меняем
### Новые файлы
- `src/main/services/team/TeamTaskReader.ts`
- `src/main/services/team/TeamInboxReader.ts` (в итерации 02 — только `listInboxNames()`)
- `src/main/services/team/TeamMemberResolver.ts`
- `src/renderer/components/team/TeamDetailView.tsx`
- `src/renderer/components/team/members/MemberList.tsx`
- `src/renderer/components/team/members/MemberCard.tsx`
- `src/renderer/components/team/tasks/TaskList.tsx`
- `src/renderer/components/team/tasks/TaskRow.tsx`
### Изменяемые файлы
- `src/shared/types/team.ts` (расширение типов)
- `src/main/utils/pathDecoder.ts` (+ `getTasksBasePath()`)
- `src/main/services/team/TeamConfigReader.ts` (обновить `listTeams()` под новый `TeamSummary`, + `getConfig(teamName)`)
- `src/main/services/team/TeamDataService.ts` (+ `getTeamData(teamName)`)
- `src/main/ipc/guards.ts` (+ `validateTeamName()` для teamName)
- `src/main/ipc/teams.ts` (+ handler `TEAM_GET_DATA`)
- `src/main/ipc/handlers.ts` (register/remove остаётся; добавить ничего нового кроме импорта константы/инициализации не нужно)
- `src/preload/constants/ipcChannels.ts` (+ `TEAM_GET_DATA`, `TEAM_CHANGE`)
- `src/preload/index.ts` (добавить `teams.getData()` и `teams.onTeamChange()`)
- `src/main/services/infrastructure/FileWatcher.ts` (добавить `teamsWatcher/tasksWatcher`, emit `team-change`)
- `src/main/index.ts` (wire `team-change` forwarding + httpServer.broadcast)
- `src/renderer/store/slices/teamSlice.ts` (расширить slice: selected team + refresh + throttling hooks)
- `src/renderer/store/index.ts``initializeNotificationListeners()` подписка на `api.teams.onTeamChange`)
- `src/renderer/types/tabs.ts` (добавить `type: 'team'` + поле `teamName?: string`)
- `src/renderer/components/layout/PaneContent.tsx` (рендер `TeamDetailView` для `tab.type === 'team'`)
- `src/renderer/components/layout/SortableTab.tsx` (иконка для `team`)
- `src/renderer/components/team/TeamListView.tsx` (клик по карточке → `openTeamTab(team.teamName)`, отображение `team.displayName`)
- `src/renderer/api/httpClient.ts` (добавить заглушки для `getData/onTeamChange`)
---
## Порядок работ (runbook) с контрольными точками
### CP0 — типы компилируются
1) **Shared types**
- Обновить `TeamSummary` на `{ teamName, displayName, ... }` (migration из итерации 01)
- Расширить `src/shared/types/team.ts` новыми типами (см. “Контракт”)
- Расширить `src/shared/types/api.ts` (`TeamsAPI.getData`, `TeamsAPI.onTeamChange`)
2) `pnpm typecheck`
---
### CP1 — backend + IPC возвращают TeamData
3) **Path helpers**
- Добавить `getTasksBasePath()` в `src/main/utils/pathDecoder.ts`:
- `return path.join(getClaudeBasePath(), 'tasks')`
4) **Readers + resolver**
- `TeamConfigReader.listTeams()`:
- `teamName = entry.name` (directory name)
- `displayName = config.name`
- `description`/`memberCount` как раньше
- `TeamConfigReader.getConfig(teamName)` читает `config.json`, возвращает `TeamConfig | null`
- `TeamTaskReader.getTasks(teamName)` читает `~/.claude/tasks/{teamName}`:
- если директории нет → `[]` (v7 fix #38)
- пропускать `.lock`, `.highwatermark`, скрытые файлы
- пропускать задачи со `status === 'deleted'`
- `TeamInboxReader.listInboxNames(teamName)`:
- читает `~/.claude/teams/{teamName}/inboxes`
- возвращает имена пользователей по файлам `*.json` без расширения
- если директории нет → `[]`
- `TeamMemberResolver.resolveMembers(config, inboxNames, tasks)`:
- строит union имён: `config.members[].name` + `inboxNames` + `task.owner`
- вычисляет `taskCount` и `currentTaskId`
- `status` = `'unknown'` для всех
5) **TeamDataService.getTeamData(teamName)**
- `config` обязателен: если нет → throw Error “Team not found”
- `tasks`/`inboxNames` грузятся с graceful fallback и `warnings[]`
- `members` строится резолвером
6) **IPC**
- В `src/main/ipc/guards.ts` добавить `validateTeamName()` (паттерн как `validateSessionId`, но для `teamName`)
- В `src/main/ipc/teams.ts`:
- добавить `TEAM_GET_DATA`
- handler `getData(teamName)`:
- валидирует teamName
- вызывает `teamDataService.getTeamData(teamName)`
- возвращает `IpcResult<TeamData>`
7) **Preload**
- `src/preload/constants/ipcChannels.ts`: добавить `TEAM_GET_DATA`, `TEAM_CHANGE`
- `src/preload/index.ts`:
- `teams.getData(teamName)` через `invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName)`
- `teams.onTeamChange(cb)` подписывается на `TEAM_CHANGE` и возвращает cleanup
8) Быстрая проверка:
- в DevTools: `await window.electronAPI.teams.getData('<teamName>')`
9) `pnpm typecheck`
---
### CP2 — FileWatcher шлёт team-change, store обновляет данные
10) **FileWatcher: teamsWatcher + tasksWatcher**
- В `src/main/services/infrastructure/FileWatcher.ts`:
- добавить `teamsWatcher/tasksWatcher` свойства
- в `stop()/dispose()` закрыть их
- в `ensureWatchers()` в local режиме запускать оба watcherа
- watchers используют `fs.watch(path, { recursive: true })`
- emit: `this.emit('team-change', teamChangeEvent)` (EventEmitter name именно `team-change`)
- события классифицировать:
- teamsWatcher: `config` если `config.json`, `inbox` если внутри `inboxes/`
- tasksWatcher: всегда `task`
- teamName извлекать как первый сегмент пути `filename.split(/[\\/]/)[0]`
- Поведение при отсутствии директории:
- если `~/.claude/teams` или `~/.claude/tasks` отсутствует — это НЕ ошибка, watcher просто не стартует и планирует retry (как todos)
11) **Main: forwarding**
- В `src/main/index.ts``wireFileWatcherEvents`):
- добавить forwarding для `'team-change'`:
- `mainWindow.webContents.send(TEAM_CHANGE /* 'team:change' */, event)`
- `httpServer.broadcast('team-change', event)`
- обязательно cleanup при rewire (как fileChangeCleanup/todoChangeCleanup)
12) **Renderer store: подписка + throttle**
- В `src/renderer/store/index.ts` внутри `initializeNotificationListeners()`:
- подписаться на `api.teams.onTeamChange` (если существует)
- coalesce 300ms:
- всегда `fetchTeams()` (лёгкая операция)
- если активен Team tab данного teamName → вызвать `refreshTeamData(teamName)`
13) `pnpm typecheck`
---
### CP3 — UI: Team tab + MemberList + TaskList
14) **Tabs**
- `src/renderer/types/tabs.ts`:
- добавить `type: 'team'`
- добавить поле `teamName?: string` (инвариант: если `type==='team'`, то строка непустая)
- `src/renderer/components/layout/PaneContent.tsx`:
- `tab.type === 'team'``TeamDetailView teamName={tab.teamName ?? ''}`
- если `teamName` пустой → показываем “Invalid team tab” (error state)
- `src/renderer/components/layout/SortableTab.tsx`:
- добавить иконку для `team`
15) **TeamSlice**
- Расширить `src/renderer/store/slices/teamSlice.ts`:
- `selectedTeamName: string | null`
- `selectedTeamData: TeamData | null`
- `selectedTeamLoading: boolean`
- `selectedTeamError: string | null`
- `selectTeam(teamName)` → вызывает `api.teams.getData`
- `refreshTeamData(teamName)` → re-fetch если выбран тот же teamName
- `openTeamTab(teamName)`:
- ищет существующий `tab.type==='team' && tab.teamName===teamName` во всех panes → фокусирует
- иначе `openTab({ type:'team', label: teamName, teamName })`
16) **UI компоненты**
- `TeamListView`:
- карточка команды кликабельна и вызывает `openTeamTab(team.teamName)`
- заголовок карточки показывает `team.displayName`
- `TeamDetailView`:
- `useEffect`: `selectTeam(teamName)`
- 4 состояния: loading / error / empty / data
- layout: слева `MemberList`, справа `TaskList`
- `MemberList/MemberCard`:
- имя + “unknown” статус + текущая задача (если есть)
- `TaskList/TaskRow`:
- таблица/лист: id, subject, owner, status, blocked (если `blockedBy.length>0`)
17) `pnpm test`
18) Ручная проверка (обязательная)
- Открыть `Teams` → кликнуть команду → открылся Team tab
- Видно участников (включая владельцев задач, даже если их нет в config.members)
- Видны задачи из `~/.claude/tasks/{teamName}`
- Потрогать файл задачи (изменить owner/status) → UI обновился в течение ~300ms1s
---
## Риски и митигации
- **Имя команды ≠ имя директории**: решено контрактом `TeamSummary.teamName` (dir) + `TeamSummary.displayName` (человекочитаемо).
- **fs.watch пропуски событий**: есть existing catch-up scan для sessions; для teams/tasks в этой итерации полагаемся на debounce+coalesce, полноценный catch-up можно добавить позже при необходимости.
- **Шумные события**: coalesce 300ms в renderer, чтобы не спамить refresh.

View file

@ -0,0 +1,266 @@
# Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json`
Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`.
Основание:
- `docs/team-management/implementation.md` (**Iteration 3: Kanban Board**)
- `docs/team-management/kanban-design.md` (колонки, storage, mapping, GC)
- `docs/team-management/research-tasks.md` (ограничения task-файлов, почему не metadata)
---
## Цель итерации
- Во вкладке Team появляется **Kanban** с 5 колонками:
- `TODO`, `IN PROGRESS`, `DONE`**авто-маппинг** из `task.status`
- `REVIEW`, `APPROVED` — управляются **только** через `kanban-state.json`
- Поддерживаем MVP UX: **click-to-move** (select/dropdown на карточке).
- Перемещения пишутся в `~/.claude/teams/{teamName}/kanban-state.json` (atomic write).
- Live refresh уже существующий (`team:change`) обновляет kanban при изменениях tasks/teams.
---
## Не-цели (строго вне scope)
- Drag-and-drop (Phase 2, `@dnd-kit`).
- Отправка сообщений ревьюверу, MessageComposer, ActivityTimeline (это итерация 04).
- Изменение `task.status` (Fix flow) и любые write-path в `~/.claude/tasks/**` (это итерация 04).
- `reviewHistory`, round-robin балансировка ревьюверов (Phase 2).
- Создание новых задач (из-за `.highwatermark` риска, см. `research-tasks.md`).
---
## Контракт итерации (Main → Preload → Renderer)
### Storage: путь и формат
- **Файл**: `~/.claude/teams/{teamName}/kanban-state.json`
- Причина: task `metadata` может быть перезаписан агентом (см. `research-tasks.md`).
Минимальный формат (под MVP):
- `teamName: string`
- `reviewers: string[]` (пока только хранение/чтение; управление — итерация 04)
- `tasks: Record<string, KanbanTaskState>`
`KanbanTaskState` (MVP):
- `column: 'review' | 'approved'`
- `reviewStatus?: 'pending' | 'error'`
- `reviewer?: string | null`
- `errorDescription?: string`
- `movedAt: string` (ISO)
Примечание по scope:
- В итерации 03 мы **пишем только** `reviewStatus: 'pending'` при переходе в `REVIEW`.
- Поля `reviewer` и `errorDescription` закладываем в формат, но не обязаны полноценно использовать до итерации 04.
### Column mapping (единый источник правды)
Логика маппинга колонки для задачи:
1) Если `kanbanState.tasks[task.id]` есть → используем `column` из state (`review|approved`)
2) Иначе:
- `pending``todo`
- `in_progress``in_progress`
- `completed``done`
- `deleted` → не показываем
Полностью соответствует `docs/team-management/kanban-design.md`.
### Shared types (`src/shared/types/team.ts`)
Добавляем типы:
- `KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'`
- `KanbanTaskState`
- `KanbanState`
И расширяем `TeamData`:
- `kanbanState: KanbanState`
### IPC / TeamsAPI
Добавляем канал и метод для обновления kanban state:
- `TEAM_UPDATE_KANBAN = 'team:updateKanban'`
- `TeamsAPI.updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void>`
`UpdateKanbanPatch` (строго фиксируем, без “и так понятно”):
- `{ op: 'set_column'; column: 'review' | 'approved' }`
- `{ op: 'remove' }`
Поведение:
- `set_column: review` → записываем `{ column:'review', reviewStatus:'pending', reviewer:null, movedAt: now }`
- `set_column: approved` → записываем `{ column:'approved', movedAt: now }` (reviewStatus/reviewer можно очистить)
- `remove` → удаляем запись для `taskId` из `kanbanState.tasks`
Требования:
- Валидация `teamName` (уже есть).
- Валидация `taskId` (добавляем `validateTaskId()`).
- IPC возвращает `IpcResult<void>` (единый паттерн проекта).
- Browser-mode: метод должен возвращать понятную ошибку “доступно только в Electron”, как и остальной Teams API.
---
## Definition of Done (DoD)
- **UI**
- В Team tab виден kanban с 5 колонками.
- Карточки задач появляются в корректных колонках по mapping-логике.
- На карточке в `DONE` есть `Move to: REVIEW` (click-to-move).
- На карточке в `REVIEW` есть `Move to: APPROVED` и `Move back` (remove state → вернётся в `DONE` по status).
- В `REVIEW` показывается `ReviewBadge` со статусом `Pending` (manual review; без авто-назначения в итерации 03).
- Пустые состояния: “нет задач”, “не удалось загрузить kanban”.
- **Backend/IPC**
- `TEAM_GET_DATA` возвращает `TeamData` с `kanbanState`.
- `TEAM_UPDATE_KANBAN` обновляет `kanban-state.json` атомарно.
- GC kanban-state выполняется **только после** полной загрузки `tasks` (см. `kanban-design.md`).
- **Live refresh**
- Изменение `kanban-state.json` приводит к refresh team data (через существующий `team:change` → store).
- **Качество**
- `pnpm typecheck` проходит.
- `pnpm test` проходит.
---
## Выходные изменения (файлы)
### Новые файлы
- `src/main/services/team/TeamKanbanManager.ts`
- `src/main/services/team/atomicWrite.ts` (минимум для `kanban-state.json`, расширим в итерации 04 под inbox/task write)
- `src/renderer/components/team/kanban/KanbanBoard.tsx`
- `src/renderer/components/team/kanban/KanbanColumn.tsx`
- `src/renderer/components/team/kanban/KanbanTaskCard.tsx`
- `src/renderer/components/team/kanban/ReviewBadge.tsx`
### Изменяемые файлы
- `src/shared/types/team.ts` (kanban types + `TeamData.kanbanState`)
- `src/shared/types/api.ts` (TeamsAPI.updateKanban)
- `src/preload/constants/ipcChannels.ts` (+ `TEAM_UPDATE_KANBAN`)
- `src/preload/index.ts` (bridge `teams.updateKanban`)
- `src/main/ipc/guards.ts` (+ `validateTaskId()`)
- `src/main/ipc/teams.ts` (+ handler `TEAM_UPDATE_KANBAN`)
- `src/main/services/team/TeamDataService.ts` (подключить kanban manager, GC)
- `src/main/services/team/index.ts` (export `TeamKanbanManager`)
- `src/main/services/infrastructure/FileWatcher.ts` (teamsWatcher: учитывать `kanban-state.json` как `config` change)
- `src/renderer/store/slices/teamSlice.ts` (action `updateKanban`, refresh после write)
- `src/renderer/components/team/TeamDetailView.tsx` (вставить kanban panel)
- `src/renderer/api/httpClient.ts` (browser-mode заглушка для `updateKanban`)
---
## Порядок работ (runbook) с контрольными точками
### CP0 — типы компилируются
1) **Shared types**
- В `src/shared/types/team.ts` добавить kanban-типы и `TeamData.kanbanState`.
- В `src/shared/types/api.ts` добавить `TeamsAPI.updateKanban`.
2) `pnpm typecheck`
---
### CP1 — backend читает/пишет `kanban-state.json`
3) **Atomic write (минимум)**
- `src/main/services/team/atomicWrite.ts`:
- `atomicWriteAsync(targetPath, data)` через `tmp + rename` + `mkdir({ recursive:true })`
- best-effort `fsync` (`open(tmp, 'r+')` → `sync()`), без hard-fail
- EXDEV fallback (copy+unlink) как safety net
4) **TeamKanbanManager**
- `getState(teamName)`:
- читает `~/.claude/teams/{teamName}/kanban-state.json`
- ENOENT → default `{ teamName, reviewers: [], tasks: {} }`
- invalid JSON → default + warning (через `TeamData.warnings`)
- `updateTask(teamName, taskId, patch)`:
- `patch: { column: 'review'|'approved' }` → set state + `movedAt = now`
- `patch: { remove: true }` → delete entry
- write через `atomicWriteAsync`
- `garbageCollect(teamName, validTaskIds)`:
- удаляет записи `tasks[id]` которых нет в `validTaskIds`
- dirty-check: писать файл только если реально что-то удалили
5) **TeamDataService**
- добавляем зависимость `TeamKanbanManager`
- в `getTeamData(teamName)`:
- сначала грузим `tasks`
- затем `kanbanState`
- затем **GC** (по `Set(tasks.map(id))`)
- возвращаем `TeamData` с `kanbanState`
6) `pnpm typecheck`
---
### CP2 — IPC: updateKanban работает end-to-end
7) **Guards**
- `validateTaskId()` в `src/main/ipc/guards.ts` (pattern как `validateTeamName()`):
- строка, только цифры, разумный лимит длины (например 110)
8) **IPC + preload**
- `TEAM_UPDATE_KANBAN` в `src/preload/constants/ipcChannels.ts`
- `src/main/ipc/teams.ts`: handler `updateKanban(teamName, taskId, patch)`:
- validateTeamName + validateTaskId
- вызвать `teamDataService.updateKanban(...)` (новый метод)
- вернуть `IpcResult<void>`
- `src/preload/index.ts`: `teams.updateKanban(...)` через `invokeIpcWithResult<void>()`
9) Быстрая проверка:
- в DevTools: `await window.electronAPI.teams.updateKanban(teamName, taskId, { column: 'review' })`
10) `pnpm typecheck`
---
### CP3 — UI: KanbanBoard виден и управляем
11) **UI компоненты**
- `KanbanBoard` получает:
- `tasks: TeamTask[]`
- `kanbanState: KanbanState`
- `onUpdate(taskId, patch)` → вызывает `teams.updateKanban(...)` через store action
- `KanbanTaskCard`:
- показывает subject / owner / status
- показывает `Move to:` select **только для разрешённых переходов**:
- `DONE → REVIEW` (set column review)
- `REVIEW → APPROVED` (set column approved)
- `REVIEW → DONE` (remove state)
- `APPROVED → DONE` (remove state)
- для `TODO`/`IN PROGRESS` — только read-only (двигать нельзя)
- В колонке `REVIEW` рендерит `ReviewBadge` (в итерации 03 всегда “Pending”)
12) **TeamDetailView layout**
- Фиксируем layout без вариантов (чтобы не расползлось):
- **Left**: `MemberList`
- **Center**: `KanbanBoard`
- **Right**: текущий `TaskList` (из итерации 02) как “сырой список” задач
- Обязательные состояния: loading/error/empty/data.
- UI инвариант: kanban всегда строится из `TeamData.tasks` + `TeamData.kanbanState` (никаких дополнительных fetch).
13) **Live refresh**
- `FileWatcher`:
- в `teamsWatcher` добавить обработку `kanban-state.json` как `type: 'config'` (detail: `kanban-state.json`)
- это гарантирует, что существующий renderer coalesce обновит team data
14) `pnpm test`
15) Ручная проверка
- Открыть Team tab → kanban виден.
- Взять completed задачу → `Move to REVIEW` → появляется в REVIEW и создаётся/обновляется `kanban-state.json`.
- `Move back` → запись удаляется, задача возвращается в DONE.
- Отредактировать `kanban-state.json` вручную → UI обновится.
---
## Риски и митигации
- **GC удалит валидные записи**: запрещено делать GC до чтения tasks. В `TeamDataService` фиксируем порядок (tasks → kanban → GC).
- **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink.
- **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен).
- **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах.

View file

@ -0,0 +1,296 @@
# Итерация 04 — Messaging + Review (Inbox + ReviewDialog)
Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**.
Основание:
- `docs/team-management/implementation.md` (**Iteration 4: Messaging + Review**)
- `docs/team-management/research-inbox.md` (формат, race conditions, messageId verify)
- `docs/team-management/research-messaging.md` (почему inbox, ограничения доставки)
- `docs/team-management/kanban-design.md` (review flow, allowed transitions)
- `docs/team-management/research-tasks.md` (безопасная запись status при Fix)
---
## Цель итерации
- Во вкладке Team появляется **ActivityTimeline** (история сообщений) и **MessageComposer** (отправка DM).
- Реализован review-flow:
- **Request Review**: перевод задачи в `REVIEW` + (если есть reviewer) уведомление в его inbox.
- **Approve**: перевод задачи в `APPROVED`.
- **Request Changes**: опциональный комментарий → задача возвращается исполнителю:
- kanban-state запись удаляется (задача вернётся в `IN PROGRESS` по `task.status`)
- `task.status` становится `in_progress` (atomic write + verify)
- отправляется сообщение ownerу с описанием правок
- Live refresh: изменения inbox/task/kanban отражаются в UI (уже существующий `team:change` + coalesce).
---
## Не-цели (строго вне scope)
- Hard interrupt (прерывание mid-turn) — ограничение платформы, Phase 2.
- Архивация inbox / очистка истории / JSONL формат — Phase 2.
- Полноценный drag-and-drop для review — Phase 2 (пока click-to-move/кнопки).
- Round-robin балансировка reviewerов и reviewHistory — Phase 2.
- Любые write-path, связанные с созданием задач и `.highwatermark`.
---
## Контракт итерации (Main → Preload → Renderer)
### Shared types (`src/shared/types/team.ts`)
Добавляем/расширяем:
- `InboxMessage` (как в `research-inbox.md`):
- `from: string`
- `text: string` (plain text или JSON-строка)
- `timestamp: string` (ISO)
- `read: boolean`
- `summary?: string`
- `color?: string`
- `messageId?: string` (в наших исходящих обязателен)
- `SendMessageRequest`:
- `member: string`
- `text: string`
- `summary?: string`
- `from?: 'user' | string` (по умолчанию `'user'`)
- `SendMessageResult`:
- `deliveredToInbox: boolean` (означает “записано в файл и verify прошёл”)
- `messageId: string`
Расширяем `TeamData`:
- `messages: InboxMessage[]` (агрегированные по всем inbox, отсортированные по `timestamp`)
Расширяем `ResolvedTeamMember`:
- `status: 'active' | 'idle' | 'terminated' | 'unknown'`
- `lastActiveAt: string | null`
- `messageCount: number`
- `color?: string`
Правила статуса (из `docs/team-management/README.md`):
- `ACTIVE`: last activity < 5 минут
- `IDLE`: last activity ≥ 5 минут
- `TERMINATED`: получен shutdown-event с approve=true (в доках встречаются разные названия, поэтому считаем terminated если structured `text` после JSON.parse имеет:
- `type === 'shutdown_response' && approve === true`, **или**
- `type === 'shutdown_approved'`, **или**
- `type === 'shutdown_response' && approved === true`
- если данных нет → `unknown`
### IPC каналы
В `src/preload/constants/ipcChannels.ts` добавляем:
- `TEAM_SEND_MESSAGE = 'team:sendMessage'`
- `TEAM_REQUEST_REVIEW = 'team:requestReview'`
- `TEAM_UPDATE_KANBAN` уже существует с итерации 03 и **расширяется** (см. ниже) для `request_changes`.
Важно: в v7-плане перечислены именно `team:sendMessage`, `team:requestReview`, `team:updateKanban`. В этой итерации не вводим новые каналы сверх этого набора.
### TeamsAPI (`src/shared/types/api.ts`)
Расширяем:
- `sendMessage(teamName: string, req: SendMessageRequest): Promise<SendMessageResult>`
- `requestReview(teamName: string, taskId: string): Promise<void>`
- `updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void>` (из итерации 03, расширяем patch)
#### UpdateKanbanPatch — расширение в итерации 04 (без двусмысленностей)
К существующим операциям добавляем:
- `{ op: 'request_changes'; comment?: string }`
Семантика `request_changes` (всё внутри main, как единая операция):
1) Удалить kanban-state запись для taskId (remove)
2) Обновить task файл: `status = 'in_progress'` (atomic write + verify)
3) Отправить сообщение ownerу задачи с комментарием (если owner отсутствует → ошибка)
---
## Definition of Done (DoD)
- **Messaging UI**
- В Team tab есть ActivityTimeline (список сообщений) и MessageComposer.
- Можно выбрать участника и отправить сообщение → появляется “Delivered” (или ошибка).
- Сообщение записывается в `~/.claude/teams/{team}/inboxes/{member}.json` с `messageId`.
- **Review UI**
- На задачах в `DONE` есть действие “Request Review”.
- В `REVIEW` есть кнопки “Approve” и “Request Changes”.
- “Request Changes” открывает диалог с опциональным комментарием.
- **Backend safety**
- Inbox write: atomic write + verify `messageId` + retry/backoff (по плану ниже).
- Task status write (Fix): atomic write + verify `status` + warning в UI при конфликте.
- **Качество**
- `pnpm typecheck` проходит.
- `pnpm test` проходит.
---
## Выходные изменения (файлы)
### Новые файлы
- `src/main/services/team/TeamInboxWriter.ts` (write-path: atomic + verify + retry + lock)
- `src/main/services/team/TeamTaskWriter.ts` (минимум: `updateStatus(teamName, taskId, status)`), чтобы не смешивать чтение и запись
- `src/renderer/components/team/activity/ActivityTimeline.tsx`
- `src/renderer/components/team/activity/ActivityItem.tsx`
- `src/renderer/components/team/activity/MessageComposer.tsx`
- `src/renderer/components/team/dialogs/ReviewDialog.tsx`
### Изменяемые файлы
- `src/shared/types/team.ts` (InboxMessage, SendMessage*, member status, `TeamData.messages`)
- `src/shared/types/api.ts` (TeamsAPI methods)
- `src/preload/constants/ipcChannels.ts` (+ 3 канала)
- `src/preload/index.ts` (bridge для sendMessage/requestReview/updateKanban)
- `src/main/ipc/guards.ts` (+ `validateMemberName()`, `validateFromField()` если потребуется)
- `src/main/ipc/teams.ts` (+ handlers)
- `src/main/services/team/TeamInboxReader.ts` (расширить: `getMessagesFor()`, `getMessages()`)
- `src/main/services/team/TeamMemberResolver.ts` (status/messageCount/color/lastActiveAt + terminated detection)
- `src/main/services/team/TeamDataService.ts` (подтянуть messages + review methods; опираться на kanban из итерации 03; в requestReview — sendMessage первому reviewer при наличии)
- `src/main/services/team/index.ts` (exports)
- `src/renderer/store/slices/teamSlice.ts` (actions + sending states)
- `src/renderer/components/team/TeamDetailView.tsx` (layout: members + kanban + activity)
- `src/main/services/infrastructure/FileWatcher.ts` (ничего нового: inbox/task/kanban уже должны попадать в `team-change`)
---
## Порядок работ (runbook) с контрольными точками
### CP0 — типы компилируются
1) Обновить `src/shared/types/team.ts` и `src/shared/types/api.ts` (контракты выше).
2) `pnpm typecheck`
---
### CP1 — Inbox read/write в main готов (без UI)
3) **Inbox read**
- `listInboxNames(teamName)` (уже есть/или оставить) — список `*.json` без расширения
- `getMessagesFor(teamName, member)`:
- ENOENT → `[]`
- invalid JSON → `[]` + warning
- `getMessages(teamName)`:
- читает все inbox `*.json` (параллельно, best-effort)
- merge в один массив
- сортировка по `timestamp` (desc)
4) **Inbox write: atomic + verify + retry**
Реализация должна следовать `research-inbox.md`:
- Каждое сообщение, которое пишет приложение, включает:
- `messageId = crypto.randomUUID()`
- `from = 'user'` (по умолчанию)
- `read = false`
- `timestamp = new Date().toISOString()`
Алгоритм (в main):
```
read inbox JSON array (если нет → [])
append message
atomicWriteAsync(tmp + rename)
read back
verify messageId exists
если не найден → retry (до 3) с backoff 10/20/40ms
```
Параллелизм:
- внутри процесса сериализуем записи в один `inboxPath` через `withInboxLock(inboxPath, fn)`,
чтобы два IPC вызова не затёрли друг друга.
Fallback по `from`:
- делаем это **не магией**, а контрактом API:
- `SendMessageRequest.from` опционален, но если задан — валидируем (`validateFromField` или reuse `validateMemberName`)
- UI в этой итерации **не обязано** показывать выбор from; по умолчанию отправляем `from: 'user'`
- для отладки можно вызывать через DevTools `sendMessage(..., { from:'debug-user', ... })`
5) `pnpm test` (если добавлялись новые unit-тесты) и `pnpm typecheck`
---
### CP2 — Review actions в main + IPC
6) **Task status write (Fix)**
Новый `TeamTaskWriter.updateStatus(teamName, taskId, status)`:
- читает `~/.claude/tasks/{teamName}/{taskId}.json`
- меняет только `status`
- пишет через `atomicWriteAsync`
- перечитывает и проверяет, что `status` равен нужному
- при конфликте (агент перезаписал) → throw, чтобы UI показал warning
7) **TeamDataService**
- `getTeamData(teamName)` теперь возвращает также `messages`
- `requestReview(teamName, taskId)`:
- `updateKanban(taskId, { op:'set_column', column:'review' })` (reviewStatus: pending)
- выбрать reviewer:
- если `kanbanState.reviewers[]` не пуст → первый элемент (round-robin — Phase 2)
- иначе reviewer отсутствует (manual review)
- если reviewer выбран → `sendMessage(reviewer, "...")`
- `approve` делаем напрямую через `updateKanban(taskId, { op:'set_column', column:'approved' })`
- `request_changes` делаем через `updateKanban(taskId, { op:'request_changes', comment })` (см. семантику patch выше)
8) **IPC**
- `TEAM_SEND_MESSAGE`: принимает `teamName`, `SendMessageRequest`
- `TEAM_REQUEST_REVIEW`: принимает `teamName`, `taskId`
- `TEAM_UPDATE_KANBAN`: принимает `teamName`, `taskId`, `UpdateKanbanPatch` (расширенный)
Guards:
- `validateMemberName()` обязателен (защита от path traversal, member используется в пути inbox файла).
- `validateTaskId()` уже есть с итерации 03.
9) `pnpm typecheck`
---
### CP3 — UI: ActivityTimeline + MessageComposer + ReviewDialog
10) **Layout**
- Team tab становится 3-панельным:
- слева Members
- центр Kanban
- справа Activity (messages + composer)
Чтобы не потерять доступ к “сырому списку задач”, фиксируем простой UI-компромисс:
- правый panel становится **табами**: `Activity` и `Tasks`
- по умолчанию открываем `Activity`, но `Tasks` остаётся доступным (и переиспользует текущий `TaskList`)
11) **ActivityTimeline**
- Рендерит `TeamData.messages` (последние N, например 200) с:
- цветной dot (если есть `color`)
- `from`, `summary` (или короткий preview `text`)
- timestamp (relative time)
12) **MessageComposer**
- select получателя (из `members`)
- textarea (минимум 12 строки)
- send → вызывает store action → IPC `sendMessage`
- состояния:
- sending / delivered / error
13) **ReviewDialog**
- открывается при “Request Changes”
- comment optional
- submit → `updateKanban(..., { op:'request_changes', comment })`
14) Ручная проверка
- Отправить сообщение → файл inbox обновился, message появился в ActivityTimeline после refresh.
- Нажать Request Review → задача в REVIEW, при наличии reviewerа в `kanban-state.reviewers` ему ушло сообщение.
- Approve → задача в APPROVED.
- Request Changes → задача ушла исполнителю (status=in_progress) + отправлено сообщение ownerу.
15) `pnpm test`
---
## Риски и митигации
- **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`.
- **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail.
- **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05).

View file

@ -0,0 +1,207 @@
# Итерация 05 — Testing + Polish (production-ready)
Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks.
Основание:
- `docs/team-management/implementation.md` (**Iteration 5: Testing + Polish**)
- `docs/team-management/research-inbox.md` (race + verify, messageId)
- `docs/team-management/research-tasks.md` (task write verify)
- `docs/team-management/kanban-design.md` (GC order, mapping)
---
## Цель итерации
- Добавить **тесты** на:
- kanban-state чтение/запись/GC
- inbox read/write (atomic + verify + retry + lock)
- task status write verify (request changes)
- IPC guards и handlers (валидаторы + happy/err paths)
- store coalesce/throttle на `team:change`
- Улучшить UX:
- ясные empty/error/loading состояния для всех team-panels
- аккуратные сообщения об ошибках write-path (inbox/task)
- ограничение и оптимизация рендера больших списков
- Подготовить основу для Phase 2 (D&D, archive, round-robin), не реализуя их.
---
## Не-цели
- E2E/UI automation (можно позже отдельным треком).
- Полный performance-профайлинг приложения.
- Редизайн UI — только точечный polish.
---
## Definition of Done (DoD)
- **Тесты**
- Добавлены новые тесты (см. ниже), все проходят локально.
- `pnpm test` зелёный.
- `pnpm typecheck` зелёный.
- **UX**
- Нет “пустых серых экранов”: каждый экран имеет понятное состояние.
- Ошибки write-path показываются пользователю понятным текстом.
- **Надёжность**
- Все write-path используют atomic write.
- Верификация после записи включена для inbox и task status.
---
## Тестовая стратегия (конкретно что проверяем)
### 1) TeamKanbanManager
- `getState()`:
- ENOENT → default state
- corrupted JSON → default + warning (если warnings протаскиваются)
- `updateTask()`:
- set column → появляется запись с `movedAt`
- remove → запись исчезает
- `garbageCollect()`:
- удаляет только невалидные taskId
- dirty-check: не пишет файл, если удалять нечего
- **GC order**:
- тест на `TeamDataService.getTeamData()` что GC вызывается **после** чтения tasks (контракт из `kanban-design.md`)
### 2) Inbox write-path
- `sendMessage()`:
- пишет `messageId`, `timestamp`, `read:false`
- после записи verify находит `messageId`
- Race simulation (упрощённо):
- смоделировать “потерю” (подменой read-back) → retry с backoff вызывается нужное число раз и в итоге error/ok как ожидается
- `withInboxLock()`:
- два параллельных send в один inbox → оба сообщения сохраняются
### 3) Task status write-path
- `updateStatus()`:
- меняет только `status`
- verify после write подтверждает изменение
- конфликт (подменить файл после write) → ошибка, UI сможет показать warning
### 4) IPC / Guards
- `validateTeamName`, `validateTaskId`, `validateMemberName`:
- reject path traversal (`../`, слеши, пробелы, пустые)
- accept валидные имена
- Handlers:
- invalid args → `IpcResult.success=false`
- happy path → `success=true`
### 5) Renderer store
- Coalesce/throttle на `team:change`:
- серия событий <300ms один refresh
- если team не виден → не делает лишние `getData` (но обновляет list если это заложено)
---
## Рекомендуемая структура тестов (адаптировать под текущее дерево)
```
test/
├── main/
│ ├── services/team/
│ │ ├── TeamKanbanManager.test.ts
│ │ ├── TeamInboxReader.test.ts # read (list + merge + sort)
│ │ ├── TeamInboxWriter.test.ts # sendMessage (atomic + verify + retry + lock)
│ │ ├── TeamTaskWriter.test.ts
│ │ └── TeamDataService.test.ts # orchestration + GC order
│ └── ipc/
│ ├── guards.test.ts # расширить (team/task/member/from)
│ └── teams.test.ts # новые каналы/handlers
├── renderer/
│ └── store/
│ └── teamSlice.test.ts # refresh, send, review actions, throttle
└── fixtures/team/
├── teams/
│ └── my-team/
│ ├── config.json
│ ├── kanban-state.json
│ └── inboxes/
│ ├── alice.json
│ └── reviewer.json
└── tasks/
└── my-team/
├── 12.json
├── 13.json
├── .lock
└── .highwatermark
```
Важно:
- `.lock`/`.highwatermark` должны присутствовать в fixture, чтобы гарантировать “пропуск мусорных файлов”.
- Не используем реальный `~/.claude` в тестах.
---
## UX/Polish checklist
- **Empty states**
- Нет tasks → “Нет задач в этой команде”
- Нет сообщений → “Нет сообщений” + подсказка “Отправь сообщение”
- Нет reviewers → в REVIEW показывать “Manual review” (без авто-assign)
- **Error states**
- Inbox write verify failed → “Сообщение записано, но не подтверждено (race). Попробуй отправить ещё раз.”
- Task status verify failed → “Не удалось обновить статус задачи (возможен конфликт с агентом).”
- **Loading**
- Skeleton для TeamDetailView (3 панели)
- **Performance**
- ActivityTimeline: рендерим только последние N (например 200), кнопка “Показать ещё” (не обязателен infinite scroll)
- Если tasks > 200: предусмотреть простую фильтрацию по owner/status (минимально)
- **Accessibility**
- Кнопки имеют понятные `aria-label`
- Навигация по клавиатуре: focus ring виден
---
## Порядок работ (runbook)
### CP0 — стабилизируем контракты
- Пройтись по типам `TeamData`, `InboxMessage`, kanban types.
- `pnpm typecheck`
### CP1 — тесты main/services
- Добавить fixtures.
- Написать тесты для kanban/inbox/task writer/service orchestration.
- `pnpm test` (обязательно запускать сразу после добавления/правки тестов)
### CP2 — тесты IPC/guards
- Расширить `guards.test.ts`
- Добавить `teams.test.ts` для новых handlerов
- `pnpm test`
### CP3 — тесты store + polish
- teamSlice tests на coalesce и actions.
- UI polish по чеклисту.
- `pnpm typecheck` + `pnpm test`
---
## Риски и митигации
- **Тесты станут “флейковыми”** из-за таймеров/throttle:
- использовать fake timers и явный flush.
- **Слишком много моков Electron**:
- держать единый helper mock `electronAPI` в `test/mocks/`.
- **Большие списки сообщений**:
- ограничение N + простой “show more” снимают риск O(n) перерендера.
---
## Минимальный чеклист регрессии (ручной)
1) Teams → открыть команду → kanban/задачи/участники видны
2) Move DONE → REVIEW → состояние сохранилось (после перезапуска app остаётся)
3) Отправить сообщение → delivered + появилось в Activity
4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло
5) Request Changes → task.status стал `in_progress` + owner получил сообщение
6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с

View file

@ -0,0 +1,361 @@
# Итерация 06 — Team Provisioning (Create Team из UI через CLI + File Monitoring)
Эта итерация добавляет **создание команды из UI**: пользователь задаёт параметры команды, приложение запускает CLI-процесс, затем **верифицирует результат через файлы** в `~/.claude/teams/**` и сообщает прогресс в UI.
Основание:
- `docs/team-management/research-cli-orchestration.md` (v2) — итоговая схема spawn + monitoring, PATH на macOS
- `docs/team-management/README.md` — важные ограничения формата (`config.json` members неполный)
- Текущая реализация Team Management (итерации 0104): `team:list`, `team:getData`, `team:change`, `team:sendMessage`, `team:requestReview`, `team:updateKanban`
---
## Цель итерации
- В `Teams` появляется кнопка **Create Team**.
- Открывается диалог создания команды:
- `teamName` (канонический ID = имя директории)
- `displayName` (пишется в `config.json.name`, по умолчанию = `teamName`)
- `description` (опционально)
- `members` (минимум 1 имя; роли опциональны — только для prompt)
- `cwd` (рабочая директория проекта; выбирается через нативный folder picker)
- По нажатию Create:
- main process валидирует входные данные и **находит путь к CLI бинарнику** (macOS PATH fix).
- запускает CLI в **одноходовом** режиме (`-p`) с env `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`.
- UI получает **стрим прогресса**: `validating → spawning → monitoring → verifying → ready/failed/cancelled`.
- Команда считается созданной (готовой к просмотру в UI), когда:
- `~/.claude/teams/{teamName}/config.json` существует и является валидным JSON, содержащим `name: string`.
- `team:list` начинает возвращать эту команду.
---
## Не-цели (строго вне scope)
- Создание/управление задачами через provisioning (TaskCreate, seed tasks) — только в prompt как best-effort, без жёсткой верификации.
- Гарантия, что teammates “точно запущены” и отвечают — MVP provisioning считает успехом создание команды (config), остальное — последующие итерации/полировка.
- Поддержка provisioning в SSH-context (remote) — **только local context**.
- Парсинг stdout CLI (`stream-json`, NDJSON) — **не делаем**, чтобы исключить backpressure/encoding/64KB буферы.
- Авто-удаление существующих директорий команды.
- Интеграция `leadSessionId` с session viewer (это отдельная задача).
---
## Допущения и ограничения (явно)
- Provisioning работает **только в local context**.
- CLI provisioning не гарантирует, что все участники сразу “живые” и отвечают. MVP-критерий успеха — **созданная команда** (валидный `config.json`).
- `config.json.members` **не является** источником полного списка участников. Полный roster будет восстанавливаться как и сейчас: `union(config members + inbox filenames + task owners)`.
- Любые частичные артефакты на диске после `cancelled/failed` **не удаляются автоматически**.
---
## Контракт итерации (Main → Preload → Renderer)
### IPC каналы (flat `export const` в `src/preload/constants/ipcChannels.ts`)
- `TEAM_CREATE = 'team:create'`
- `TEAM_PROVISIONING_STATUS = 'team:provisioningStatus'`
- `TEAM_PROVISIONING_PROGRESS = 'team:provisioningProgress'` (event main → renderer)
### Shared types (`src/shared/types/team.ts`)
Добавляем:
```ts
export type TeamProvisioningState =
| 'idle'
| 'validating'
| 'spawning'
| 'monitoring'
| 'verifying'
| 'ready'
| 'failed'
| 'cancelled';
export interface TeamProvisioningMemberInput {
name: string;
role?: string;
}
export interface TeamCreateRequest {
teamName: string; // canonical ID (directory name)
displayName?: string; // config.json.name (default = teamName)
description?: string;
members: TeamProvisioningMemberInput[];
cwd: string; // project working directory
}
export interface TeamCreateResponse {
runId: string;
}
export interface TeamProvisioningProgress {
runId: string;
teamName: string;
state: Exclude<TeamProvisioningState, 'idle'>;
message: string;
startedAt: string; // ISO
updatedAt: string; // ISO
pid?: number;
error?: string;
warnings?: string[];
}
```
### Семантика IPC (без двусмысленностей)
- `team:create(request)`:
- валидирует `teamName`, `cwd`, `members` (см. правила валидации ниже)
- если команда уже существует (`~/.claude/teams/{teamName}/config.json` существует) → `IpcResult` error (“Team already exists”)
- если provisioning уже запущен для этого `teamName``IpcResult` error (“Provisioning already running”)
- иначе стартует provisioning и возвращает `{ runId }`
- `team:provisioningStatus(runId)`:
- возвращает текущий snapshot прогресса (или ошибку “Unknown runId”)
- `team:provisioningProgress` (event):
- отправляется при каждом переходе state machine (и при важных milestones внутри шага)
- payload всегда включает `runId`, чтобы UI мог безопасно фильтровать
### Правила валидации (без двусмысленностей)
- `teamName`:
- строка, trim не пустой
- только `[a-z0-9-]`, длина 1..64
- не может начинаться/заканчиваться на `-`
- не может содержать `..`, `/`, `\`, пробелы
- `cwd`:
- абсолютный путь
- должен существовать и быть директорией
- `members[].name`:
- строка, trim не пустой
- валидируем тем же правилом, что и для inbox member name (чтобы любые best-effort проверки файлов не открывали путь traversal)
- дубликаты по имени запрещены
---
## Архитектура provisioning (main)
### State machine
```
idle
→ validating
→ spawning
→ monitoring
→ verifying
→ ready
↘ failed
↘ cancelled
```
### Binary resolution (macOS PATH проблема)
Добавляем `ClaudeBinaryResolver` (main):
- сперва пробуем `claude` в текущем PATH (на случай запуска из терминала)
- затем common paths (`/usr/local/bin/claude`, `/opt/homebrew/bin/claude`, `~/.npm-global/bin/claude`, nvm paths, Windows `.cmd`)
- если не найдено → понятная ошибка для UI (“Claude CLI не найден; установите или укажите путь”)
- **запрещено** использовать `shell: true` для любых вызовов
### Spawn contract (одноходовый, без stdout)
Запуск:
- args: `['-p', prompt, '--output-format', 'text']`
- env: `...process.env` + `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
- `cwd = request.cwd`
- `stdio = ['ignore', 'ignore', 'pipe']`
- stdin ignored (неинтерактивно)
- stdout ignored (не читаем и не рискуем deadlockом)
- stderr piped (только для диагностики)
**Важно (без двусмысленностей): stderr pipe обязателен к drainу.**
- мы **всегда** читаем `child.stderr.on('data')` и:
- складываем в ring-buffer (например, последние 64KB), чтобы не держать бесконечный лог в памяти
- используем для `failed.error` (обрезанный, безопасный текст)
### Таймауты и зависания (без двусмысленностей)
- Если процесс не завершился за `T_TOTAL` (например 2 минуты) → переводим в `failed` с ошибкой “Timed out waiting for CLI”.
- В тексте ошибки обязателен хинт:
- CLI может требовать интерактивную авторизацию/онбординг
- нужно запустить `claude` один раз из терминала и завершить онбординг, затем повторить provisioning
### Cancel semantics (без двусмысленностей)
- Cancel доступен только в состояниях: `spawning | monitoring | verifying`.
- При cancel:
- если child process ещё существует → посылаем завершение (best-effort)
- state становится `cancelled`
- любые созданные файлы/директории **не удаляем**
### Monitoring + verifying (через файлы)
Monitoring триггерится после `exit code === 0` (или сразу, если config появляется раньше).
Проверки:
- `config.json` существует по пути `~/.claude/teams/{teamName}/config.json`
- JSON валиден
- `config.name` — непустая строка
Оговорка: `config.json.members` **не является** источником полного состава команды (см. `docs/team-management/README.md`). Поэтому эта итерация **не требует** полного roster в config как критерий успеха.
Дополнительная best-effort верификация (не блокирует ready):
- если prompt включает “bootstrap DM” каждому участнику, то ожидаем появление `inboxes/{member}.json`
- при таймауте: `ready` + `warnings: ['Some inboxes not created yet']`
### Разрешение конфликтов сигналов (без двусмысленностей)
- Если `config.json` стал валидным и прошёл verify, но процесс CLI завершился с non-zero:
- итоговое состояние: `ready`
- `warnings`: добавить строку вида “CLI exited with code X after config was created”
- Если процесс CLI завершился `0`, но `config.json` не появился/невалиден до таймаута verify:
- итоговое состояние: `failed`
- error: “TeamCreate did not produce a valid config.json”
---
## Изменения в UI (renderer)
### Точка входа
- В `TeamListView` добавляем кнопку **Create Team**.
- В browser-mode и SSH-context:
- кнопка disabled + подпись “Доступно только в local Electron-режиме”.
### CreateTeamDialog (минимальный UX)
- Валидация на клиенте:
- `teamName`: kebab-case `[a-z0-9-]` (сообщение об ошибке рядом с полем)
- `members`: минимум 1; имена не пустые
- `cwd`: обязателен
- После старта provisioning:
- показываем шаги прогресса
- показываем кнопку Cancel (если процесс ещё в `spawning/monitoring/verifying`)
- при `ready`: кнопка “Open Team” (открывает team tab)
- при `failed`: показываем error + “Retry” (повторно вызывает create; если команда уже создана — предлагаем открыть)
### Store contract (Zustand)
Добавляем в `teamSlice`:
- `provisioningRuns: Record<runId, TeamProvisioningProgress>`
- `activeProvisioningRunId: string | null`
- actions:
- `createTeam(req): Promise<string /* runId */>`
- `cancelProvisioning(runId): Promise<void>` (если делаем cancel в этой итерации)
- subscribe/unsubscribe на `team:provisioningProgress`
---
## Выходные изменения (файлы)
### Новые файлы (ожидаемо)
- `src/main/services/team/ClaudeBinaryResolver.ts`
- `src/main/services/team/TeamProvisioningService.ts`
- `src/shared/types` (расширение `team.ts` provisioning-типами)
- `src/renderer/components/team/dialogs/CreateTeamDialog.tsx`
### Изменяемые файлы (ожидаемо)
- `src/preload/constants/ipcChannels.ts` (новые каналы)
- `src/shared/types/api.ts` (TeamsAPI: `createTeam`, `getProvisioningStatus`, `onProvisioningProgress`)
- `src/preload/index.ts` (bridge)
- `src/main/ipc/teams.ts` (+ handlers)
- `src/main/ipc/guards.ts` (валидация `cwd` и members, если нужно)
- `src/renderer/components/team/TeamListView.tsx` (кнопка + диалог)
- `src/renderer/store/slices/teamSlice.ts` (state + actions)
---
## Порядок работ (runbook) с контрольными точками
### CP0 — типы и IPC компилируются
1) Расширить shared types (provisioning contract).
2) Добавить IPC channels constants.
3) `pnpm typecheck`
### CP1 — main provisioning skeleton (без UI)
4) `ClaudeBinaryResolver` (без `shell: true`).
5) `TeamProvisioningService`:
- lock по `teamName`
- state machine
- spawn contract (`stdio` + stderr drain + timeout)
- monitoring/verifying по `config.json`
6) IPC handlers `team:create`, `team:provisioningStatus`, progress event.
7) `pnpm test` + `pnpm typecheck`
### CP2 — preload bridge + renderer store
8) `TeamsAPI` расширение + preload bridge.
9) `teamSlice` actions + подписка на progress event.
10) `pnpm typecheck`
### CP3 — UI dialog
11) `CreateTeamDialog` + интеграция в `TeamListView`.
12) Ручная проверка (см. ниже).
13) `pnpm test` + `pnpm typecheck`
---
## Manual regression checklist (обязательно)
1) Local mode: Create Team → прогресс до `ready`, команда появляется в списке.
2) Ошибка “CLI не найден” на macOS Finder-запуске воспроизводится → UI показывает понятное сообщение.
3) Cancel во время `spawning/monitoring`:
- UI показывает `cancelled`
- процесс не остаётся “висячим”
4) Повторный create с тем же `teamName`, когда команда уже существует → понятная ошибка без перезаписи.
5) После `ready` можно открыть Team tab и увидеть данные (пусть даже roster заполнится постепенно).
---
## Appendix A — One-shot prompt template (одноходовый bootstrap)
Ниже шаблон prompt, который должен отработать в одном turn в режиме `-p`.
**Переменные (подставляет приложение):**
- `{teamName}` — directory name
- `{displayName}` — UI name
- `{description}` — описание (может быть пустым)
- `{members}` — список участников (строки), где каждый элемент:
- `name` (валидирован)
- `role` (опционально)
**Шаблон:**
```
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
Goal: Provision a Claude Code agent team.
Constraints:
- Use ONLY these tools: TeamCreate, SendMessage, TaskCreate.
- Do not use Read/Edit/Bash.
- Do not output large logs. Keep assistant text minimal.
Steps (must be executed in this order):
1) Call TeamCreate to create team "{teamName}" with:
- display name: "{displayName}"
- description: "{description}"
- members: the list below (include roles if present)
2) For each member in the list, call SendMessage to send a short bootstrap DM:
- summary: "Bootstrap"
- text: "Team \"{displayName}\" is ready. Reply with 'OK' when you are available."
3) (Best-effort) Create 1 initial task via TaskCreate:
- subject: "Bootstrap check"
- description: "Confirm team provisioning succeeded. Each member replied OK."
- owner: leave empty (unassigned)
Members:
{members}
```
Примечания:
- Bootstrap DM нужен, чтобы с высокой вероятностью создались `inboxes/{member}.json`.
- TaskCreate — best-effort; отсутствие задач НЕ блокирует `ready`.

View file

@ -0,0 +1,493 @@
# Research: CLI Orchestration for Team Provisioning
## Status: RESEARCH COMPLETE (v2)
Результаты исследования 10 агентов (3 + 4 + 3) по запуску Claude CLI для создания команд.
Раунд 3 — верификация и финальная архитектура.
---
## 1. Варианты запуска Claude CLI
### 1.1 Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`)
**Пакет**: `@anthropic-ai/claude-agent-sdk` v0.2.44 (npm)
**Repo**: `github.com/anthropics/claude-agent-sdk-typescript`
**Статус**: Official, maintained by Anthropic, pre-1.0
#### API
```typescript
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const msg of query({
prompt: "Create a team to work on X",
options: {
allowedTools: ["TeamCreate", "TaskCreate", "SendMessage", "Read", "Edit", "Bash"],
model: "opus",
maxTurns: 250,
cwd: "/path/to/project",
permissionMode: "acceptEdits", // или "bypassPermissions"
}
})) {
console.log(msg); // SDKMessage — structured
}
```
#### Характеристики
| Параметр | Значение |
|----------|----------|
| Startup overhead | ~12 сек per query() call |
| Hot process reuse | НЕТ (issue #34 в SDK repo) |
| Internal mechanism | Спавнит Claude Code CLI как subprocess |
| Team tools | Доступны с `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` |
| Streaming | AsyncGenerator<SDKMessage> |
| Multi-turn | Stateless — каждый query() = новая сессия |
| Node.js | 18+ |
| Electron compatible | Да (main process only) |
#### V2 Preview (simpler API)
```typescript
import { unstable_v2_prompt } from "@anthropic-ai/claude-agent-sdk";
const result = await unstable_v2_prompt("What is 2 + 2?");
```
#### Проблемы
- **12 сек startup** — непригоден для real-time UX
- **No hot reuse** — каждый вызов = new process (issue #34)
- **Pre-1.0** — API может измениться
- **Team features experimental** — документированные issues:
- Session resumption breaks team state
- Task status can lag
- Shutdown slow
- One team per session
### 1.2 CLI с `--output-format stream-json`
**Флаги**:
| Флаг | Опции | Описание |
|------|-------|----------|
| `--output-format` | `text`, `json`, `stream-json` | Формат stdout |
| `--input-format` | `text`, `stream-json` | Формат stdin |
| `-p "prompt"` | string | Non-interactive (print mode) |
| `--verbose` | flag | Подробный вывод |
#### Stream-JSON Protocol (NDJSON)
Каждая строка — полный JSON object:
```json
{"type":"init","session_id":"abc123","timestamp":"2024-01-01T00:00:00Z"}
{"type":"message","role":"assistant","content":[{"type":"text","text":"Analyzing..."}]}
{"type":"tool_use","name":"TeamCreate","input":{"team_name":"my-team","description":"..."}}
{"type":"tool_result","output":"Team created successfully"}
{"type":"result","status":"success","duration_ms":1234}
```
#### Можно детектить tool_use events
```bash
claude -p "Create a team" --output-format stream-json | \
jq 'select(.type == "tool_use" and .name == "TeamCreate")'
```
#### Проблемы
- **Баг #5034**: `--input-format stream-json` дублирует entries в session .jsonl при multi-turn
- **Stateful parsing**: tool_result не имеет поля `previous_event` → нужна корреляция вручную
- **stdio buffer**: 64KB platform limit → deadlock если не drain'ить
- **Encoding**: UTF-8 multi-byte может порваться на chunk boundary
- **Backpressure**: если не читаем stdout → subprocess зависает
### 1.3 CLI с file-based monitoring (без парсинга stdout)
```bash
spawn(claudePath, [prompt], {
stdio: ['ignore', 'ignore', 'pipe'], # stdout ИГНОРИРУЕМ, stderr для ошибок
})
```
Мониторинг через FileWatcher:
```
~/.claude/teams/{name}/config.json → team created/updated
~/.claude/teams/{name}/inboxes/*.json → messages
~/.claude/tasks/{name}/*.json → tasks
~/.claude/projects/{path}/{id}.jsonl → session data (already parsed by app)
```
#### Преимущества
- JSONL = stable API contract (не меняется между версиями CLI)
- FileWatcher уже есть в проекте (retry, SSH polling, debounce)
- Crash-safe: файлы на диске сохраняются
- No buffer overflow: читаем файлы, не pipes
- Масштабируется: один FileWatcher для N агентов
---
## 2. Проблема PATH на macOS
### Суть проблемы
Когда Electron запускается из macOS Finder (не из терминала), `process.env.PATH` минимальный:
```
/usr/bin:/bin:/usr/sbin:/sbin
```
**Отсутствуют**: `/usr/local/bin`, `~/.npm/bin`, `~/.nvm/versions/...`, `/opt/homebrew/bin`
`spawn('claude', [...])`**ENOENT** ("command not found")
### Где Claude CLI устанавливается
| Метод | Путь бинарника |
|-------|----------------|
| npm global | `/usr/local/bin/claude` (symlink) |
| npm global (user) | `~/.npm-global/bin/claude` |
| nvm + npm | `~/.nvm/versions/node/v20/bin/claude` |
| Homebrew (Intel) | `/usr/local/bin/claude` |
| Homebrew (Apple Silicon) | `/opt/homebrew/bin/claude` |
| Windows | `C:\Users\{user}\AppData\Roaming\npm\claude.cmd` |
### Решение: ClaudeBinaryResolver
```typescript
async function resolveClaudeBinary(): Promise<string | null> {
const candidates = [
// 1. Try current PATH first (works in terminal)
'claude',
// 2. Common npm global paths
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
path.join(os.homedir(), '.npm', 'bin', 'claude'),
// 3. System paths
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude', // Apple Silicon
// 4. nvm — find active version
...await findNvmClaudePaths(),
// 5. Windows
...(process.platform === 'win32'
? [`${process.env.APPDATA}\\npm\\claude.cmd`]
: []),
];
for (const candidate of candidates) {
try {
await fs.promises.access(candidate, fs.constants.X_OK);
return candidate;
} catch { continue; }
}
// Fallback: `which claude`
try {
const { stdout } = await execFileAsync('which', ['claude'], {
timeout: 2000, shell: true
});
return stdout.trim() || null;
} catch {
return null;
}
}
```
### Существующие паттерны в проекте
- `config.ts:570-604` — пробует несколько editor candidates (cursor, code, subl, zed)
- `SshConnectionManager.ts:325` — использует ПОЛНЫЙ путь: `/bin/launchctl`
- `fix-path` / `shell-path` НЕ используются в проекте
---
## 3. stdio vs File Monitoring
### Сравнительная матрица
| Критерий | stdio Pipes | File Monitoring |
|----------|-------------|-----------------|
| **Потеря данных** | ВЫСОКИЙ РИСК (buffer overflow 64KB) | НЕТ РИСКА (файлы на диске) |
| **Latency** | ~0ms (pipe events) | 0-100ms (fs.watch), 3-30s (polling) |
| **Structured data** | Нужен парсинг (NDJSON или raw text) | JSONL уже structured |
| **Crash recovery** | Потеря буфера | Файлы сохранены |
| **Memory** | Накапливает в буферах | Читаем инкрементально |
| **Complexity** | State machine + backpressure + timeouts | Уже реализовано (FileWatcher) |
| **Fragility** | Ломается при смене формата CLI | JSONL = stable contract |
| **Масштаб** | 10 agents × stdio = resource explosion | Один watcher на все файлы |
### Конкретные риски stdio
1. **Buffer overflow**: stdout buffer = 64KB. Claude team session легко генерит > 64KB → subprocess deadlock
2. **Partial JSON reads**: chunk boundary может порвать JSON object → нужен stateful line parser
3. **Backpressure**: если не drain'ить pipe → Claude CLI зависает навсегда
4. **Encoding**: multi-byte UTF-8 рвётся на chunk boundaries
5. **ANSI escapes**: Claude может слать цветные коды (даже в pipe mode)
### Вердикт
**Hybrid**: stdio ТОЛЬКО для lifecycle (started/stopped/crashed), файлы для ВСЕХ данных.
---
## 4. Сравнение подходов
| | **SDK query()** | **CLI + stream-json** | **CLI + file monitoring** |
|---|---|---|---|
| Startup | ~12 сек (!) | ~2-3 сек | ~2-3 сек |
| Structured output | SDKMessage | NDJSON events | JSONL files |
| TeamCreate detection | Да | Да (tool_use event) | Да (config.json appears) |
| Multi-turn | Stateless per query | Баг #5034 | Работает |
| stdio risks | Hidden in SDK | Buffer/encoding/deadlock | Нет (файлы) |
| Crash recovery | Потеря | Потеря | Автоматическое |
| PATH resolution | SDK сам резолвит | Нужен resolver | Нужен resolver |
| API stability | Pre-1.0 | Experimental | JSONL stable |
| Hot reuse | Нет (issue #34) | N/A | N/A |
---
## 5. Env Variable для Team Tools
Team tools **выключены по умолчанию**. Для включения:
```bash
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
```
Или в `~/.claude/settings.json`:
```json
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}
```
Доступные team tools:
- `TeamCreate` — создание команды
- `TaskCreate` — создание задачи
- `TaskUpdate` — обновление статуса задачи
- `TaskList` — список задач
- `TaskGet` — получение задачи по ID
- `SendMessage` — отправка сообщения участнику
- `TeamDelete` — удаление команды
---
## 6. Ограничения Agent Teams (из официальной документации)
1. `/resume` и `/rewind` ломают team state
2. Task status может отставать — teammates иногда не отмечают tasks completed
3. Shutdown медленный — teammates завершают текущий request перед выходом
4. Один team per session — нельзя управлять несколькими командами из одного lead
5. Нет nested teams — teammates не могут создавать свои команды
6. Token cost высокий — каждый teammate = отдельное context window
7. Split-pane mode fragile — требует tmux или iTerm2
---
## 7. Верификация (раунд 3, 3 агента)
### 7.1 Вердикт по SDK: НЕТ
**Финальный вердикт**: SDK query() НЕ использовать.
Причины:
1. SDK **внутри спавнит CLI как subprocess** — нет выигрыша по сравнению с прямым spawn
2. ~12 сек cold start per query() call — неприемлемо для UX
3. Pre-1.0: API может измениться без предупреждения
4. SDK **не решает** проблему PATH resolution — всё равно нужен resolver
5. Team features experimental — задокументированные баги с session resumption, task lag, slow shutdown
6. Никакой добавленной ценности: FileWatcher уже делает всё, что мог бы дать SDK streaming
### 7.2 Верификация file monitoring: ОК с оговорками
**Находка**: CLI flag `-p` (print mode) **выходит после первого ответа**. Для multi-turn team creation:
- Вариант A: `--continue --resume $SESSION_ID` для продолжения сессии
- Вариант B: **Одноходовый промпт** — Claude за один turn вызывает TeamCreate + TaskCreate + спавнит teammates
- **Рекомендация**: Вариант B (одноходовый) — достаточно для bootstrap, проще и надёжнее
**Session JSONL parsing**: В JSONL session файле видны:
- `tool_use` с `name: "TeamCreate"` → команда создана
- `tool_result` с `teammate_spawned` → участник запущен
- SubagentResolver в проекте **уже умеет** детектить `teammate_spawned`
**Edge cases**:
- Mid-creation crash → retry safe (TeamCreate идемпотентен — перезаписывает config.json)
- Concurrent creation attempts → лок на teamName (validateTeamName + state machine)
- Dir exists without config.json → graceful skip (уже исправлено в v7, баг #37)
- Tasks dir ENOENT → return [] (уже исправлено в v7, баг #38)
### 7.3 Финальная архитектура (верифицирована)
#### Новые файлы (7)
| Файл | Описание |
|------|----------|
| `src/main/services/teams/ClaudeBinaryResolver.ts` | Поиск бинарника Claude CLI |
| `src/main/services/teams/TeamProvisioningService.ts` | State machine для создания команды |
| `src/main/services/teams/types.ts` | Типы для provisioning |
| `src/main/ipc/teams.ts` | IPC handlers для provisioning |
| `src/preload/constants/teamChannels.ts` | IPC channel constants |
| `src/renderer/hooks/useTeamProvisioning.ts` | React hook |
| `src/renderer/components/teams/CreateTeamDialog.tsx` | UI диалог создания |
#### Модификации (5)
| Файл | Изменение |
|------|-----------|
| `src/main/ipc/handlers.ts` | + registerTeamProvisioningHandlers |
| `src/preload/index.ts` | + api.teams.create, api.teams.getProvisioningStatus |
| `src/renderer/store/slices/teamSlice.ts` | + provisioning state |
| `src/main/services/infrastructure/FileWatcher.ts` | Уже добавлен в v7 (teamsWatcher) |
| `src/shared/types/ElectronAPI.ts` | + provisioning types |
#### State Machine
```
idle → validating → spawning → monitoring → ready
↘ failed → idle (retry)
```
- `idle` — начальное состояние
- `validating` — проверка teamName, поиск Claude binary
- `spawning` — spawn CLI process
- `monitoring` — ожидание config.json через FileWatcher
- `ready` — команда создана, participants bootstrapped
- `failed` — ошибка (с retry)
#### IPC Contract
```typescript
// Channels
export const TEAM_CREATE = 'team:create';
export const TEAM_PROVISIONING_STATUS = 'team:provisioning-status';
export const TEAM_PROVISIONING_PROGRESS = 'team:provisioning-progress'; // event
// Request
interface TeamCreateRequest {
teamName: string;
description: string;
members: Array<{ name: string; role: string }>;
cwd: string; // project working directory
}
// Response (via event stream)
interface ProvisioningProgress {
teamName: string;
state: 'validating' | 'spawning' | 'monitoring' | 'ready' | 'failed';
message: string;
error?: string;
sessionId?: string;
}
```
#### Spawn Strategy
```typescript
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) throw new Error('Claude CLI not found');
const child = spawn(claudePath, [
'-p', prompt, // print mode (single turn)
'--output-format', 'text', // НЕ парсим stdout
], {
cwd: projectPath,
env: {
...process.env,
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
},
stdio: ['ignore', 'ignore', 'pipe'], // stdout ИГНОРИРУЕМ
});
// Только stderr для ошибок
child.stderr.on('data', (data) => { stderrBuffer += data; });
// Lifecycle
child.on('exit', (code) => {
if (code === 0) transitionTo('monitoring');
else transitionTo('failed', stderrBuffer);
});
child.on('error', (err) => transitionTo('failed', err.message));
```
#### Bootstrap Detection
```typescript
// FileWatcher УЖЕ следит за ~/.claude/teams/
// При появлении config.json → проверяем содержимое
function onTeamConfigCreated(teamName: string) {
const config = await readTeamConfig(teamName);
if (config && config.members?.length > 0) {
transitionTo('ready');
emitProgress({ state: 'ready', sessionId });
}
}
```
#### Idempotency
1. `validateTeamName()` проверяет формат (a-z, 0-9, -)
2. Если dir уже существует с config.json → **ошибка** (не перезаписываем чужую команду)
3. Если dir существует БЕЗ config.json → **очищаем и продолжаем** (неудачная предыдущая попытка)
4. PID tracking для active processes → нельзя запустить два provisioning для одного teamName
---
## 8. Итоговая рекомендация
### Подход: CLI spawn + FileWatcher (hybrid)
```
[UI] → [IPC: team:create] → [TeamProvisioningService]
[ClaudeBinaryResolver.resolve()]
[spawn(claudePath, ['-p', prompt], {
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
stdio: ['ignore', 'ignore', 'pipe']
})]
[FileWatcher: ~/.claude/teams/{name}/config.json]
[IPC event: team:provisioning-progress → 'ready']
```
### Почему НЕ другие варианты
| Вариант | Причина отказа |
|---------|----------------|
| SDK query() | Внутри = тот же spawn, +12с overhead, pre-1.0, не решает PATH |
| CLI + stream-json | stdio fragile (64KB buffer), баг #5034, нужен stateful parser |
| CLI + stdout pipe | Buffer overflow, encoding, backpressure — все те же риски |
| tmux/iTerm2 | Не подходит для Electron (нет терминала) |
---
## 9. Sources
### Official Documentation
- [CLI reference](https://code.claude.com/docs/en/cli-reference)
- [Run Claude Code programmatically](https://code.claude.com/docs/en/headless)
- [Orchestrate teams](https://code.claude.com/docs/en/agent-teams)
- [Agent SDK overview](https://platform.claude.com/docs/en/agent-sdk/overview)
- [Agent SDK TypeScript](https://platform.claude.com/docs/en/agent-sdk/typescript)
### NPM / GitHub
- [npmjs.com/@anthropic-ai/claude-agent-sdk](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk)
- [github.com/anthropics/claude-agent-sdk-typescript](https://github.com/anthropics/claude-agent-sdk-typescript)
### Known Issues
- [#5034 — Duplicate entries in stream-json](https://github.com/anthropics/claude-code/issues/5034)
- [#1920 — Missing Final Result Event in Streaming JSON](https://github.com/anthropics/claude-code/issues/1920)
- [#34 — Hot process reuse for SDK](https://github.com/anthropics/claude-agent-sdk-typescript/issues/34)
- [#15511 — Stream partial JSON tokens](https://github.com/anthropics/claude-code/issues/15511)
### Community
- [Building agents with Claude Agent SDK — Anthropic Engineering](https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk)
- [Agent Teams with Claude Code — Medium](https://kargarisaac.medium.com/agent-teams-with-claude-code-and-claude-agent-sdk-e7de4e0cb03e)
- [Claude Code Multi-Agent tmux Setup — Dariusz Parys](https://www.dariuszparys.com/claude-code-multi-agent-tmux-setup/)

View file

@ -18,7 +18,8 @@
},
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "electron-vite dev",
"dev": "trap 'kill 0' INT; electron-vite dev",
"dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",
"dist:mac": "electron-builder --mac --publish always",
@ -53,7 +54,16 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-virtual": "^3.10.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "^3.6.0",
"electron-updater": "^6.7.3",
"fastify": "^5.7.4",
@ -67,6 +77,8 @@
"remark-parse": "^11.0.0",
"ssh-config": "^5.0.4",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"zustand": "^4.5.0"
},

File diff suppressed because it is too large Load diff

View file

@ -49,6 +49,7 @@ const CONTEXT_CHANGED = 'context:changed';
const HTTP_SERVER_START = 'httpServer:start';
const HTTP_SERVER_STOP = 'httpServer:stop';
const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
const TEAM_CHANGE = 'team:change';
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection in main process:', reason);
@ -67,6 +68,7 @@ import {
ServiceContextRegistry,
SshConnectionManager,
TeamDataService,
TeamProvisioningService,
UpdaterService,
} from './services';
@ -82,11 +84,13 @@ let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
let httpServer: HttpServer;
// File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null;
let todoChangeCleanup: (() => void) | null = null;
let teamChangeCleanup: (() => void) | null = null;
/**
* Resolve production renderer index path.
@ -116,6 +120,10 @@ function wireFileWatcherEvents(context: ServiceContext): void {
todoChangeCleanup();
todoChangeCleanup = null;
}
if (teamChangeCleanup) {
teamChangeCleanup();
teamChangeCleanup = null;
}
// Wire file-change events to renderer and HTTP SSE
const fileChangeHandler = (event: unknown): void => {
@ -137,6 +145,16 @@ function wireFileWatcherEvents(context: ServiceContext): void {
context.fileWatcher.on('todo-change', todoChangeHandler);
todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler);
// Forward team-change events to renderer and HTTP SSE
const teamChangeHandler = (event: unknown): void => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(TEAM_CHANGE, event);
}
httpServer?.broadcast('team-change', event);
};
context.fileWatcher.on('team-change', teamChangeHandler);
teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler);
logger.info(`FileWatcher events wired for context: ${context.id}`);
}
@ -267,16 +285,24 @@ function initializeServices(): void {
// Initialize updater service
updaterService = new UpdaterService();
teamDataService = new TeamDataService();
teamProvisioningService = new TeamProvisioningService();
httpServer = new HttpServer();
// Initialize IPC handlers with registry
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, teamDataService, {
rewire: rewireContextEvents,
full: onContextSwitched,
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
reconfigureLocalContextForClaudeRoot();
},
});
initializeIpcHandlers(
contextRegistry,
updaterService,
sshConnectionManager,
teamDataService,
teamProvisioningService,
{
rewire: rewireContextEvents,
full: onContextSwitched,
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
reconfigureLocalContextForClaudeRoot();
},
}
);
// HTTP Server control IPC handlers
ipcMain.handle(HTTP_SERVER_START, async () => {

View file

@ -12,6 +12,10 @@ const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TEAM_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TASK_ID_PATTERN = /^\d{1,10}$/;
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const MAX_QUERY_LENGTH = 512;
const MAX_RESULTS = 200;
@ -109,6 +113,58 @@ export function validateTriggerId(triggerId: unknown): ValidationResult<string>
return { valid: true, value: basic.value };
}
export function validateTeamName(teamName: unknown): ValidationResult<string> {
const basic = validateString(teamName, 'teamName', 128);
if (!basic.valid) {
return basic;
}
if (!TEAM_NAME_PATTERN.test(basic.value!)) {
return { valid: false, error: 'teamName contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateTaskId(taskId: unknown): ValidationResult<string> {
const basic = validateString(taskId, 'taskId', 16);
if (!basic.valid) {
return basic;
}
if (!TASK_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'taskId must contain only digits' };
}
return { valid: true, value: basic.value };
}
export function validateMemberName(memberName: unknown): ValidationResult<string> {
const basic = validateString(memberName, 'member', 128);
if (!basic.valid) {
return basic;
}
if (!MEMBER_NAME_PATTERN.test(basic.value!)) {
return { valid: false, error: 'member contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateFromField(from: unknown): ValidationResult<string> {
const basic = validateString(from, 'from', 128);
if (!basic.valid) {
return basic;
}
if (!FROM_FIELD_PATTERN.test(basic.value!)) {
return { valid: false, error: 'from contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateSearchQuery(query: unknown): ValidationResult<string> {
if (typeof query !== 'string') {
return { valid: false, error: 'query must be a string' };

View file

@ -57,6 +57,7 @@ import type {
ServiceContextRegistry,
SshConnectionManager,
TeamDataService,
TeamProvisioningService,
UpdaterService,
} from '../services';
@ -68,6 +69,7 @@ export function initializeIpcHandlers(
updater: UpdaterService,
sshManager: SshConnectionManager,
teamDataService: TeamDataService,
teamProvisioningService: TeamProvisioningService,
contextCallbacks: {
rewire: (context: ServiceContext) => void;
full: (context: ServiceContext) => void;
@ -82,7 +84,7 @@ export function initializeIpcHandlers(
initializeUpdaterHandlers(updater);
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
initializeContextHandlers(registry, contextCallbacks.rewire);
initializeTeamHandlers(teamDataService);
initializeTeamHandlers(teamDataService, teamProvisioningService);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
});

View file

@ -1,25 +1,94 @@
import { TEAM_LIST } from '@preload/constants/ipcChannels';
import {
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
TEAM_CREATE,
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_DATA,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import type { TeamDataService } from '../services';
import type { IpcResult, TeamSummary } from '@shared/types';
import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards';
import type { TeamDataService, TeamProvisioningService } from '../services';
import type {
CreateTaskRequest,
IpcResult,
SendMessageRequest,
SendMessageResult,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
UpdateKanbanPatch,
} from '@shared/types';
const logger = createLogger('IPC:teams');
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
export function initializeTeamHandlers(service: TeamDataService): void {
export function initializeTeamHandlers(
service: TeamDataService,
provisioningService: TeamProvisioningService
): void {
teamDataService = service;
teamProvisioningService = provisioningService;
}
export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_LIST, handleListTeams);
ipcMain.handle(TEAM_GET_DATA, handleGetData);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
logger.info('Team handlers registered');
}
export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_LIST);
ipcMain.removeHandler(TEAM_GET_DATA);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_CREATE);
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
ipcMain.removeHandler(TEAM_CREATE_TASK);
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_PROCESS_SEND);
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
ipcMain.removeHandler(TEAM_ALIVE_LIST);
}
function getTeamDataService(): TeamDataService {
@ -29,6 +98,13 @@ function getTeamDataService(): TeamDataService {
return teamDataService;
}
function getTeamProvisioningService(): TeamProvisioningService {
if (!teamProvisioningService) {
throw new Error('Team provisioning handlers are not initialized');
}
return teamProvisioningService;
}
async function wrapTeamHandler<T>(
operation: string,
handler: () => Promise<T>
@ -46,3 +122,401 @@ async function wrapTeamHandler<T>(
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
}
async function handleGetData(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<TeamData>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('getData', () => getTeamDataService().getTeamData(validated.value!));
}
async function handleDeleteTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<void>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!));
}
function isProvisioningTeamName(teamName: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(teamName) && teamName.length <= 64;
}
async function validateProvisioningRequest(
request: unknown
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
if (!request || typeof request !== 'object') {
return { valid: false, error: 'Invalid team create request' };
}
const payload = request as Partial<TeamCreateRequest>;
if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) {
return { valid: false, error: 'teamName is required' };
}
const teamName = payload.teamName.trim();
if (!isProvisioningTeamName(teamName)) {
return { valid: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' };
}
if (payload.displayName !== undefined && typeof payload.displayName !== 'string') {
return { valid: false, error: 'displayName must be string' };
}
if (payload.description !== undefined && typeof payload.description !== 'string') {
return { valid: false, error: 'description must be string' };
}
if (!Array.isArray(payload.members) || payload.members.length === 0) {
return { valid: false, error: 'members must contain at least one member' };
}
const seenNames = new Set<string>();
const members: TeamCreateRequest['members'] = [];
for (const member of payload.members) {
if (!member || typeof member !== 'object') {
return { valid: false, error: 'member must be object' };
}
const nameValidation = validateMemberName((member as { name?: unknown }).name);
if (!nameValidation.valid) {
return { valid: false, error: nameValidation.error ?? 'Invalid member name' };
}
const memberName = nameValidation.value!;
if (seenNames.has(memberName)) {
return { valid: false, error: 'member names must be unique' };
}
seenNames.add(memberName);
const role = (member as { role?: unknown }).role;
if (role !== undefined && typeof role !== 'string') {
return { valid: false, error: 'member role must be string' };
}
members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined });
}
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
return { valid: false, error: 'cwd is required' };
}
const cwd = payload.cwd.trim();
if (!path.isAbsolute(cwd)) {
return { valid: false, error: 'cwd must be an absolute path' };
}
try {
await fs.promises.mkdir(cwd, { recursive: true });
} catch {
return { valid: false, error: 'failed to create cwd directory' };
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(cwd);
} catch {
return { valid: false, error: 'cwd does not exist' };
}
if (!stat.isDirectory()) {
return { valid: false, error: 'cwd must be a directory' };
}
return {
valid: true,
value: {
teamName,
displayName: payload.displayName?.trim() || undefined,
description: payload.description?.trim() || undefined,
members,
cwd,
},
};
}
async function handleCreateTeam(
event: IpcMainInvokeEvent,
request: unknown
): Promise<IpcResult<TeamCreateResponse>> {
const validation = await validateProvisioningRequest(request);
if (!validation.valid) {
return { success: false, error: validation.error };
}
return wrapTeamHandler('create', () =>
getTeamProvisioningService().createTeam(validation.value, (progress) => {
try {
event.sender.send(TEAM_PROVISIONING_PROGRESS, progress);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to emit provisioning progress: ${message}`);
}
})
);
}
async function handlePrepareProvisioning(
_event: IpcMainInvokeEvent,
cwd: unknown
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
let validatedCwd: string | undefined;
if (cwd !== undefined) {
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
return { success: false, error: 'cwd must be a non-empty string' };
}
validatedCwd = cwd.trim();
if (!path.isAbsolute(validatedCwd)) {
return { success: false, error: 'cwd must be an absolute path' };
}
}
return wrapTeamHandler('prepareProvisioning', () =>
getTeamProvisioningService().prepareForProvisioning(validatedCwd)
);
}
async function handleProvisioningStatus(
_event: IpcMainInvokeEvent,
runId: unknown
): Promise<IpcResult<TeamProvisioningProgress>> {
if (typeof runId !== 'string' || runId.trim().length === 0) {
return { success: false, error: 'runId is required' };
}
return wrapTeamHandler('provisioningStatus', () =>
getTeamProvisioningService().getProvisioningStatus(runId.trim())
);
}
async function handleCancelProvisioning(
_event: IpcMainInvokeEvent,
runId: unknown
): Promise<IpcResult<void>> {
if (typeof runId !== 'string' || runId.trim().length === 0) {
return { success: false, error: 'runId is required' };
}
return wrapTeamHandler('cancelProvisioning', () =>
getTeamProvisioningService().cancelProvisioning(runId.trim())
);
}
function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
if (!value || typeof value !== 'object') {
return false;
}
const patch = value as Partial<UpdateKanbanPatch> & { op?: unknown; column?: unknown };
if (patch.op === 'remove') {
return true;
}
if (patch.op === 'request_changes') {
return patch.comment === undefined || typeof patch.comment === 'string';
}
return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved');
}
async function handleSendMessage(
_event: IpcMainInvokeEvent,
teamName: unknown,
request: unknown
): Promise<IpcResult<SendMessageResult>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
if (!request || typeof request !== 'object') {
return { success: false, error: 'Invalid send message request' };
}
const payload = request as Partial<SendMessageRequest>;
const validatedMember = validateMemberName(payload.member);
if (!validatedMember.valid) {
return { success: false, error: validatedMember.error ?? 'Invalid member' };
}
if (typeof payload.text !== 'string' || payload.text.trim().length === 0) {
return { success: false, error: 'text must be non-empty string' };
}
if (payload.summary !== undefined && typeof payload.summary !== 'string') {
return { success: false, error: 'summary must be string' };
}
if (payload.from !== undefined) {
const validatedFrom = validateFromField(payload.from);
if (!validatedFrom.valid) {
return { success: false, error: validatedFrom.error ?? 'Invalid from' };
}
}
return wrapTeamHandler('sendMessage', () =>
getTeamDataService().sendMessage(validatedTeamName.value!, {
member: validatedMember.value!,
text: payload.text!,
summary: payload.summary,
from: payload.from,
})
);
}
async function handleCreateTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
request: unknown
): Promise<IpcResult<TeamTask>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
if (!request || typeof request !== 'object') {
return { success: false, error: 'Invalid create task request' };
}
const payload = request as Partial<CreateTaskRequest>;
if (typeof payload.subject !== 'string' || payload.subject.trim().length === 0) {
return { success: false, error: 'subject must be a non-empty string' };
}
if (payload.subject.trim().length > 500) {
return { success: false, error: 'subject exceeds max length (500)' };
}
if (payload.description !== undefined && typeof payload.description !== 'string') {
return { success: false, error: 'description must be string' };
}
if (payload.owner !== undefined) {
const validatedOwner = validateMemberName(payload.owner);
if (!validatedOwner.valid) {
return { success: false, error: validatedOwner.error ?? 'Invalid owner' };
}
}
if (payload.blockedBy !== undefined) {
if (
!Array.isArray(payload.blockedBy) ||
payload.blockedBy.some((id) => typeof id !== 'string')
) {
return { success: false, error: 'blockedBy must be an array of task ID strings' };
}
}
return wrapTeamHandler('createTask', () =>
getTeamDataService().createTask(validatedTeamName.value!, {
subject: payload.subject!.trim(),
description: payload.description?.trim(),
owner: payload.owner?.trim() || undefined,
blockedBy: payload.blockedBy,
})
);
}
async function handleRequestReview(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<void>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
const validatedTaskId = validateTaskId(taskId);
if (!validatedTaskId.valid) {
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
}
return wrapTeamHandler('requestReview', () =>
getTeamDataService().requestReview(validatedTeamName.value!, validatedTaskId.value!)
);
}
async function handleUpdateKanban(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
patch: unknown
): Promise<IpcResult<void>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
const validatedTaskId = validateTaskId(taskId);
if (!validatedTaskId.valid) {
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
}
if (!isUpdateKanbanPatch(patch)) {
return { success: false, error: 'Invalid kanban patch' };
}
return wrapTeamHandler('updateKanban', async () => {
await getTeamDataService().updateKanban(
validatedTeamName.value!,
validatedTaskId.value!,
patch
);
});
}
const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed'];
async function handleUpdateTaskStatus(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
status: unknown
): Promise<IpcResult<void>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
const validatedTaskId = validateTaskId(taskId);
if (!validatedTaskId.valid) {
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
}
if (typeof status !== 'string' || !VALID_TASK_STATUSES.includes(status as TeamTaskStatus)) {
return { success: false, error: `status must be one of: ${VALID_TASK_STATUSES.join(', ')}` };
}
return wrapTeamHandler('updateTaskStatus', () =>
getTeamDataService().updateTaskStatus(
validatedTeamName.value!,
validatedTaskId.value!,
status as TeamTaskStatus
)
);
}
async function handleProcessSend(
_event: IpcMainInvokeEvent,
teamName: unknown,
message: unknown
): Promise<IpcResult<void>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
if (typeof message !== 'string' || message.trim().length === 0) {
return { success: false, error: 'message must be a non-empty string' };
}
return wrapTeamHandler('processSend', () =>
getTeamProvisioningService().sendMessageToTeam(validatedTeamName.value!, message)
);
}
async function handleProcessAlive(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<boolean>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('processAlive', async () =>
getTeamProvisioningService().isTeamAlive(validatedTeamName.value!)
);
}
async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<string[]>> {
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
}

View file

@ -12,7 +12,12 @@
import { type FileChangeEvent, type ParsedMessage } from '@main/types';
import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl';
import { getProjectsBasePath, getTodosBasePath } from '@main/utils/pathDecoder';
import {
getProjectsBasePath,
getTasksBasePath,
getTeamsBasePath,
getTodosBasePath,
} from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { EventEmitter } from 'events';
import * as fs from 'fs';
@ -27,6 +32,7 @@ import { LocalFileSystemProvider } from './LocalFileSystemProvider';
import { type NotificationManager } from './NotificationManager';
import type { FileSystemProvider, FsDirent } from './FileSystemProvider';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('Service:FileWatcher');
@ -54,9 +60,13 @@ interface ActiveSessionFile {
export class FileWatcher extends EventEmitter {
private projectsWatcher: fs.FSWatcher | null = null;
private todosWatcher: fs.FSWatcher | null = null;
private teamsWatcher: fs.FSWatcher | null = null;
private tasksWatcher: fs.FSWatcher | null = null;
private retryTimer: NodeJS.Timeout | null = null;
private projectsPath: string;
private todosPath: string;
private teamsPath: string;
private tasksPath: string;
private dataCache: DataCache;
private fsProvider: FileSystemProvider;
private notificationManager: NotificationManager | null = null;
@ -96,6 +106,8 @@ export class FileWatcher extends EventEmitter {
super();
this.projectsPath = projectsPath ?? getProjectsBasePath();
this.todosPath = todosPath ?? getTodosBasePath();
this.teamsPath = getTeamsBasePath();
this.tasksPath = getTasksBasePath();
this.dataCache = dataCache;
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
@ -163,6 +175,16 @@ export class FileWatcher extends EventEmitter {
this.todosWatcher = null;
}
if (this.teamsWatcher) {
this.teamsWatcher.close();
this.teamsWatcher = null;
}
if (this.tasksWatcher) {
this.tasksWatcher.close();
this.tasksWatcher = null;
}
// Clear any pending debounce timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
@ -317,6 +339,62 @@ export class FileWatcher extends EventEmitter {
}
}
/**
* Starts the teams directory watcher.
*/
private startTeamsWatcher(): void {
if (this.teamsWatcher) {
return;
}
try {
if (!fs.existsSync(this.teamsPath)) {
this.scheduleWatcherRetry();
return;
}
this.teamsWatcher = fs.watch(this.teamsPath, { recursive: true }, (eventType, filename) => {
if (filename) {
this.handleTeamsChange(eventType, filename);
}
});
this.attachWatcherRecovery(this.teamsWatcher, 'teams');
logger.info(`FileWatcher: Started watching teams at ${this.teamsPath}`);
} catch (error) {
logger.error('Error starting teams watcher:', error);
this.teamsWatcher = null;
this.scheduleWatcherRetry();
}
}
/**
* Starts the tasks directory watcher.
*/
private startTasksWatcher(): void {
if (this.tasksWatcher) {
return;
}
try {
if (!fs.existsSync(this.tasksPath)) {
this.scheduleWatcherRetry();
return;
}
this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => {
if (filename) {
this.handleTasksChange(eventType, filename);
}
});
this.attachWatcherRecovery(this.tasksWatcher, 'tasks');
logger.info(`FileWatcher: Started watching tasks at ${this.tasksPath}`);
} catch (error) {
logger.error('Error starting tasks watcher:', error);
this.tasksWatcher = null;
this.scheduleWatcherRetry();
}
}
private ensureWatchers(): void {
if (!this.isWatching || this.fsProvider.type === 'ssh') {
return;
@ -324,8 +402,10 @@ export class FileWatcher extends EventEmitter {
this.startProjectsWatcher();
this.startTodosWatcher();
this.startTeamsWatcher();
this.startTasksWatcher();
if (!this.projectsWatcher || !this.todosWatcher) {
if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) {
this.scheduleWatcherRetry();
}
}
@ -341,13 +421,20 @@ export class FileWatcher extends EventEmitter {
}, WATCHER_RETRY_MS);
}
private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: 'projects' | 'todos'): void {
private attachWatcherRecovery(
watcher: fs.FSWatcher,
watcherType: 'projects' | 'todos' | 'teams' | 'tasks'
): void {
watcher.on('error', (error) => {
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
if (watcherType === 'projects') {
this.projectsWatcher = null;
} else {
} else if (watcherType === 'todos') {
this.todosWatcher = null;
} else if (watcherType === 'teams') {
this.teamsWatcher = null;
} else {
this.tasksWatcher = null;
}
this.scheduleWatcherRetry();
});
@ -358,8 +445,12 @@ export class FileWatcher extends EventEmitter {
}
if (watcherType === 'projects') {
this.projectsWatcher = null;
} else {
} else if (watcherType === 'todos') {
this.todosWatcher = null;
} else if (watcherType === 'teams') {
this.teamsWatcher = null;
} else {
this.tasksWatcher = null;
}
this.scheduleWatcherRetry();
});
@ -808,6 +899,87 @@ export class FileWatcher extends EventEmitter {
logger.info(`FileWatcher: ${changeType} todo - ${filename}`);
}
private handleTeamsChange(eventType: string, filename: string): void {
try {
this.debounce(`teams/${filename}`, () => this.processTeamsChange(eventType, filename));
} catch (error) {
logger.error('Error handling teams change:', error);
}
}
private processTeamsChange(_eventType: string, filename: string): void {
const normalized = filename.split(/[\\/]/).filter(Boolean);
const teamName = normalized[0];
if (!teamName) {
return;
}
// `detail` is relative to the team root (plan examples: `inboxes/alice.json`, `config.json`)
const relative = normalized.slice(1).join('/');
if (!relative) {
return;
}
// Classify only the paths we care about in iteration 02.
if (normalized.includes('inboxes')) {
const event: TeamChangeEvent = {
type: 'inbox',
teamName,
detail: relative,
};
this.emit('team-change', event);
return;
}
if (relative === 'config.json' || relative === 'kanban-state.json') {
const event: TeamChangeEvent = {
type: 'config',
teamName,
detail: relative,
};
this.emit('team-change', event);
}
}
private handleTasksChange(eventType: string, filename: string): void {
try {
this.debounce(`tasks/${filename}`, () => this.processTasksChange(eventType, filename));
} catch (error) {
logger.error('Error handling tasks change:', error);
}
}
private processTasksChange(_eventType: string, filename: string): void {
const normalized = filename.split(/[\\/]/).filter(Boolean);
const teamName = normalized[0];
if (!teamName) {
return;
}
// `detail` is relative to the team tasks dir (plan example: `12.json`)
const relative = normalized.slice(1).join('/');
if (!relative) {
return;
}
// Ignore known non-task files in ~/.claude/tasks
if (
relative === '.lock' ||
relative === '.highwatermark' ||
relative.startsWith('.') ||
!relative.endsWith('.json')
) {
return;
}
const event: TeamChangeEvent = {
type: 'task',
teamName,
detail: relative,
};
this.emit('team-change', event);
}
// ===========================================================================
// Catch-Up Scan
// ===========================================================================
@ -909,10 +1081,12 @@ export class FileWatcher extends EventEmitter {
/**
* Returns watched paths.
*/
getWatchedPaths(): { projects: string; todos: string } {
getWatchedPaths(): { projects: string; todos: string; teams: string; tasks: string } {
return {
projects: this.projectsPath,
todos: this.todosPath,
teams: this.teamsPath,
tasks: this.tasksPath,
};
}
}

View file

@ -0,0 +1,75 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
async function isExecutable(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
async function collectNvmCandidates(): Promise<string[]> {
const nvmNodeRoot = path.join(os.homedir(), '.nvm', 'versions', 'node');
let versions: string[];
try {
versions = await fs.promises.readdir(nvmNodeRoot);
} catch {
return [];
}
return versions
.map((version) => path.join(nvmNodeRoot, version, 'bin', 'claude'))
.sort((a, b) => a.localeCompare(b))
.reverse();
}
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
const rawPath = process.env.PATH;
if (!rawPath) {
return null;
}
const pathParts = rawPath.split(path.delimiter);
for (const part of pathParts) {
if (!part) {
continue;
}
const candidate = path.join(part, binaryName);
if (await isExecutable(candidate)) {
return candidate;
}
}
return null;
}
export class ClaudeBinaryResolver {
static async resolve(): Promise<string | null> {
const platformBinaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude';
const fromPath = await resolveFromPathEnv(platformBinaryName);
if (fromPath) {
return fromPath;
}
const candidates: string[] = [
path.join(os.homedir(), '.npm-global', 'bin', platformBinaryName),
path.join(os.homedir(), '.npm', 'bin', platformBinaryName),
process.platform === 'win32'
? path.join(process.env.APPDATA ?? '', 'npm', 'claude.cmd')
: '/usr/local/bin/claude',
process.platform === 'win32' ? '' : '/opt/homebrew/bin/claude',
].filter((candidate) => candidate.length > 0);
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
for (const candidate of [...candidates, ...nvmCandidates]) {
if (await isExecutable(candidate)) {
return candidate;
}
}
return null;
}
}

View file

@ -0,0 +1,614 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 2;
function buildTeamCtlScript(): string {
const script = String.raw`#!/usr/bin/env node
'use strict';
// Team tools (v${TOOL_VERSION})
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const TOOL_VERSION = ${TOOL_VERSION};
function nowIso() {
return new Date().toISOString();
}
function formatError(err) {
if (!err) return 'Unknown error';
if (typeof err === 'string') return err;
if (err instanceof Error) return err.message || String(err);
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}
function die(message, code = 1) {
process.stderr.write(String(message).trimEnd() + '\n');
process.exit(code);
}
function parseArgs(argv) {
const out = { _: [], flags: {} };
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (token === '--') {
out._.push(...argv.slice(i + 1));
break;
}
if (token === '-h' || token === '--help') {
out.flags.help = true;
continue;
}
if (token.startsWith('--')) {
const eq = token.indexOf('=');
if (eq !== -1) {
const key = token.slice(2, eq);
const value = token.slice(eq + 1);
out.flags[key] = value;
continue;
}
const key = token.slice(2);
const next = argv[i + 1];
if (next != null && !String(next).startsWith('-')) {
out.flags[key] = next;
i++;
} else {
out.flags[key] = true;
}
continue;
}
if (token.startsWith('-') && token.length > 1) {
// minimal short-flag support
for (const ch of token.slice(1)) {
if (ch === 'h') out.flags.help = true;
}
continue;
}
out._.push(token);
}
return out;
}
function getHomeDir() {
return process.env.HOME || process.env.USERPROFILE || '';
}
function getClaudeDir(flags) {
const raw =
(typeof flags['claude-dir'] === 'string' && flags['claude-dir']) ||
(typeof flags['claudeDir'] === 'string' && flags['claudeDir']) ||
(typeof flags['claude_path'] === 'string' && flags['claude_path']) ||
'';
if (raw) return path.resolve(raw);
const inferred = inferClaudeDirFromScriptPath();
if (inferred) return inferred;
const home = getHomeDir();
if (!home) die('HOME is not set');
return path.join(home, '.claude');
}
function inferClaudeDirFromScriptPath() {
// Expected: <claudeDir>/teams/<teamName>/tools/teamctl.js
const toolsDir = path.dirname(__filename);
const teamDir = path.dirname(toolsDir);
const teamsDir = path.dirname(teamDir);
if (path.basename(teamsDir) !== 'teams') return null;
const claudeDir = path.dirname(teamsDir);
return claudeDir || null;
}
function inferTeamNameFromScriptPath() {
// Expected: ~/.claude/teams/<teamName>/tools/teamctl.js
const toolsDir = path.dirname(__filename);
const teamDir = path.dirname(toolsDir);
const parent = path.basename(path.dirname(teamDir));
if (parent !== 'teams') return null;
const teamName = path.basename(teamDir);
return teamName || null;
}
function getTeamName(flags) {
const explicit =
(typeof flags.team === 'string' && flags.team.trim()) ||
(typeof flags['teamName'] === 'string' && flags['teamName'].trim()) ||
'';
if (explicit) return explicit;
const inferred = inferTeamNameFromScriptPath();
if (inferred) return inferred;
die('Missing --team (and could not infer team name from script path)');
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err && err.code === 'ENOENT') return fallback;
throw err;
}
}
function atomicWrite(filePath, data) {
ensureDir(path.dirname(filePath));
const tmp =
String(filePath) +
'.tmp.' +
String(process.pid) +
'.' +
String(Math.random().toString(16).slice(2));
fs.writeFileSync(tmp, data, 'utf8');
fs.renameSync(tmp, filePath);
}
function normalizeStatus(value) {
const v = String(value || '').trim();
if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v;
return null;
}
function normalizeColumn(value) {
const v = String(value || '').trim();
if (v === 'review' || v === 'approved') return v;
return null;
}
function getPaths(flags, teamName) {
const claudeDir = getClaudeDir(flags);
const teamDir = path.join(claudeDir, 'teams', teamName);
const tasksDir = path.join(claudeDir, 'tasks', teamName);
const kanbanPath = path.join(teamDir, 'kanban-state.json');
return { claudeDir, teamDir, tasksDir, kanbanPath };
}
function readTask(paths, taskId) {
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
const task = readJson(taskPath, null);
if (!task) die('Task not found: ' + String(taskId));
return { taskPath, task };
}
function writeTask(taskPath, task) {
atomicWrite(taskPath, JSON.stringify(task, null, 2));
const verify = readJson(taskPath, null);
if (!verify) die('Task write verification failed');
return verify;
}
function setTaskStatus(paths, taskId, status) {
const normalized = normalizeStatus(status);
if (!normalized) die('Invalid status: ' + String(status));
const { taskPath, task } = readTask(paths, taskId);
task.status = normalized;
writeTask(taskPath, task);
}
function listTaskIds(tasksDir) {
let entries = [];
try {
entries = fs.readdirSync(tasksDir);
} catch (err) {
if (err && err.code === 'ENOENT') return [];
throw err;
}
const ids = [];
for (const file of entries) {
if (!file.endsWith('.json')) continue;
if (file.startsWith('.')) continue;
const num = Number(file.replace(/\.json$/, ''));
if (!Number.isFinite(num)) continue;
ids.push(String(num));
}
ids.sort((a, b) => Number(a) - Number(b));
return ids;
}
function getNextTaskId(paths) {
const ids = listTaskIds(paths.tasksDir);
const maxFromFiles = ids.length ? Number(ids[ids.length - 1]) : 0;
const hwmPath = path.join(paths.tasksDir, '.highwatermark');
const hwmRaw = readJson(hwmPath, null);
const maxFromHwm = typeof hwmRaw === 'number' ? hwmRaw : Number(String(hwmRaw || '0').trim());
const max = Math.max(maxFromFiles, Number.isFinite(maxFromHwm) ? maxFromHwm : 0);
return String(max + 1);
}
function updateHighwatermark(paths, taskId) {
const hwmPath = path.join(paths.tasksDir, '.highwatermark');
atomicWrite(hwmPath, String(taskId));
}
function createTask(paths, flags) {
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
if (!subject) die('Missing --subject');
const description =
typeof flags.description === 'string'
? flags.description
: typeof flags.desc === 'string'
? flags.desc
: '';
const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending');
const activeForm =
typeof flags.activeForm === 'string'
? flags.activeForm
: typeof flags['active-form'] === 'string'
? flags['active-form']
: undefined;
ensureDir(paths.tasksDir);
const nextId = getNextTaskId(paths);
const taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId));
const task = {
id: nextId,
subject,
description: String(description || subject),
activeForm: activeForm ? String(activeForm) : undefined,
owner,
status,
blocks: [],
blockedBy: [],
};
writeTask(taskPath, task);
updateHighwatermark(paths, nextId);
return task;
}
function readKanbanState(paths, teamName) {
const fallback = { teamName, reviewers: [], tasks: {} };
const parsed = readJson(paths.kanbanPath, fallback);
if (!parsed || typeof parsed !== 'object') return fallback;
const reviewers = Array.isArray(parsed.reviewers)
? parsed.reviewers.filter((r) => typeof r === 'string' && r.trim())
: [];
const tasks = parsed.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {};
return { teamName, reviewers, tasks };
}
function writeKanbanState(paths, state) {
atomicWrite(paths.kanbanPath, JSON.stringify(state, null, 2));
}
function setKanbanColumn(paths, teamName, taskId, column) {
const normalized = normalizeColumn(column);
if (!normalized) die('Invalid column: ' + String(column));
const state = readKanbanState(paths, teamName);
if (normalized === 'review') {
state.tasks[String(taskId)] = {
column: 'review',
reviewStatus: 'pending',
reviewer: null,
movedAt: nowIso(),
};
} else {
state.tasks[String(taskId)] = {
column: 'approved',
movedAt: nowIso(),
};
}
writeKanbanState(paths, state);
}
function clearKanban(paths, teamName, taskId) {
const state = readKanbanState(paths, teamName);
delete state.tasks[String(taskId)];
writeKanbanState(paths, state);
}
function sendInboxMessage(paths, teamName, flags) {
const to = typeof flags.to === 'string' ? flags.to.trim() : '';
if (!to) die('Missing --to');
const text = typeof flags.text === 'string' ? flags.text : '';
if (!text) die('Missing --text');
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
ensureDir(path.dirname(inboxPath));
const messageId = crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
const payload = {
from,
text,
timestamp: nowIso(),
read: false,
summary,
messageId,
};
const existing = readJson(inboxPath, []);
const list = Array.isArray(existing) ? existing : [];
list.push(payload);
atomicWrite(inboxPath, JSON.stringify(list, null, 2));
const verify = readJson(inboxPath, []);
if (!Array.isArray(verify) || !verify.some((m) => m && m.messageId === messageId)) {
die('Inbox write verification failed');
}
return { deliveredToInbox: true, messageId };
}
function reviewApprove(paths, teamName, taskId, flags) {
setKanbanColumn(paths, teamName, taskId, 'approved');
const notify = flags.notify === true || flags['notify-owner'] === true;
if (!notify) return;
const { task } = readTask(paths, taskId);
if (!task.owner) return;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
const text = note
? 'Task #' + String(taskId) + ' approved.\n\n' + note
: 'Task #' + String(taskId) + ' approved.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
summary: 'Approved #' + String(taskId),
from,
});
}
function reviewRequestChanges(paths, teamName, taskId, flags) {
const comment = typeof flags.comment === 'string' ? flags.comment.trim() : '';
const { taskPath, task } = readTask(paths, taskId);
if (!task.owner) die('No owner found for task ' + String(taskId));
clearKanban(paths, teamName, taskId);
task.status = 'in_progress';
writeTask(taskPath, task);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
const text =
'Task #' +
String(taskId) +
' needs fixes.\n\n' +
(comment || 'Reviewer requested changes.') +
'\n\n' +
'Please fix and mark it as completed when ready.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
summary: 'Fix request for #' + String(taskId),
from,
});
}
function printHelp() {
const inferred = inferTeamNameFromScriptPath();
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
process.stdout.write(
[
'teamctl.js v' + String(TOOL_VERSION) + teamHint,
'',
'Usage:',
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
' node teamctl.js kanban clear <id> [--team <team>]',
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
' node teamctl.js review request-changes <id> --comment "..." [--from "member"] [--team <team>]',
' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team <team>]',
'',
'Options:',
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
' --claude-dir <path> Override ~/.claude location',
'',
].join('\n')
);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.flags.help || args._.length === 0) {
printHelp();
return;
}
const domain = args._[0];
const action = args._[1];
const rest = args._.slice(2);
const teamName = getTeamName(args.flags);
const paths = getPaths(args.flags, teamName);
if (domain === 'task') {
if (action === 'set-status') {
const id = rest[0] || args.flags.id;
const status = rest[1] || args.flags.status;
if (!id || !status) die('Usage: task set-status <id> <status>');
setTaskStatus(paths, String(id), status);
process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n');
return;
}
if (action === 'complete' || action === 'done') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task complete <id>');
setTaskStatus(paths, String(id), 'completed');
process.stdout.write('OK task #' + String(id) + ' status=completed\n');
return;
}
if (action === 'start') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task start <id>');
setTaskStatus(paths, String(id), 'in_progress');
process.stdout.write('OK task #' + String(id) + ' status=in_progress\n');
return;
}
if (action === 'create') {
const task = createTask(paths, args.flags);
const notify = args.flags.notify === true || args.flags['notify-owner'] === true;
if (notify && task.owner) {
const from =
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user';
const text =
'New task assigned to you: #' +
String(task.id) +
' "' +
String(task.subject) +
'".\n\n' +
'When you start: node "$HOME/.claude/teams/' +
String(teamName) +
'/tools/${TOOL_FILE_NAME}" task start ' +
String(task.id) +
'\n' +
'When done: node "$HOME/.claude/teams/' +
String(teamName) +
'/tools/${TOOL_FILE_NAME}" task complete ' +
String(task.id) +
'\n';
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
summary: 'New task #' + String(task.id) + ' assigned',
from,
});
}
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
return;
}
if (action === 'get') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task get <id>');
const { task } = readTask(paths, String(id));
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
return;
}
if (action === 'list') {
const ids = listTaskIds(paths.tasksDir);
const tasks = [];
for (const id of ids) {
try {
tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null));
} catch {}
}
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n');
return;
}
die('Unknown task action: ' + String(action));
}
if (domain === 'kanban') {
if (action === 'set-column') {
const id = rest[0] || args.flags.id;
const column = rest[1] || args.flags.column;
if (!id || !column) die('Usage: kanban set-column <id> <review|approved>');
setKanbanColumn(paths, teamName, String(id), column);
process.stdout.write(
'OK kanban #' + String(id) + ' column=' + String(column) + '\n'
);
return;
}
if (action === 'clear' || action === 'remove') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: kanban clear <id>');
clearKanban(paths, teamName, String(id));
process.stdout.write('OK kanban #' + String(id) + ' cleared\n');
return;
}
if (action === 'reviewers') {
const sub = rest[0];
const name = rest[1];
const state = readKanbanState(paths, teamName);
if (sub === 'list') {
process.stdout.write(JSON.stringify(state.reviewers, null, 2) + '\n');
return;
}
if ((sub === 'add' || sub === 'remove') && (!name || !String(name).trim())) {
die('Usage: kanban reviewers add|remove <name>');
}
const trimmed = String(name || '').trim();
const before = new Set(state.reviewers);
if (sub === 'add') before.add(trimmed);
else if (sub === 'remove') before.delete(trimmed);
else die('Usage: kanban reviewers list|add|remove ...');
state.reviewers = [...before];
writeKanbanState(paths, state);
process.stdout.write('OK reviewers ' + String(sub) + '\n');
return;
}
die('Unknown kanban action: ' + String(action));
}
if (domain === 'review') {
if (action === 'approve') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: review approve <id>');
reviewApprove(paths, teamName, String(id), args.flags);
process.stdout.write('OK review #' + String(id) + ' approved\n');
return;
}
if (action === 'request-changes') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: review request-changes <id> --comment "..."');
reviewRequestChanges(paths, teamName, String(id), args.flags);
process.stdout.write('OK review #' + String(id) + ' requested changes\n');
return;
}
die('Unknown review action: ' + String(action));
}
if (domain === 'message') {
if (action === 'send') {
const result = sendInboxMessage(paths, teamName, args.flags);
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return;
}
die('Unknown message action: ' + String(action));
}
die('Unknown domain: ' + String(domain));
}
main().catch((err) => {
die(formatError(err));
});
`;
return script.trimStart();
}
export class TeamAgentToolsInstaller {
async ensureInstalled(teamName: string): Promise<string> {
const toolsDir = path.join(getTeamsBasePath(), teamName, 'tools');
const toolPath = path.join(toolsDir, TOOL_FILE_NAME);
await fs.promises.mkdir(toolsDir, { recursive: true });
const desired = buildTeamCtlScript();
let current: string | null = null;
try {
current = await fs.promises.readFile(toolPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
if (current && current.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) {
return toolPath;
}
await atomicWriteAsync(toolPath, desired);
return toolPath;
}
}

View file

@ -36,7 +36,8 @@ export class TeamConfigReader {
const memberCount = Array.isArray(config.members) ? config.members.length : 0;
summaries.push({
name: config.name,
teamName: entry.name,
displayName: config.name,
description: typeof config.description === 'string' ? config.description : '',
memberCount,
taskCount: 0,
@ -49,4 +50,18 @@ export class TeamConfigReader {
return summaries;
}
async getConfig(teamName: string): Promise<TeamConfig | null> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as TeamConfig;
if (typeof config.name !== 'string' || config.name.trim() === '') {
return null;
}
return config;
} catch {
return null;
}
}
}

View file

@ -1,11 +1,238 @@
import { TeamConfigReader } from './TeamConfigReader';
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import type { TeamSummary } from '@shared/types';
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamInboxWriter } from './TeamInboxWriter';
import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
import type {
CreateTaskRequest,
InboxMessage,
KanbanState,
KanbanTaskState,
SendMessageRequest,
SendMessageResult,
TeamData,
TeamSummary,
TeamTask,
TeamTaskStatus,
UpdateKanbanPatch,
} from '@shared/types';
export class TeamDataService {
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(),
private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(),
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller()
) {}
async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams();
}
async deleteTeam(teamName: string): Promise<void> {
const teamsDir = path.join(getTeamsBasePath(), teamName);
await fs.promises.rm(teamsDir, { recursive: true, force: true });
const tasksDir = path.join(getTasksBasePath(), teamName);
await fs.promises.rm(tasksDir, { recursive: true, force: true });
}
async getTeamData(teamName: string): Promise<TeamData> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
const warnings: string[] = [];
let tasks: TeamTask[] = [];
let tasksLoaded = true;
try {
tasks = await this.taskReader.getTasks(teamName);
} catch {
warnings.push('Tasks failed to load');
tasksLoaded = false;
}
let inboxNames: string[] = [];
try {
inboxNames = await this.inboxReader.listInboxNames(teamName);
} catch {
warnings.push('Inboxes failed to load');
}
let messages: InboxMessage[] = [];
try {
messages = await this.inboxReader.getMessages(teamName);
} catch {
warnings.push('Messages failed to load');
}
let kanbanState: KanbanState = {
teamName,
reviewers: [],
tasks: {},
};
let canRunKanbanGc = true;
try {
kanbanState = await this.kanbanManager.getState(teamName);
} catch {
warnings.push('Kanban state failed to load');
canRunKanbanGc = false;
}
if (canRunKanbanGc && tasksLoaded) {
try {
await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id)));
kanbanState = await this.kanbanManager.getState(teamName);
} catch {
warnings.push('Kanban state cleanup failed');
}
}
const members = this.memberResolver.resolveMembers(config, inboxNames, tasks, messages);
return {
teamName,
config,
tasks,
members,
messages,
kanbanState,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> {
const nextId = await this.taskReader.getNextTaskId(teamName);
const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? [];
const task: TeamTask = {
id: nextId,
subject: request.subject,
description: request.description
? `${request.subject}\n\n${request.description}`
: request.subject,
owner: request.owner,
status: request.owner ? 'in_progress' : 'pending',
blocks: [],
blockedBy,
};
await this.taskWriter.createTask(teamName, task);
// Update blocks[] on each referenced task so the reverse link exists
for (const depId of blockedBy) {
await this.taskWriter.addBlocksEntry(teamName, depId, nextId);
}
if (request.owner) {
try {
const toolPath = await this.toolsInstaller.ensureInstalled(teamName);
await this.sendMessage(teamName, {
member: request.owner,
text:
`New task assigned to you: #${task.id} "${task.subject}".\n\n` +
`Update task status using:\n` +
`node "${toolPath}" task start ${task.id}\n` +
`node "${toolPath}" task complete ${task.id}\n\n` +
`Help:\n` +
`node "${toolPath}" --help`,
summary: `New task #${task.id} assigned`,
});
} catch {
// Best-effort notification — don't fail task creation if message fails
}
}
return task;
}
async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
await this.taskWriter.updateStatus(teamName, taskId, status);
}
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
return this.inboxWriter.sendMessage(teamName, request);
}
async requestReview(teamName: string, taskId: string): Promise<void> {
await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' });
const state = await this.kanbanManager.getState(teamName);
const reviewer = state.reviewers[0];
if (!reviewer) {
return;
}
try {
const toolPath = await this.toolsInstaller.ensureInstalled(teamName);
await this.sendMessage(teamName, {
member: reviewer,
text:
`Please review task #${taskId}.\n\n` +
`When approved, move it to APPROVED:\n` +
`node "${toolPath}" review approve ${taskId}\n\n` +
`If changes are needed:\n` +
`node "${toolPath}" review request-changes ${taskId} --comment "..."`,
summary: `Review request for #${taskId}`,
});
} catch (error) {
await this.kanbanManager
.updateTask(teamName, taskId, { op: 'remove' })
.catch(() => undefined);
throw error;
}
}
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
if (patch.op !== 'request_changes') {
await this.kanbanManager.updateTask(teamName, taskId, patch);
return;
}
const tasks = await this.taskReader.getTasks(teamName);
const task = tasks.find((candidate) => candidate.id === taskId);
if (!task?.owner) {
throw new Error(`No owner found for task ${taskId}`);
}
const previousStatus: TeamTaskStatus = task.status;
const previousState = await this.kanbanManager.getState(teamName);
const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId];
await this.kanbanManager.updateTask(teamName, taskId, { op: 'remove' });
try {
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
await this.sendMessage(teamName, {
member: task.owner,
text:
`Task #${taskId} needs fixes.\n\n` +
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +
`Please fix and mark it as completed when ready.`,
summary: `Fix request for #${taskId}`,
});
} catch (error) {
await this.taskWriter.updateStatus(teamName, taskId, previousStatus).catch(() => undefined);
if (previousKanbanEntry) {
await this.kanbanManager
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
.catch(() => undefined);
}
throw error;
}
}
}

View file

@ -0,0 +1,115 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import type { InboxMessage } from '@shared/types';
export class TeamInboxReader {
async listInboxNames(teamName: string): Promise<string[]> {
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
let entries: string[];
try {
entries = await fs.promises.readdir(inboxDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
return entries
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
.map((name) => name.replace(/\.json$/, ''));
}
async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
let raw: string;
try {
raw = await fs.promises.readFile(inboxPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return [];
}
if (!Array.isArray(parsed)) {
return [];
}
const messages: InboxMessage[] = [];
for (const item of parsed) {
if (!item || typeof item !== 'object') {
continue;
}
const row = item as Partial<InboxMessage>;
if (
typeof row.from !== 'string' ||
typeof row.text !== 'string' ||
typeof row.timestamp !== 'string'
) {
continue;
}
messages.push({
from: row.from,
to: typeof row.to === 'string' ? row.to : undefined,
text: row.text,
timestamp: row.timestamp,
read: typeof row.read === 'boolean' ? row.read : false,
summary: typeof row.summary === 'string' ? row.summary : undefined,
color: typeof row.color === 'string' ? row.color : undefined,
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
});
}
messages.sort((a, b) => {
const bt = Date.parse(b.timestamp);
const at = Date.parse(a.timestamp);
if (Number.isNaN(bt) || Number.isNaN(at)) {
return 0;
}
return bt - at;
});
return messages;
}
async getMessages(teamName: string): Promise<InboxMessage[]> {
const members = await this.listInboxNames(teamName);
const chunks = await Promise.all(
members.map(async (member) => {
try {
const msgs = await this.getMessagesFor(teamName, member);
for (const msg of msgs) {
if (!msg.to) {
msg.to = member;
}
}
return msgs;
} catch {
return [] as InboxMessage[];
}
})
);
const merged = chunks.flat();
merged.sort((a, b) => {
const bt = Date.parse(b.timestamp);
const at = Date.parse(a.timestamp);
if (Number.isNaN(bt) || Number.isNaN(at)) {
return 0;
}
return bt - at;
});
return merged;
}
}

View file

@ -0,0 +1,93 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types';
const writeLocks = new Map<string, Promise<void>>();
async function withInboxLock<T>(inboxPath: string, fn: () => Promise<T>): Promise<T> {
const prev = writeLocks.get(inboxPath) ?? Promise.resolve();
let release!: () => void;
const mine = new Promise<void>((resolve) => {
release = resolve;
});
writeLocks.set(inboxPath, mine);
await prev;
try {
return await fn();
} finally {
release();
if (writeLocks.get(inboxPath) === mine) {
writeLocks.delete(inboxPath);
}
}
}
export class TeamInboxWriter {
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
const messageId = randomUUID();
const payload: InboxMessage = {
from: request.from ?? 'user',
text: request.text,
timestamp: new Date().toISOString(),
read: false,
summary: request.summary,
messageId,
};
await withInboxLock(inboxPath, async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const list = await this.readInbox(inboxPath);
list.push(payload);
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
const written = await this.readInbox(inboxPath);
if (written.some((msg) => msg.messageId === messageId)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
}
throw new Error('Failed to verify inbox write');
});
return {
deliveredToInbox: true,
messageId,
};
}
private async readInbox(inboxPath: string): Promise<InboxMessage[]> {
let raw: string;
try {
raw = await fs.promises.readFile(inboxPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((item): item is InboxMessage => {
if (!item || typeof item !== 'object') {
return false;
}
const row = item as Partial<InboxMessage>;
return (
typeof row.from === 'string' &&
typeof row.text === 'string' &&
typeof row.timestamp === 'string' &&
typeof row.read === 'boolean'
);
});
}
}

View file

@ -0,0 +1,136 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { KanbanState, UpdateKanbanPatch } from '@shared/types';
const logger = createLogger('Service:TeamKanbanManager');
function createDefaultState(teamName: string): KanbanState {
return {
teamName,
reviewers: [],
tasks: {},
};
}
function isValidColumn(value: unknown): value is 'review' | 'approved' {
return value === 'review' || value === 'approved';
}
export class TeamKanbanManager {
async getState(teamName: string): Promise<KanbanState> {
const statePath = this.getStatePath(teamName);
let raw: string;
try {
raw = await fs.promises.readFile(statePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return createDefaultState(teamName);
}
throw error;
}
let parsed: Partial<KanbanState>;
try {
parsed = JSON.parse(raw) as Partial<KanbanState>;
} catch {
return createDefaultState(teamName);
}
const sanitizedTasks: KanbanState['tasks'] = {};
if (parsed.tasks && typeof parsed.tasks === 'object') {
for (const [taskId, value] of Object.entries(parsed.tasks)) {
if (!value || typeof value !== 'object') {
continue;
}
const candidate = value as Partial<KanbanState['tasks'][string]>;
if (!isValidColumn(candidate.column) || typeof candidate.movedAt !== 'string') {
continue;
}
sanitizedTasks[taskId] = {
column: candidate.column,
movedAt: candidate.movedAt,
reviewStatus:
candidate.reviewStatus === 'pending' || candidate.reviewStatus === 'error'
? candidate.reviewStatus
: undefined,
reviewer:
typeof candidate.reviewer === 'string' || candidate.reviewer === null
? candidate.reviewer
: undefined,
errorDescription:
typeof candidate.errorDescription === 'string' ? candidate.errorDescription : undefined,
};
}
}
return {
teamName,
reviewers: Array.isArray(parsed.reviewers)
? parsed.reviewers.filter((r): r is string => typeof r === 'string' && r.trim().length > 0)
: [],
tasks: sanitizedTasks,
};
}
async updateTask(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
const state = await this.getState(teamName);
if (patch.op === 'remove' || patch.op === 'request_changes') {
delete state.tasks[taskId];
} else if (patch.column === 'review') {
state.tasks[taskId] = {
column: 'review',
reviewStatus: 'pending',
reviewer: null,
movedAt: new Date().toISOString(),
};
} else {
state.tasks[taskId] = {
column: 'approved',
movedAt: new Date().toISOString(),
};
}
await this.writeState(teamName, state);
}
async garbageCollect(teamName: string, validTaskIds: Set<string>): Promise<void> {
const state = await this.getState(teamName);
const before = Object.keys(state.tasks).length;
for (const taskId of Object.keys(state.tasks)) {
if (!validTaskIds.has(taskId)) {
delete state.tasks[taskId];
}
}
const after = Object.keys(state.tasks).length;
if (before === after) {
return;
}
logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`);
await this.writeState(teamName, state);
}
private getStatePath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'kanban-state.json');
}
private async writeState(teamName: string, state: KanbanState): Promise<void> {
const statePath = this.getStatePath(teamName);
const payload: KanbanState = {
teamName,
reviewers: state.reviewers,
tasks: state.tasks,
};
await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2));
}
}

View file

@ -0,0 +1,115 @@
import type {
InboxMessage,
MemberStatus,
ResolvedTeamMember,
TeamConfig,
TeamTask,
} from '@shared/types';
export class TeamMemberResolver {
resolveMembers(
config: TeamConfig,
inboxNames: string[],
tasks: TeamTask[],
messages: InboxMessage[]
): ResolvedTeamMember[] {
const names = new Set<string>();
if (Array.isArray(config.members)) {
for (const member of config.members) {
if (typeof member?.name === 'string' && member.name.trim() !== '') {
names.add(member.name.trim());
}
}
}
for (const inboxName of inboxNames) {
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
names.add(inboxName.trim());
}
}
for (const task of tasks) {
if (typeof task.owner === 'string' && task.owner.trim() !== '') {
names.add(task.owner.trim());
}
}
for (const message of messages) {
if (typeof message.from === 'string' && message.from.trim() !== '') {
names.add(message.from.trim());
}
}
const configMemberMap = new Map<string, { agentType?: string }>();
if (Array.isArray(config.members)) {
for (const m of config.members) {
if (typeof m?.name === 'string' && m.name.trim() !== '') {
configMemberMap.set(m.name.trim(), { agentType: m.agentType });
}
}
}
const members: ResolvedTeamMember[] = [];
for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask = ownedTasks.find((task) => task.status === 'in_progress') ?? null;
const memberMessages = messages.filter((message) => message.from === name);
const latestMessage = memberMessages[0] ?? null;
const status = this.resolveStatus(latestMessage);
const configMember = configMemberMap.get(name);
members.push({
name,
status,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
messageCount: memberMessages.length,
lastActiveAt: latestMessage?.timestamp ?? null,
color: latestMessage?.color,
agentType: configMember?.agentType,
});
}
members.sort((a, b) => a.name.localeCompare(b.name));
return members;
}
private resolveStatus(message: InboxMessage | null): MemberStatus {
if (!message) {
return 'unknown';
}
const structured = this.parseStructuredMessage(message.text);
if (structured) {
const typed = structured as { type?: string; approve?: boolean; approved?: boolean };
if (
(typed.type === 'shutdown_response' &&
(typed.approve === true || typed.approved === true)) ||
typed.type === 'shutdown_approved'
) {
return 'terminated';
}
}
const ageMs = Date.now() - Date.parse(message.timestamp);
if (Number.isNaN(ageMs)) {
return 'unknown';
}
if (ageMs < 5 * 60 * 1000) {
return 'active';
}
return 'idle';
}
private parseStructuredMessage(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore plain text.
}
return null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,105 @@
import { getTasksBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import type { TeamTask } from '@shared/types';
const logger = createLogger('Service:TeamTaskReader');
export class TeamTaskReader {
/**
* Returns the next available numeric task ID by scanning ALL task files
* (including _internal ones) to avoid ID collisions.
*/
async getNextTaskId(teamName: string): Promise<string> {
const tasksDir = path.join(getTasksBasePath(), teamName);
let entries: string[];
try {
entries = await fs.promises.readdir(tasksDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return '1';
}
throw error;
}
let maxId = 0;
for (const file of entries) {
if (!file.endsWith('.json')) continue;
const num = Number(file.replace('.json', ''));
if (Number.isFinite(num) && num > maxId) {
maxId = num;
}
}
return String(maxId + 1);
}
async getTasks(teamName: string): Promise<TeamTask[]> {
const tasksDir = path.join(getTasksBasePath(), teamName);
let entries: string[];
try {
entries = await fs.promises.readdir(tasksDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const tasks: TeamTask[] = [];
for (const file of entries) {
if (
!file.endsWith('.json') ||
file.startsWith('.') ||
file === '.lock' ||
file === '.highwatermark'
) {
continue;
}
const taskPath = path.join(tasksDir, file);
try {
const raw = await fs.promises.readFile(taskPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
// Skip internal CLI tracking entries (spawned subagent bookkeeping)
const metadata = parsed.metadata as Record<string, unknown> | undefined;
if (metadata?._internal === true) {
continue;
}
// CLI sometimes writes "title" instead of "subject" — normalize
const subject =
typeof parsed.subject === 'string'
? parsed.subject
: typeof parsed.title === 'string'
? parsed.title
: '';
const task: TeamTask = {
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined,
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
parsed.status as TeamTask['status']
)
? (parsed.status as TeamTask['status'])
: 'pending',
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
};
if (task.status === 'deleted') {
continue;
}
tasks.push(task);
} catch {
logger.debug(`Skipping invalid task file: ${taskPath}`);
}
}
return tasks;
}
}

View file

@ -0,0 +1,90 @@
import { getTasksBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { TeamTask, TeamTaskStatus } from '@shared/types';
export class TeamTaskWriter {
async createTask(teamName: string, task: TeamTask): Promise<void> {
const tasksDir = path.join(getTasksBasePath(), teamName);
await fs.promises.mkdir(tasksDir, { recursive: true });
const taskPath = path.join(tasksDir, `${task.id}.json`);
try {
await fs.promises.access(taskPath, fs.constants.F_OK);
throw new Error(`Task already exists: ${task.id}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
// Ensure CLI-compatible format: description, blocks, blockedBy are required
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
const cliCompatibleTask: TeamTask = {
...task,
description: task.description ?? '',
blocks: task.blocks ?? [],
blockedBy: task.blockedBy ?? [],
};
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
const verifyTask = JSON.parse(verifyRaw) as TeamTask;
if (verifyTask.id !== task.id) {
throw new Error(`Task create verification failed: ${task.id}`);
}
}
async addBlocksEntry(
teamName: string,
targetTaskId: string,
blockedTaskId: string
): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${targetTaskId}.json`);
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return; // Target task doesn't exist — skip silently
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
const blocks = task.blocks ?? [];
if (!blocks.includes(blockedTaskId)) {
task.blocks = [...blocks, blockedTaskId];
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
}
}
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
task.status = status;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
const verifyTask = JSON.parse(verifyRaw) as TeamTask;
if (verifyTask.status !== status) {
throw new Error(`Task status update verification failed: ${taskId}`);
}
}
}

View file

@ -0,0 +1,39 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
/**
* Async atomic write: write tmp file then rename over target.
* Uses best-effort fsync and EXDEV fallback for safety.
*/
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
const dir = path.dirname(targetPath);
const tmpPath = path.join(dir, `.tmp.${randomUUID()}`);
try {
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(tmpPath, data, 'utf8');
try {
const fd = await fs.promises.open(tmpPath, 'r+');
await fd.sync();
await fd.close();
} catch {
// fsync is best-effort.
}
try {
await fs.promises.rename(tmpPath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
await fs.promises.copyFile(tmpPath, targetPath);
await fs.promises.unlink(tmpPath).catch(() => undefined);
} else {
throw error;
}
}
} catch (error) {
await fs.promises.unlink(tmpPath).catch(() => undefined);
throw error;
}
}

View file

@ -1,2 +1,11 @@
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
export { TeamConfigReader } from './TeamConfigReader';
export { TeamDataService } from './TeamDataService';
export { TeamInboxReader } from './TeamInboxReader';
export { TeamInboxWriter } from './TeamInboxWriter';
export { TeamKanbanManager } from './TeamKanbanManager';
export { TeamMemberResolver } from './TeamMemberResolver';
export { TeamProvisioningService } from './TeamProvisioningService';
export { TeamTaskReader } from './TeamTaskReader';
export { TeamTaskWriter } from './TeamTaskWriter';

View file

@ -314,3 +314,10 @@ export function getTodosBasePath(): string {
export function getTeamsBasePath(): string {
return path.join(getClaudeBasePath(), 'teams');
}
/**
* Get the tasks directory path (~/.claude/tasks).
*/
export function getTasksBasePath(): string {
return path.join(getClaudeBasePath(), 'tasks');
}

View file

@ -178,3 +178,51 @@ export const WINDOW_IS_MAXIMIZED = 'window:isMaximized';
/** List all teams */
export const TEAM_LIST = 'team:list';
/** Get detailed team data */
export const TEAM_GET_DATA = 'team:getData';
/** Update team kanban state */
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
/** Send inbox message to team member */
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
/** Request review for task */
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
/** Team change events (main -> renderer) */
export const TEAM_CHANGE = 'team:change';
/** Create new team by provisioning through CLI */
export const TEAM_CREATE = 'team:create';
/** Warm up provisioning runtime before create */
export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning';
/** Get provisioning status by runId */
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus';
/** Cancel running provisioning by runId */
export const TEAM_CANCEL_PROVISIONING = 'team:cancelProvisioning';
/** Team provisioning progress events (main -> renderer) */
export const TEAM_PROVISIONING_PROGRESS = 'team:provisioningProgress';
/** Send message to team's live CLI process via stream-json stdin */
export const TEAM_PROCESS_SEND = 'team:processSend';
/** Check if team has a live CLI process */
export const TEAM_PROCESS_ALIVE = 'team:processAlive';
/** Create a task in team's task directory */
export const TEAM_CREATE_TASK = 'team:createTask';
/** Update task status directly (pending/in_progress/completed) */
export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
/** Delete a team and its associated task directory */
export const TEAM_DELETE_TEAM = 'team:deleteTeam';
/** Get list of teams with live CLI processes */
export const TEAM_ALIVE_LIST = 'team:aliveList';

View file

@ -18,7 +18,23 @@ import {
SSH_SAVE_LAST_CONNECTION,
SSH_STATUS,
SSH_TEST,
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
TEAM_CHANGE,
TEAM_CREATE,
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_DATA,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
@ -60,18 +76,30 @@ import type {
ClaudeRootFolderSelection,
ClaudeRootInfo,
ContextInfo,
CreateTaskRequest,
ElectronAPI,
HttpServerStatus,
IpcResult,
NotificationTrigger,
SendMessageRequest,
SendMessageResult,
SessionsByIdsOptions,
SessionsPaginationOptions,
SshConfigHostEntry,
SshConnectionConfig,
SshConnectionStatus,
SshLastConnection,
TeamChangeEvent,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
TriggerTestResult,
IpcResult,
UpdateKanbanPatch,
WslClaudeRootCandidate,
} from '@shared/types';
@ -456,6 +484,74 @@ const electronAPI: ElectronAPI = {
list: async () => {
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
},
getData: async (teamName: string) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
},
deleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
},
prepareProvisioning: async (cwd?: string) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
},
createTeam: async (request: TeamCreateRequest) => {
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
},
getProvisioningStatus: async (runId: string) => {
return invokeIpcWithResult<TeamProvisioningProgress>(TEAM_PROVISIONING_STATUS, runId);
},
cancelProvisioning: async (runId: string) => {
return invokeIpcWithResult<void>(TEAM_CANCEL_PROVISIONING, runId);
},
sendMessage: async (teamName: string, request: SendMessageRequest) => {
return invokeIpcWithResult<SendMessageResult>(TEAM_SEND_MESSAGE, teamName, request);
},
createTask: async (teamName: string, request: CreateTaskRequest) => {
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
},
requestReview: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_REQUEST_REVIEW, teamName, taskId);
},
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_KANBAN, teamName, taskId, patch);
},
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
},
processSend: async (teamName: string, message: string) => {
return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message);
},
processAlive: async (teamName: string) => {
return invokeIpcWithResult<boolean>(TEAM_PROCESS_ALIVE, teamName);
},
aliveList: async () => {
return invokeIpcWithResult<string[]>(TEAM_ALIVE_LIST);
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
TEAM_CHANGE,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void
): (() => void) => {
ipcRenderer.on(
TEAM_PROVISIONING_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
TEAM_PROVISIONING_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
};

View file

@ -14,6 +14,7 @@ import type {
ConfigAPI,
ContextInfo,
ConversationGroup,
CreateTaskRequest,
ElectronAPI,
FileChangeEvent,
HttpServerAPI,
@ -24,6 +25,8 @@ import type {
Project,
RepositoryGroup,
SearchSessionsResult,
SendMessageRequest,
SendMessageResult,
Session,
SessionAPI,
SessionDetail,
@ -36,9 +39,18 @@ import type {
SshConnectionStatus,
SshLastConnection,
SubagentDetail,
TeamSummary,
TeamChangeEvent,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamsAPI,
TeamSummary,
TeamTask,
TeamTaskStatus,
TriggerTestResult,
UpdateKanbanPatch,
UpdaterAPI,
WaterfallData,
WslClaudeRootCandidate,
@ -592,5 +604,68 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] teams API is not available in browser mode');
return [];
},
getData: async (_teamName: string): Promise<TeamData> => {
throw new Error('Teams detail is not available in browser mode');
},
deleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team deletion is not available in browser mode');
},
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
throw new Error('Team provisioning is not available in browser mode');
},
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
throw new Error('Team provisioning is not available in browser mode');
},
getProvisioningStatus: async (_runId: string): Promise<TeamProvisioningProgress> => {
throw new Error('Team provisioning is not available in browser mode');
},
cancelProvisioning: async (_runId: string): Promise<void> => {
throw new Error('Team provisioning is not available in browser mode');
},
sendMessage: async (
_teamName: string,
_request: SendMessageRequest
): Promise<SendMessageResult> => {
throw new Error('Team messaging is not available in browser mode');
},
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
throw new Error('Team task creation is not available in browser mode');
},
requestReview: async (_teamName: string, _taskId: string): Promise<void> => {
throw new Error('Team review is not available in browser mode');
},
updateKanban: async (
_teamName: string,
_taskId: string,
_patch: UpdateKanbanPatch
): Promise<void> => {
throw new Error('Team kanban is not available in browser mode');
},
updateTaskStatus: async (
_teamName: string,
_taskId: string,
_status: TeamTaskStatus
): Promise<void> => {
throw new Error('Team task status update is not available in browser mode');
},
processSend: async (_teamName: string, _message: string): Promise<void> => {
throw new Error('Team process communication is not available in browser mode');
},
processAlive: async (_teamName: string): Promise<boolean> => {
return false;
},
aliveList: async (): Promise<string[]> => {
return [];
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)
);
},
onProvisioningProgress: (
_callback: (event: unknown, data: TeamProvisioningProgress) => void
): (() => void) => {
return () => {};
},
};
}

View file

@ -8,6 +8,7 @@ import {
CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
@ -31,57 +32,6 @@ interface TeammateMessageItemProps {
highlightStyle?: React.CSSProperties;
}
/** Operational noise types that should be rendered minimally */
const NOISE_TYPES = new Set([
'idle_notification',
'shutdown_approved',
'teammate_terminated',
'shutdown_request',
]);
/** Human-readable labels for noise message types */
const NOISE_LABELS: Record<string, string> = {
idle_notification: 'Idle',
shutdown_approved: 'Shutdown confirmed',
teammate_terminated: 'Terminated',
shutdown_request: 'Shutdown requested',
};
/**
* Detect operational noise in teammate message content.
* Returns label if noise, null if real content.
*/
function detectNoise(content: string, teammateId: string): string | null {
// System messages are always noise
if (teammateId === 'system') {
const trimmed = content.trim();
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed) as { type?: string; message?: string };
if (parsed.type && NOISE_TYPES.has(parsed.type)) {
return parsed.message ?? NOISE_LABELS[parsed.type] ?? parsed.type;
}
} catch {
// Not JSON, fall through
}
}
return trimmed.length < 200 ? trimmed : null;
}
// Non-system: check if content is a JSON operational message
const trimmed = content.trim();
if (!trimmed.startsWith('{')) return null;
try {
const parsed = JSON.parse(trimmed) as { type?: string };
if (parsed.type && NOISE_TYPES.has(parsed.type)) {
return NOISE_LABELS[parsed.type] ?? parsed.type;
}
} catch {
// Not JSON
}
return null;
}
// =============================================================================
// Resend Detection
// =============================================================================
@ -128,7 +78,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
// Detect operational noise
const noiseLabel = useMemo(
() => detectNoise(teammateMessage.content, teammateMessage.teammateId),
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
[teammateMessage.content, teammateMessage.teammateId]
);

View file

@ -8,6 +8,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { DashboardView } from '../dashboard/DashboardView';
import { NotificationsView } from '../notifications/NotificationsView';
import { SettingsView } from '../settings/SettingsView';
import { TeamDetailView } from '../team/TeamDetailView';
import { TeamListView } from '../team/TeamListView';
import { SessionTabContent } from './SessionTabContent';
@ -44,6 +45,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
{tab.type === 'notifications' && <NotificationsView />}
{tab.type === 'settings' && <SettingsView />}
{tab.type === 'teams' && <TeamListView />}
{tab.type === 'team' && <TeamDetailView teamName={tab.teamName ?? ''} />}
{tab.type === 'session' && (
<TabUIProvider tabId={tab.id}>
<SessionTabContent tab={tab} isActive={isActive} />

View file

@ -31,6 +31,7 @@ const TAB_ICONS = {
settings: Settings,
session: FileText,
teams: Users,
team: Users,
} as const;
export const SortableTab = ({

View file

@ -0,0 +1,47 @@
import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { ChevronRight } from 'lucide-react';
interface CollapsibleTeamSectionProps {
title: string;
badge?: string | number;
defaultOpen?: boolean;
action?: React.ReactNode;
children: React.ReactNode;
}
export const CollapsibleTeamSection = ({
title,
badge,
defaultOpen = true,
action,
children,
}: CollapsibleTeamSectionProps): React.JSX.Element => {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0">
<div className="flex items-center">
<button
type="button"
className="flex flex-1 items-center gap-2 text-left"
onClick={() => setOpen((prev) => !prev)}
>
<ChevronRight
size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
/>
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
{badge != null && (
<Badge variant="secondary" className="px-1.5 py-0.5 text-[10px] font-normal leading-none">
{badge}
</Badge>
)}
</button>
{action && <div className="shrink-0">{action}</div>}
</div>
{open && <div className="mt-2">{children}</div>}
</section>
);
};

View file

@ -0,0 +1,310 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { Plus, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActivityTimeline } from './activity/ActivityTimeline';
import { MessageComposer } from './activity/MessageComposer';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { ReviewDialog } from './dialogs/ReviewDialog';
import { KanbanBoard } from './kanban/KanbanBoard';
import { MemberList } from './members/MemberList';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
interface TeamDetailViewProps {
teamName: string;
}
interface CreateTaskDialogState {
open: boolean;
defaultSubject: string;
defaultDescription: string;
}
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false,
defaultSubject: '',
defaultDescription: '',
});
const [creatingTask, setCreatingTask] = useState(false);
const {
data,
loading,
error,
selectTeam,
updateKanban,
updateTaskStatus,
sendTeamMessage,
requestReview,
createTeamTask,
deleteTeam,
openTeamsTab,
sendingMessage,
sendMessageError,
lastSendMessageResult,
reviewActionError,
} = useStore(
useShallow((s) => ({
data: s.selectedTeamData,
loading: s.selectedTeamLoading,
error: s.selectedTeamError,
selectTeam: s.selectTeam,
updateKanban: s.updateKanban,
updateTaskStatus: s.updateTaskStatus,
sendTeamMessage: s.sendTeamMessage,
requestReview: s.requestReview,
createTeamTask: s.createTeamTask,
deleteTeam: s.deleteTeam,
openTeamsTab: s.openTeamsTab,
sendingMessage: s.sendingMessage,
sendMessageError: s.sendMessageError,
lastSendMessageResult: s.lastSendMessageResult,
reviewActionError: s.reviewActionError,
}))
);
useEffect(() => {
if (!teamName) {
return;
}
void selectTeam(teamName);
}, [teamName, selectTeam]);
const openCreateTaskDialog = (subject = '', description = ''): void => {
setCreateTaskDialog({ open: true, defaultSubject: subject, defaultDescription: description });
};
const closeCreateTaskDialog = (): void => {
setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '' });
};
const handleDeleteTeam = useCallback((): void => {
const confirmed = window.confirm(
`Удалить команду "${teamName}"? Это действие необратимо. Будут удалены все данные команды и задачи.`
);
if (!confirmed) {
return;
}
void (async () => {
try {
await deleteTeam(teamName);
openTeamsTab();
} catch {
// error is shown via store
}
})();
}, [teamName, deleteTeam, openTeamsTab]);
const handleCreateTask = (
subject: string,
description: string,
owner?: string,
blockedBy?: string[]
): void => {
setCreatingTask(true);
void (async () => {
try {
await createTeamTask(teamName, {
subject,
description: description || undefined,
owner,
blockedBy,
});
closeCreateTaskDialog();
} catch {
// error shown via store
} finally {
setCreatingTask(false);
}
})();
};
if (!teamName) {
return (
<div className="flex size-full items-center justify-center p-6 text-sm text-red-400">
Invalid team tab
</div>
);
}
if (loading && !data) {
return (
<div className="size-full overflow-auto p-4">
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
<div className="space-y-3">
<div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
</div>
</div>
);
}
if (error) {
return (
<div className="flex size-full items-center justify-center p-6">
<div className="text-center">
<p className="text-sm font-medium text-red-400">Не удалось загрузить команду</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{error}</p>
</div>
</div>
);
}
if (!data) {
return (
<div className="flex size-full items-center justify-center p-6 text-sm text-[var(--color-text-muted)]">
Нет данных по команде
</div>
);
}
return (
<div className="size-full overflow-auto p-4">
<div className="mb-3">
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
{data.config.description && (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{data.config.description}</p>
)}
</div>
<TeamProvisioningBanner teamName={teamName} />
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
<div className="mb-3 rounded-md border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
Не удалось полностью загрузить kanban. Отображены безопасные данные.
</div>
) : null}
{reviewActionError ? (
<div className="mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{reviewActionError}
</div>
) : null}
<CollapsibleTeamSection title="Участники" badge={data.members.length} defaultOpen>
<MemberList members={data.members} />
</CollapsibleTeamSection>
<CollapsibleTeamSection
title="Kanban"
badge={data.tasks.length}
defaultOpen
action={
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
openCreateTaskDialog();
}}
>
<Plus size={12} />
Задача
</Button>
}
>
<KanbanBoard
tasks={data.tasks}
kanbanState={data.kanbanState}
onRequestReview={(taskId) => {
void requestReview(teamName, taskId);
}}
onApprove={(taskId) => {
void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' });
}}
onRequestChanges={(taskId) => {
setRequestChangesTaskId(taskId);
}}
onMoveBackToDone={(taskId) => {
void updateKanban(teamName, taskId, { op: 'remove' });
}}
onCompleteTask={(taskId) => {
void updateTaskStatus(teamName, taskId, 'completed');
}}
onScrollToTask={(taskId) => {
const el = document.querySelector(`[data-task-id="${taskId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
el.classList.add('ring-2', 'ring-blue-400/50');
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
}
}}
/>
</CollapsibleTeamSection>
<CollapsibleTeamSection title="Активность" badge={data.messages.length} defaultOpen>
<div className="flex flex-col gap-3">
<MessageComposer
members={data.members}
sending={sendingMessage}
sendError={sendMessageError}
lastResult={lastSendMessageResult}
onSend={(member, text, summary) => {
void sendTeamMessage(teamName, { member, text, summary });
}}
/>
<div className="rounded-md border border-[var(--color-border)] p-2">
<ActivityTimeline
messages={data.messages}
onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description);
}}
/>
</div>
</div>
</CollapsibleTeamSection>
<ReviewDialog
open={requestChangesTaskId !== null}
taskId={requestChangesTaskId}
onCancel={() => setRequestChangesTaskId(null)}
onSubmit={(comment) => {
if (!requestChangesTaskId) {
return;
}
void (async () => {
try {
await updateKanban(teamName, requestChangesTaskId, {
op: 'request_changes',
comment,
});
setRequestChangesTaskId(null);
} catch {
// error state is handled in the store and shown in the view
}
})();
}}
/>
<CreateTaskDialog
open={createTaskDialog.open}
members={data.members}
tasks={data.tasks}
defaultSubject={createTaskDialog.defaultSubject}
defaultDescription={createTaskDialog.defaultDescription}
onClose={closeCreateTaskDialog}
onSubmit={handleCreateTask}
submitting={creatingTask}
/>
<div className="mt-6 border-t border-[var(--color-border)] pt-4">
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={handleDeleteTeam}
>
<Trash2 size={14} className="mr-1.5" />
Удалить команду
</Button>
</div>
</div>
);
};

View file

@ -1,6 +1,6 @@
export const TeamEmptyState = (): React.JSX.Element => {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex size-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-medium text-[var(--color-text)]">Команды не найдены</p>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">

View file

@ -1,21 +1,62 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
export const TeamListView = (): React.JSX.Element => {
const electronMode = isElectronMode();
const { teams, teamsLoading, teamsError, fetchTeams } = useStore(
const [showCreateDialog, setShowCreateDialog] = useState(false);
const { teams, teamsLoading, teamsError, fetchTeams, openTeamTab, deleteTeam } = useStore(
useShallow((s) => ({
teams: s.teams,
teamsLoading: s.teamsLoading,
teamsError: s.teamsError,
fetchTeams: s.fetchTeams,
openTeamTab: s.openTeamTab,
deleteTeam: s.deleteTeam,
}))
);
const {
connectionMode,
createTeam,
cancelProvisioning,
provisioningRuns,
activeProvisioningRunId,
provisioningError,
} = useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
cancelProvisioning: s.cancelProvisioning,
provisioningRuns: s.provisioningRuns,
activeProvisioningRunId: s.activeProvisioningRunId,
provisioningError: s.provisioningError,
}))
);
const activeProgress = useMemo(
() => (activeProvisioningRunId ? (provisioningRuns[activeProvisioningRunId] ?? null) : null),
[activeProvisioningRunId, provisioningRuns]
);
const canCreate = electronMode && connectionMode === 'local';
const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm(`Удалить команду "${teamName}"? Это действие необратимо.`);
if (!confirmed) {
return;
}
void deleteTeam(teamName);
},
[deleteTeam]
);
useEffect(() => {
if (!electronMode) {
@ -39,68 +80,150 @@ export const TeamListView = (): React.JSX.Element => {
);
}
const createDialogElement = (
<CreateTeamDialog
open={showCreateDialog}
canCreate={canCreate}
provisioningError={provisioningError}
progress={activeProgress}
existingTeamNames={teams.map((t) => t.teamName)}
onClose={() => setShowCreateDialog(false)}
onCreate={async (request) => {
await createTeam(request);
}}
onCancelProvisioning={async (runId) => {
await cancelProvisioning(runId);
}}
onOpenTeam={openTeamTab}
/>
);
const renderHeader = (): React.JSX.Element => (
<div className="mb-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--color-text)]">Teams</h2>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!canCreate}
onClick={() => setShowCreateDialog(true)}
>
Create Team
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
void fetchTeams();
}}
>
Обновить
</Button>
</div>
</div>
{!canCreate ? (
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
Доступно только в local Electron-режиме.
</p>
) : null}
</div>
);
if (teamsLoading) {
return (
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Загружаем команды...
<div className="size-full overflow-auto p-4">
{renderHeader()}
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Загружаем команды...
</div>
{createDialogElement}
</div>
);
}
if (teamsError) {
return (
<div className="flex size-full items-center justify-center p-6">
<div className="text-center">
<p className="text-sm font-medium text-red-400">Не удалось загрузить команды</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{teamsError}</p>
<button
className="mt-4 rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => {
void fetchTeams();
}}
>
Повторить
</button>
<div className="size-full overflow-auto p-4">
{renderHeader()}
<div className="flex size-full items-center justify-center p-6">
<div className="text-center">
<p className="text-sm font-medium text-red-400">Не удалось загрузить команды</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{teamsError}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => {
void fetchTeams();
}}
>
Повторить
</Button>
</div>
</div>
{createDialogElement}
</div>
);
}
if (teams.length === 0) {
return <TeamEmptyState />;
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
<TeamEmptyState />
{createDialogElement}
</div>
);
}
return (
<div className="size-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--color-text)]">Teams</h2>
<button
className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => {
void fetchTeams();
}}
>
Обновить
</button>
</div>
{renderHeader()}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => (
<article
key={team.name}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
<div
key={team.teamName}
role="button"
tabIndex={0}
className="group cursor-pointer rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
onClick={() => openTeamTab(team.teamName)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName);
}
}}
>
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">{team.name}</h3>
<div className="flex items-start justify-between">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<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, e)}
title="Удалить команду"
>
<Trash2 size={14} />
</button>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'Без описания'}
</p>
<div className="mt-3 flex items-center gap-3 text-xs text-[var(--color-text-muted)]">
<span>Участников: {team.memberCount}</span>
<span>Задач: {team.taskCount}</span>
<div className="mt-3 flex items-center gap-2">
<Badge variant="secondary" className="text-[10px] font-normal">
Участников: {team.memberCount}
</Badge>
<Badge variant="secondary" className="text-[10px] font-normal">
Задач: {team.taskCount}
</Badge>
</div>
</article>
</div>
))}
</div>
{createDialogElement}
</div>
);
};

View file

@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
import type { TeamProvisioningProgress } from '@shared/types';
interface TeamProvisioningBannerProps {
teamName: string;
}
function findProgressForTeam(
runs: Record<string, TeamProvisioningProgress>,
teamName: string
): TeamProvisioningProgress | null {
const entries = Object.values(runs);
const matching = entries
.filter((r) => r.teamName === teamName)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return matching[0] ?? null;
}
const READY_DISMISS_MS = 5_000;
export const TeamProvisioningBanner = ({
teamName,
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
const { provisioningRuns, cancelProvisioning } = useStore(
useShallow((s) => ({
provisioningRuns: s.provisioningRuns,
cancelProvisioning: s.cancelProvisioning,
}))
);
const progress = findProgressForTeam(provisioningRuns, teamName);
const [dismissed, setDismissed] = useState(false);
const prevRunIdRef = useRef(progress?.runId);
if (prevRunIdRef.current !== progress?.runId) {
prevRunIdRef.current = progress?.runId;
if (dismissed) {
setDismissed(false);
}
}
useEffect(() => {
if (progress?.state !== 'ready') {
return;
}
const timer = window.setTimeout(() => {
setDismissed(true);
}, READY_DISMISS_MS);
return () => {
window.clearTimeout(timer);
};
}, [progress?.state, progress?.runId]);
if (!progress || dismissed) {
return null;
}
if (progress.state === 'ready' || progress.state === 'cancelled') {
return null;
}
const isFailed = progress.state === 'failed';
const isDisconnected = progress.state === 'disconnected';
const isActive =
progress.state === 'validating' ||
progress.state === 'spawning' ||
progress.state === 'monitoring' ||
progress.state === 'verifying';
const canCancel =
progress.state === 'spawning' ||
progress.state === 'monitoring' ||
progress.state === 'verifying';
const progressStepIndex = STEP_ORDER.indexOf(progress.state as ProvisioningStep);
if (isFailed) {
return (
<div className="mb-3 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2">
<p className="flex-1 text-xs text-red-200">{progress.message}</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
</div>
);
}
if (isDisconnected) {
return (
<div className="mb-3 flex items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<p className="flex-1 text-xs text-amber-200">Team offline</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-amber-500/40 px-2 text-xs text-amber-300 hover:bg-amber-500/10 hover:text-amber-200"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
</div>
);
}
if (isActive) {
return (
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-[var(--color-text-muted)]">{progress.message}</p>
{canCancel ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-xs"
onClick={() => {
void cancelProvisioning(progress.runId);
}}
>
Cancel
</Button>
) : null}
</div>
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
{STEP_ORDER.map((step, index) => {
const isDone = progressStepIndex >= 0 && index < progressStepIndex;
const isCurrent = progressStepIndex >= 0 && index === progressStepIndex;
return (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
isCurrent &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
</div>
);
})}
</div>
</div>
);
}
return null;
};

View file

@ -0,0 +1,263 @@
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { cn } from '@renderer/lib/utils';
import {
getMessageTypeLabel,
getStructuredMessageSummary,
parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting';
import { CheckCircle2, Circle, ListPlus, LogOut, Power, PowerOff } from 'lucide-react';
import type { InboxMessage } from '@shared/types';
type StructuredMessage = Record<string, unknown>;
interface ActivityItemProps {
message: InboxMessage;
onCreateTask?: (subject: string, description: string) => void;
}
function getStringField(obj: StructuredMessage, key: string): string | null {
const value = obj[key];
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
function formatIdleReason(reason: string | null): string {
if (!reason) {
return 'idle';
}
return reason;
}
function isCompactStructuredDisplay(parsed: StructuredMessage): boolean {
const type = getStringField(parsed, 'type');
return (
type === 'idle_notification' ||
type === 'shutdown_response' ||
type === 'shutdown_request' ||
type === 'shutdown_approved' ||
type === 'teammate_terminated' ||
type === 'task_completed'
);
}
function agentAvatarUrl(name: string): string {
return `https://robohash.org/${encodeURIComponent(name)}?size=48x48`;
}
const AgentAvatar = ({ name }: { name: string }): React.JSX.Element => (
<img
src={agentAvatarUrl(name)}
alt={name}
className="size-5 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
);
const CompactStatusLine = ({
icon,
text,
className,
}: {
icon: React.ReactNode;
text: string;
className?: string;
}): React.JSX.Element => (
<div className={cn('flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs', className)}>
{icon}
<span>{text}</span>
</div>
);
const StructuredCompactDisplay = ({
parsed,
}: {
parsed: StructuredMessage;
}): React.JSX.Element | null => {
const type = getStringField(parsed, 'type');
const from = getStringField(parsed, 'from');
const agentName = from ?? 'agent';
if (type === 'idle_notification') {
const idleReason = getStringField(parsed, 'idleReason');
return (
<CompactStatusLine
icon={<Circle size={12} className="text-[var(--color-text-muted)]" />}
text={`${agentName} is idle (${formatIdleReason(idleReason)})`}
className="bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]"
/>
);
}
if (type === 'shutdown_response') {
const approved = parsed.approve === true;
if (approved) {
return (
<CompactStatusLine
icon={<PowerOff size={12} className="text-red-400" />}
text={`${agentName} has shut down`}
className="bg-red-500/5 text-red-300"
/>
);
}
const reason = getStringField(parsed, 'content') ?? 'declined';
return (
<CompactStatusLine
icon={<Power size={12} className="text-amber-400" />}
text={`${agentName} rejected shutdown: ${reason}`}
className="bg-amber-500/5 text-amber-300"
/>
);
}
if (type === 'shutdown_request') {
const recipient = getStringField(parsed, 'recipient') ?? agentName;
return (
<CompactStatusLine
icon={<LogOut size={12} className="text-amber-400" />}
text={`Shutdown requested for ${recipient}`}
className="bg-amber-500/5 text-amber-300"
/>
);
}
if (type === 'shutdown_approved' || type === 'teammate_terminated') {
return (
<CompactStatusLine
icon={<PowerOff size={12} className="text-red-400" />}
text={`${agentName} ${type === 'shutdown_approved' ? 'shutdown confirmed' : 'terminated'}`}
className="bg-red-500/5 text-red-300"
/>
);
}
if (type === 'task_completed') {
const rawTaskId = parsed.taskId;
const taskId =
typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null;
const taskLabel = taskId !== null ? `task #${taskId}` : 'a task';
return (
<CompactStatusLine
icon={<CheckCircle2 size={12} className="text-emerald-400" />}
text={`${agentName} completed ${taskLabel}`}
className="bg-emerald-500/5 text-emerald-300"
/>
);
}
return null;
};
const StructuredFallbackDisplay = ({
parsed,
autoSummary,
}: {
parsed: StructuredMessage;
autoSummary: string;
}): React.JSX.Element => (
<div className="space-y-2">
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
Raw JSON
</summary>
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{JSON.stringify(parsed, null, 2)}
</pre>
</details>
</div>
);
export const ActivityItem = ({ message, onCreateTask }: ActivityItemProps): React.JSX.Element => {
const timestamp = Number.isNaN(Date.parse(message.timestamp))
? message.timestamp
: new Date(message.timestamp).toLocaleString();
const structured = parseStructuredAgentMessage(message.text);
const messageType =
structured && typeof structured.type === 'string' ? getMessageTypeLabel(structured.type) : null;
const autoSummary = structured ? getStructuredMessageSummary(structured) : null;
const handleCreateTask = (): void => {
const subject = message.summary || autoSummary || `Task from ${message.from}`;
const plainText = structured ? JSON.stringify(structured, null, 2) : message.text;
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
onCreateTask?.(subject, description);
};
const isCompact = structured !== null && isCompactStructuredDisplay(structured);
if (isCompact && structured) {
return (
<article className="group rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<AgentAvatar name={message.from} />
<StructuredCompactDisplay parsed={structured} />
</div>
<div className="flex shrink-0 items-center gap-1.5">
{onCreateTask && (
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)] group-hover:opacity-100"
title="Create task from message"
onClick={handleCreateTask}
>
<ListPlus size={14} />
</button>
)}
<p className="text-[10px] text-[var(--color-text-muted)]">{timestamp}</p>
</div>
</div>
</article>
);
}
return (
<article className="group rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<AgentAvatar name={message.from} />
<p className="truncate text-xs font-medium text-[var(--color-text)]">
{message.from}
{message.to && message.to !== message.from ? (
<span className="font-normal text-[var(--color-text-muted)]">
{' → '}
{message.to}
</span>
) : null}
</p>
{messageType ? (
<span className="rounded border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
{messageType}
</span>
) : null}
</div>
<div className="flex items-center gap-1.5">
{onCreateTask && (
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)] group-hover:opacity-100"
title="Create task from message"
onClick={handleCreateTask}
>
<ListPlus size={14} />
</button>
)}
<p className="text-[10px] text-[var(--color-text-muted)]">{timestamp}</p>
</div>
</div>
{message.summary ? (
<p className="mb-1 text-xs font-medium text-[var(--color-text)]">{message.summary}</p>
) : autoSummary && autoSummary !== messageType ? (
<p className="mb-2 text-xs font-medium text-[var(--color-text)]">{autoSummary}</p>
) : null}
{structured ? (
<StructuredFallbackDisplay
parsed={structured}
autoSummary={autoSummary ?? 'Structured message'}
/>
) : (
<MarkdownViewer content={message.text} maxHeight="max-h-56" copyable className="mt-2" />
)}
</article>
);
};

View file

@ -0,0 +1,34 @@
import { ActivityItem } from './ActivityItem';
import type { InboxMessage } from '@shared/types';
interface ActivityTimelineProps {
messages: InboxMessage[];
onCreateTaskFromMessage?: (subject: string, description: string) => void;
}
export const ActivityTimeline = ({
messages,
onCreateTaskFromMessage,
}: ActivityTimelineProps): React.JSX.Element => {
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
<p>Нет сообщений</p>
<p className="mt-1 text-[11px]">Отправьте сообщение участнику, чтобы увидеть активность.</p>
</div>
);
}
return (
<div className="space-y-2">
{messages.slice(0, 200).map((message) => (
<ActivityItem
key={`${message.messageId ?? 'no-id'}-${message.timestamp}-${message.from}`}
message={message}
onCreateTask={onCreateTaskFromMessage}
/>
))}
</div>
);
};

View file

@ -0,0 +1,137 @@
import { useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { ChevronRight, MessageSquare } from 'lucide-react';
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
interface MessageComposerProps {
members: ResolvedTeamMember[];
sending: boolean;
sendError: string | null;
lastResult: SendMessageResult | null;
onSend: (member: string, text: string, summary?: string) => void;
}
const NO_MEMBER = '__none__';
export const MessageComposer = ({
members,
sending,
sendError,
lastResult,
onSend,
}: MessageComposerProps): React.JSX.Element => {
const [open, setOpen] = useState(false);
const [member, setMember] = useState('');
const [text, setText] = useState('');
const [summary, setSummary] = useState('');
const canSend = member.trim().length > 0 && text.trim().length > 0 && !sending;
return (
<div className="rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-sidebar)]">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left"
onClick={() => setOpen((prev) => !prev)}
>
<MessageSquare size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="flex-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
Send Message
</span>
{lastResult ? (
<span className="text-[10px] text-green-300">
Sent ({lastResult.messageId.slice(0, 8)}...)
</span>
) : null}
<ChevronRight
size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
/>
</button>
{open ? (
<div className="border-t border-[var(--color-border)] px-3 pb-3 pt-2">
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="mc-recipient" className="text-xs text-[var(--color-text-muted)]">
Recipient
</Label>
<Select
value={member || NO_MEMBER}
onValueChange={(value) => setMember(value === NO_MEMBER ? '' : value)}
>
<SelectTrigger id="mc-recipient" className="h-8 text-xs" aria-label="Recipient">
<SelectValue placeholder="Select member..." />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
{members.map((item) => (
<SelectItem key={item.name} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="mc-summary" className="text-xs text-[var(--color-text-muted)]">
Summary (optional)
</Label>
<Input
id="mc-summary"
className="h-8 text-xs"
value={summary}
aria-label="Summary"
onChange={(event) => setSummary(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="mc-message" className="text-xs text-[var(--color-text-muted)]">
Message
</Label>
<Textarea
id="mc-message"
className="min-h-[80px] text-xs"
value={text}
aria-label="Message text"
onChange={(event) => setText(event.target.value)}
/>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
disabled={!canSend}
aria-label="Send message"
onClick={() => {
onSend(member.trim(), text, summary.trim() || undefined);
setText('');
setSummary('');
}}
>
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
{sendError ? <p className="mt-2 text-[10px] text-red-300">{sendError}</p> : null}
</div>
) : null}
</div>
);
};

View file

@ -0,0 +1,197 @@
import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface CreateTaskDialogProps {
open: boolean;
members: ResolvedTeamMember[];
tasks: TeamTask[];
defaultSubject?: string;
defaultDescription?: string;
onClose: () => void;
onSubmit: (subject: string, description: string, owner?: string, blockedBy?: string[]) => void;
submitting?: boolean;
}
export const CreateTaskDialog = ({
open,
members,
tasks,
defaultSubject = '',
defaultDescription = '',
onClose,
onSubmit,
submitting = false,
}: CreateTaskDialogProps): React.JSX.Element => {
const [subject, setSubject] = useState(defaultSubject);
const [description, setDescription] = useState(defaultDescription);
const [owner, setOwner] = useState<string>('');
const [blockedBy, setBlockedBy] = useState<string[]>([]);
const canSubmit = subject.trim().length > 0 && !submitting;
// Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter((t) => t.status !== 'deleted');
const toggleBlockedBy = (taskId: string): void => {
setBlockedBy((prev) =>
prev.includes(taskId) ? prev.filter((id) => id !== taskId) : [...prev, taskId]
);
};
const handleSubmit = (): void => {
if (!canSubmit) return;
onSubmit(
subject.trim(),
description.trim(),
owner || undefined,
blockedBy.length > 0 ? blockedBy : undefined
);
setSubject('');
setDescription('');
setOwner('');
setBlockedBy([]);
};
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
onClose();
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Создать задачу</DialogTitle>
<DialogDescription>
Задача будет создана в директории tasks/ команды и появится на Kanban-доске.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="task-subject">Название</Label>
<Input
id="task-subject"
placeholder="Что нужно сделать?"
value={subject}
autoFocus
onChange={(e) => setSubject(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit) handleSubmit();
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="task-description">Описание (опционально)</Label>
<Textarea
id="task-description"
placeholder="Детали задачи..."
value={description}
rows={3}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>Исполнитель (опционально)</Label>
<Select value={owner} onValueChange={setOwner}>
<SelectTrigger>
<SelectValue placeholder="Не назначен" />
</SelectTrigger>
<SelectContent>
{members.map((m) => {
const role = formatAgentRole(m.agentType);
return (
<SelectItem key={m.name} value={m.name}>
{m.name}
{role ? ` (${role})` : ''}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label>Заблокирована задачами (опционально)</Label>
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
{availableTasks.map((t) => {
const isSelected = blockedBy.includes(t.id);
return (
<button
key={t.id}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleBlockedBy(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-blue-400 bg-blue-500/30 text-blue-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
#{t.id}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
{blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300">
Задача будет заблокирована: {blockedBy.map((id) => `#${id}`).join(', ')}
</p>
) : null}
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Отмена
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? 'Создание...' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,921 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { highlightLine } from '@renderer/components/chat/viewers/syntaxHighlighter';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Combobox } from '@renderer/components/ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { cn } from '@renderer/lib/utils';
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
import { STEP_LABELS, STEP_ORDER } from '../provisioningSteps';
import type { ProvisioningStep } from '../provisioningSteps';
import type {
Project,
TeamCreateRequest,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
} from '@shared/types';
interface CreateTeamDialogProps {
open: boolean;
canCreate: boolean;
provisioningError: string | null;
progress: TeamProvisioningProgress | null;
existingTeamNames: string[];
onClose: () => void;
onCreate: (request: TeamCreateRequest) => Promise<void>;
onCancelProvisioning: (runId: string) => Promise<void>;
onOpenTeam: (teamName: string) => void;
}
interface ValidationResult {
valid: boolean;
errors?: {
teamName?: string;
members?: string;
cwd?: string;
};
}
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
const CUSTOM_ROLE = '__custom__';
const NO_ROLE = '__none__';
const DEV_DEFAULT_TEAM = {
teamName: 'team-alpha',
description: 'Dev test team for provisioning flow',
} as const;
interface MemberDraft {
id: string;
name: string;
roleSelection: string;
customRole: string;
}
const DEV_DEFAULT_MEMBERS: Pick<MemberDraft, 'name' | 'roleSelection'>[] = [
{ name: 'alice', roleSelection: 'reviewer' },
{ name: 'bob', roleSelection: 'developer' },
{ name: 'carol', roleSelection: 'lead' },
];
function newDraftId(): string {
// eslint-disable-next-line sonarjs/pseudo-random -- Used for generating unique UI keys, not security
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
return {
id: initial?.id ?? newDraftId(),
name: initial?.name ?? '',
roleSelection: initial?.roleSelection ?? '',
customRole: initial?.customRole ?? '',
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function renderHighlightedText(text: string, query: string): React.JSX.Element {
if (!query.trim()) {
return <span>{text}</span>;
}
const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig');
const parts = text.split(pattern);
return (
<span>
{parts.map((part, index) => {
const isMatch = part.toLowerCase() === query.toLowerCase();
if (!isMatch) {
return <span key={`${part}-${index}`}>{part}</span>;
}
return (
<mark
key={`${part}-${index}`}
// eslint-disable-next-line tailwindcss/no-custom-classname -- Tailwind arbitrary value with CSS variable
className="bg-[var(--color-accent)]/25 rounded px-0.5 text-[var(--color-text)]"
>
{part}
</mark>
);
})}
</span>
);
}
function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
return members
.map((member) => {
const name = member.name.trim();
if (!name) {
return null;
}
const role =
member.roleSelection === CUSTOM_ROLE
? member.customRole.trim() || undefined
: member.roleSelection === NO_ROLE
? undefined
: member.roleSelection.trim() || undefined;
return {
name,
role,
};
})
.filter((member): member is NonNullable<typeof member> => member !== null);
}
function renderCliLogLine(line: string, index: number): React.JSX.Element {
const trimmed = line.trim();
if (!trimmed) {
return (
<div key={index} className="h-2">
{'\n'}
</div>
);
}
let jsonLines: string[] | null = null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed === 'object' && parsed !== null) {
jsonLines = JSON.stringify(parsed, null, 2).split('\n');
}
} catch {
// Not JSON, render as plain text
}
if (jsonLines) {
return (
<div key={index}>
{jsonLines.map((jsonLine, lineIdx) => (
<div key={lineIdx} className="whitespace-pre font-mono">
{highlightLine(jsonLine, 'json')}
</div>
))}
</div>
);
}
return (
<div key={index} className="whitespace-pre-wrap break-all font-mono [overflow-wrap:anywhere]">
{trimmed}
</div>
);
}
function validateRequest(request: TeamCreateRequest): ValidationResult {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
return {
valid: false,
errors: {
teamName: 'Use kebab-case [a-z0-9-], max 64 chars',
},
};
}
if (!request.cwd.trim()) {
return {
valid: false,
errors: {
cwd: 'Select working directory (cwd)',
},
};
}
if (request.members.length === 0) {
return {
valid: false,
errors: {
members: 'At least one member is required',
},
};
}
if (request.members.some((member) => !member.name.trim())) {
return {
valid: false,
errors: {
members: 'Member name cannot be empty',
},
};
}
const uniqueNames = new Set(request.members.map((member) => member.name.trim().toLowerCase()));
if (uniqueNames.size !== request.members.length) {
return {
valid: false,
errors: {
members: 'Member names must be unique',
},
};
}
return { valid: true };
}
export const CreateTeamDialog = ({
open,
canCreate,
provisioningError,
progress,
existingTeamNames,
onClose,
onCreate,
onCancelProvisioning,
onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => {
const isDev = process.env.NODE_ENV !== 'production';
const [teamName, setTeamName] = useState('');
const [description, setDescription] = useState('');
const [members, setMembers] = useState<MemberDraft[]>([]);
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPath] = useState('');
const [customCwd, setCustomCwd] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [fieldErrors, setFieldErrors] = useState<{
teamName?: string;
members?: string;
cwd?: string;
}>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const autoOpenedRef = useRef(false);
const resetFormState = (): void => {
setLocalError(null);
setFieldErrors({});
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
autoOpenedRef.current = false;
};
useEffect(() => {
if (!open || !canCreate) {
return;
}
if (typeof api.teams.prepareProvisioning !== 'function') {
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
);
return;
}
let cancelled = false;
setPrepareState('loading');
setPrepareMessage('Warming up CLI environment...');
setPrepareWarnings([]);
void (async () => {
try {
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
if (cancelled) {
return;
}
setPrepareState(prepResult.ready ? 'ready' : 'failed');
setPrepareMessage(prepResult.message);
setPrepareWarnings(prepResult.warnings ?? []);
} catch (error) {
if (cancelled) {
return;
}
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareMessage(
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
);
}
})();
return () => {
cancelled = true;
};
}, [open, canCreate]);
useEffect(() => {
if (!open) {
return;
}
setProjectsLoading(true);
setProjectsError(null);
let cancelled = false;
void (async () => {
try {
const nextProjects = await api.getProjects();
if (cancelled) {
return;
}
setProjects(nextProjects);
} catch (error) {
if (cancelled) {
return;
}
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
setProjects([]);
} finally {
if (!cancelled) {
setProjectsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [open]);
useEffect(() => {
if (!open || members.length > 0) {
return;
}
if (isDev) {
setMembers(
DEV_DEFAULT_MEMBERS.map((member) =>
createMemberDraft({
name: member.name,
roleSelection: member.roleSelection,
})
)
);
return;
}
setMembers([createMemberDraft()]);
}, [isDev, members.length, open]);
useEffect(() => {
if (!open || !isDev) {
return;
}
if (teamName.trim().length === 0) {
setTeamName(DEV_DEFAULT_TEAM.teamName);
}
if (description.trim().length === 0) {
setDescription(DEV_DEFAULT_TEAM.description);
}
}, [open, isDev, teamName, description]);
useEffect(() => {
if (cwdMode !== 'project') {
return;
}
if (selectedProjectPath || projects.length === 0) {
return;
}
setSelectedProjectPath(projects[0].path);
}, [cwdMode, projects, selectedProjectPath]);
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const request = useMemo<TeamCreateRequest>(
() => ({
teamName: teamName.trim(),
description: description.trim() || undefined,
members: buildMembers(members),
cwd: effectiveCwd,
}),
[teamName, description, members, effectiveCwd]
);
const activeError = localError ?? provisioningError;
const isReady = progress?.state === 'ready' || progress?.state === 'disconnected';
const isFailed = progress?.state === 'failed';
// Navigate as soon as team actually appears in the teams list (FileWatcher detected config.json)
const provisioningTeamName = progress?.teamName;
const teamExistsInList =
provisioningTeamName != null && existingTeamNames.includes(provisioningTeamName);
useEffect(() => {
if (!teamExistsInList || !provisioningTeamName) {
return;
}
if (autoOpenedRef.current) {
return;
}
autoOpenedRef.current = true;
onOpenTeam(provisioningTeamName);
onClose();
}, [teamExistsInList, provisioningTeamName, onOpenTeam, onClose]);
const canCancel =
progress?.state === 'spawning' ||
progress?.state === 'monitoring' ||
progress?.state === 'verifying';
const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
const progressStepIndex = progress ? STEP_ORDER.indexOf(progress.state as ProvisioningStep) : -1;
const failedStepIndex =
progress?.state === 'failed'
? Math.max(
0,
STEP_ORDER.findIndex((step) => progress.message.toLowerCase().includes(step))
)
: -1;
const cliLogsText = (() => {
const tail = progress?.cliLogsTail?.trim();
if (tail) {
return tail;
}
if (!progress) {
return '';
}
if (progress.state === 'ready' || progress.state === 'disconnected') {
const timedOut =
progress.warnings?.some((w) => w.toLowerCase().includes('timed out')) ?? false;
return timedOut
? 'No CLI output captured before timeout. The team appears ready based on created files.'
: 'No CLI output captured.';
}
if (progress.state === 'failed') {
return 'No CLI output captured.';
}
if (progress.state === 'cancelled') {
return 'Cancelled.';
}
return 'Waiting for CLI output...';
})();
const updateMemberName = (memberId: string, name: string): void => {
setMembers((prev) =>
prev.map((candidate) => (candidate.id === memberId ? { ...candidate, name } : candidate))
);
};
const updateMemberRole = (memberId: string, roleSelection: string): void => {
const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection;
setMembers((prev) =>
prev.map((candidate) =>
candidate.id === memberId
? {
...candidate,
roleSelection: resolvedRole,
customRole: resolvedRole === CUSTOM_ROLE ? candidate.customRole : '',
}
: candidate
)
);
};
const updateMemberCustomRole = (memberId: string, customRole: string): void => {
setMembers((prev) =>
prev.map((candidate) =>
candidate.id === memberId ? { ...candidate, customRole } : candidate
)
);
};
const removeMember = (memberId: string): void => {
setMembers((prev) => prev.filter((candidate) => candidate.id !== memberId));
};
const handleSubmit = (): void => {
const validation = validateRequest(request);
if (!validation.valid) {
setFieldErrors(validation.errors ?? {});
setLocalError('Check form fields');
return;
}
setFieldErrors({});
setLocalError(null);
setIsSubmitting(true);
void (async () => {
try {
await onCreate(request);
} finally {
setIsSubmitting(false);
}
})();
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
resetFormState();
onClose();
}
}}
>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm">Create Team</DialogTitle>
<DialogDescription className="text-xs">
Team provisioning via local Claude CLI.
</DialogDescription>
</DialogHeader>
{canCreate && prepareState === 'failed' ? (
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
{prepareWarnings.length > 0 ? (
<div className="mt-1 space-y-1">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
</div>
) : null}
</div>
) : null}
{!canCreate ? (
<p className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-300">
Available only in local Electron mode.
</p>
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-name" className="text-xs text-[var(--color-text-muted)]">
teamName
</Label>
<Input
id="team-name"
className="h-8 text-xs"
value={teamName}
onChange={(event) => setTeamName(event.target.value)}
placeholder="team-alpha"
/>
{fieldErrors.teamName ? (
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-description" className="text-xs text-[var(--color-text-muted)]">
description (optional)
</Label>
<Textarea
id="team-description"
className="min-h-[40px] resize-none text-xs"
rows={2}
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Brief description of the team purpose"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">members</Label>
<div className="space-y-2">
{members.map((member, index) => (
<div
key={member.id}
className="grid grid-cols-1 gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2 md:grid-cols-[1fr_220px_auto]"
>
<Input
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
onChange={(event) => updateMemberName(member.id, event.target.value)}
placeholder="member-name"
/>
<div className="space-y-1">
<Select
value={member.roleSelection || NO_ROLE}
onValueChange={(roleSelection) => updateMemberRole(member.id, roleSelection)}
>
<SelectTrigger
className="h-8 text-xs"
aria-label={`Member ${index + 1} role`}
>
<SelectValue placeholder="No role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_ROLE}>No role</SelectItem>
{PRESET_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
</SelectContent>
</Select>
{member.roleSelection === CUSTOM_ROLE ? (
<Input
className="h-8 text-xs"
value={member.customRole}
aria-label={`Member ${index + 1} custom role`}
onChange={(event) => updateMemberCustomRole(member.id, event.target.value)}
placeholder="e.g. architect"
/>
) : null}
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={() => removeMember(member.id)}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
setMembers((prev) => [...prev, createMemberDraft()]);
}}
>
Add member
</Button>
</div>
{fieldErrors.members ? (
<p className="text-[11px] text-red-300">{fieldErrors.members}</p>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant={cwdMode === 'project' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('project')}
>
From project list
</Button>
<Button
type="button"
variant={cwdMode === 'custom' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('custom')}
>
Custom path
</Button>
</div>
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={setSelectedProjectPath}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? (
<p className="text-[11px] text-red-300">{projectsError}</p>
) : null}
{!projectsLoading && projects.length === 0 ? (
<p className="text-[11px] text-amber-300">
No projects found, switch to custom path.
</p>
) : null}
</div>
) : (
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
className="h-8 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => setCustomCwd(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
setCustomCwd(paths[0]);
}
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
)}
</div>
{fieldErrors.cwd ? <p className="text-[11px] text-red-300">{fieldErrors.cwd}</p> : null}
</div>
</div>
{progress ? (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3">
<p className="text-xs font-medium text-[var(--color-text)]">Provisioning Progress</p>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{progress.message}</p>
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-1">
{STEP_ORDER.map((step, index) => {
const isFailedStep = progress.state === 'failed' && index === failedStepIndex;
const isDone =
progress.state === 'ready' ||
(progressStepIndex >= 0 && index < progressStepIndex) ||
(progress.state === 'failed' && index < failedStepIndex);
const isActive =
(progressStepIndex >= 0 && index === progressStepIndex) || isFailedStep;
return (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isFailedStep && 'border-red-400/60 bg-red-500/10 text-red-300',
!isFailedStep &&
isDone &&
'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
!isFailedStep &&
!isDone &&
isActive &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
</div>
);
})}
</div>
{progress.warnings?.length ? (
<div className="mt-2 space-y-1">
{progress.warnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
</div>
) : null}
<div className="mt-2">
<p className="mb-1 text-[11px] font-medium text-[var(--color-text)]">
Claude CLI logs
</p>
<div className="max-h-44 min-w-0 overflow-auto rounded border border-[var(--color-border)] bg-black/25 p-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{cliLogsText.split('\n').map((line, index) => renderCliLogLine(line, index))}
</div>
</div>
</div>
) : null}
{activeError ? (
<p className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
{activeError}
</p>
) : null}
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<span className="text-[var(--color-text-muted)]">&middot;</span>
<span>Team provisioning via local Claude CLI.</span>
</div>
) : null}
{canCreate && prepareState === 'ready' ? (
<div className="flex items-center gap-2 text-xs text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>CLI environment ready</span>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
{canCancel ? (
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={() => {
void onCancelProvisioning(progress.runId);
}}
>
Cancel
</Button>
) : null}
{isReady ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenTeam(progress.teamName);
onClose();
}}
>
Open Team
</Button>
) : null}
{canOpenExistingTeam ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenTeam(request.teamName);
onClose();
}}
>
Open Existing Team
</Button>
) : null}
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
<Button
size="sm"
disabled={!canCreate || isSubmitting || prepareState !== 'ready' || !!progress}
onClick={handleSubmit}
>
{isSubmitting || progress ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Creating...
</>
) : isFailed ? (
'Retry'
) : (
'Create'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,78 @@
import { useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Label } from '@renderer/components/ui/label';
import { Textarea } from '@renderer/components/ui/textarea';
interface ReviewDialogProps {
open: boolean;
taskId: string | null;
onCancel: () => void;
onSubmit: (comment?: string) => void;
}
export const ReviewDialog = ({
open,
taskId,
onCancel,
onSubmit,
}: ReviewDialogProps): React.JSX.Element => {
const [comment, setComment] = useState('');
const handleCancel = (): void => {
setComment('');
onCancel();
};
const handleSubmit = (): void => {
const trimmed = comment.trim() || undefined;
setComment('');
onSubmit(trimmed);
};
return (
<Dialog
open={open && taskId !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleCancel();
}
}}
>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Request Changes</DialogTitle>
<DialogDescription>Task #{taskId}</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
<Label htmlFor="review-comment">Comment (optional)</Label>
<Textarea
id="review-comment"
className="min-h-[110px] text-xs"
value={comment}
placeholder="Describe what needs to change..."
onChange={(event) => setComment(event.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleSubmit}>
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,99 @@
import { KanbanColumn } from './KanbanColumn';
import { KanbanTaskCard } from './KanbanTaskCard';
import type { KanbanColumnId, KanbanState, TeamTask } from '@shared/types';
interface KanbanBoardProps {
tasks: TeamTask[];
kanbanState: KanbanState;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
onMoveBackToDone: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
}
const COLUMNS: { id: KanbanColumnId; title: string }[] = [
{ id: 'todo', title: 'TODO' },
{ id: 'in_progress', title: 'IN PROGRESS' },
{ id: 'done', title: 'DONE' },
{ id: 'review', title: 'REVIEW' },
{ id: 'approved', title: 'APPROVED' },
];
function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId | null {
const explicit = kanbanState.tasks[task.id];
if (explicit?.column) {
return explicit.column;
}
if (task.status === 'pending') {
return 'todo';
}
if (task.status === 'in_progress') {
return 'in_progress';
}
if (task.status === 'completed') {
return 'done';
}
return null;
}
export const KanbanBoard = ({
tasks,
kanbanState,
onRequestReview,
onApprove,
onRequestChanges,
onMoveBackToDone,
onCompleteTask,
onScrollToTask,
}: KanbanBoardProps): React.JSX.Element => {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const grouped = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
);
for (const task of tasks) {
const column = getTaskColumn(task, kanbanState);
if (!column) {
continue;
}
grouped.get(column)?.push(task);
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? [];
return (
<KanbanColumn key={column.id} title={column.title} count={columnTasks.length}>
{columnTasks.length === 0 ? (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
) : (
columnTasks.map((task) => (
<KanbanTaskCard
key={task.id}
task={task}
columnId={column.id}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
taskMap={taskMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onCompleteTask={onCompleteTask}
onScrollToTask={onScrollToTask}
/>
))
)}
</KanbanColumn>
);
})}
</div>
);
};

View file

@ -0,0 +1,23 @@
import { Badge } from '@renderer/components/ui/badge';
interface KanbanColumnProps {
title: string;
count: number;
children: React.ReactNode;
}
export const KanbanColumn = ({ title, count, children }: KanbanColumnProps): React.JSX.Element => {
return (
<section className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
{title}
</h4>
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
{count}
</Badge>
</header>
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
</section>
);
};

View file

@ -0,0 +1,191 @@
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2 } from 'lucide-react';
import { ReviewBadge } from './ReviewBadge';
import type { KanbanColumnId, KanbanTaskState, TeamTask } from '@shared/types';
interface KanbanTaskCardProps {
task: TeamTask;
columnId: KanbanColumnId;
kanbanTaskState?: KanbanTaskState;
hasReviewers: boolean;
taskMap: Map<string, TeamTask>;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
onMoveBackToDone: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
}
interface DependencyBadgeProps {
taskId: string;
taskMap: Map<string, TeamTask>;
onScrollToTask?: (taskId: string) => void;
}
const DependencyBadge = ({
taskId,
taskMap,
onScrollToTask,
}: DependencyBadgeProps): React.JSX.Element => {
const depTask = taskMap.get(taskId);
const isCompleted = depTask?.status === 'completed';
return (
<button
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} ${onScrollToTask ? 'cursor-pointer' : ''}`}
title={depTask ? `#${taskId}: ${depTask.subject}` : `#${taskId}`}
onClick={(e) => {
e.stopPropagation();
onScrollToTask?.(taskId);
}}
>
#{taskId}
</button>
);
};
export const KanbanTaskCard = ({
task,
columnId,
kanbanTaskState,
hasReviewers,
taskMap,
onRequestReview,
onApprove,
onRequestChanges,
onMoveBackToDone,
onCompleteTask,
onScrollToTask,
}: KanbanTaskCardProps): React.JSX.Element => {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const hasBlockedBy = blockedByIds.length > 0;
const hasBlocks = blocksIds.length > 0;
return (
<article
data-task-id={task.id}
className={`rounded-md border p-3 ${
hasBlockedBy
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
}`}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<Badge variant="secondary" className="mb-1 px-1.5 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
<h5 className="text-sm font-medium text-[var(--color-text)]">{task.subject}</h5>
</div>
{columnId === 'review' ? <ReviewBadge status={kanbanTaskState?.reviewStatus} /> : null}
</div>
<p className="mb-2 text-xs text-[var(--color-text-muted)]">Owner: {task.owner ?? '\u2014'}</p>
{hasBlockedBy ? (
<div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-300">
<ArrowLeftFromLine size={10} />
Blocked by
</span>
{blockedByIds.map((id) => (
<DependencyBadge
key={id}
taskId={id}
taskMap={taskMap}
onScrollToTask={onScrollToTask}
/>
))}
</div>
) : null}
{hasBlocks ? (
<div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-blue-400">
<ArrowRightFromLine size={10} />
Blocks
</span>
{blocksIds.map((id) => (
<DependencyBadge
key={id}
taskId={id}
taskMap={taskMap}
onScrollToTask={onScrollToTask}
/>
))}
</div>
) : null}
{columnId === 'todo' || columnId === 'in_progress' ? (
<Button
variant="outline"
size="sm"
className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={() => onCompleteTask(task.id)}
>
<CheckCircle2 size={12} />
Complete
</Button>
) : null}
{columnId === 'done' ? (
<Button
variant="outline"
size="sm"
aria-label={`Request review for task ${task.id}`}
onClick={() => onRequestReview(task.id)}
>
Request Review
</Button>
) : null}
{columnId === 'review' ? (
<div className="space-y-2">
{!hasReviewers ? (
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
) : null}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
aria-label={`Approve task ${task.id}`}
onClick={() => onApprove(task.id)}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
aria-label={`Request changes for task ${task.id}`}
onClick={() => onRequestChanges(task.id)}
>
Request Changes
</Button>
</div>
</div>
) : null}
{columnId === 'approved' ? (
<Button
variant="outline"
size="sm"
aria-label={`Move task ${task.id} back to done`}
onClick={() => onMoveBackToDone(task.id)}
>
Move back to DONE
</Button>
) : null}
</article>
);
};

View file

@ -0,0 +1,17 @@
interface ReviewBadgeProps {
status?: 'pending' | 'error';
}
export const ReviewBadge = ({ status = 'pending' }: ReviewBadgeProps): React.JSX.Element => {
const isError = status === 'error';
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${
isError ? 'bg-red-500/15 text-red-300' : 'bg-yellow-500/15 text-yellow-300'
}`}
>
{isError ? 'Review: Error' : 'Review: Pending'}
</span>
);
};

View file

@ -0,0 +1,68 @@
import { Badge } from '@renderer/components/ui/badge';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberCardProps {
member: ResolvedTeamMember;
}
const statusDotColor: Record<string, string> = {
active: 'bg-emerald-400',
idle: 'bg-emerald-400/50',
terminated: 'bg-zinc-500',
unknown: 'bg-zinc-600',
};
export const MemberCard = ({ member }: MemberCardProps): React.JSX.Element => {
const dotClass =
member.status === 'terminated'
? statusDotColor.terminated
: member.currentTaskId
? statusDotColor.active
: statusDotColor.idle;
const avatarUrl = `https://robohash.org/${encodeURIComponent(member.name)}?size=64x64`;
const presenceLabel =
member.status === 'terminated' ? 'terminated' : member.currentTaskId ? 'working' : 'idle';
return (
<div
className="group flex items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]"
title={member.currentTaskId ? `Текущая задача: ${member.currentTaskId}` : undefined}
>
<div className="relative shrink-0">
<img
src={avatarUrl}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={member.status}
/>
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
{member.name}
</span>
{formatAgentRole(member.agentType) && (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{formatAgentRole(member.agentType)}
</span>
)}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={member.currentTaskId ? `Текущая задача: ${member.currentTaskId}` : undefined}
>
{presenceLabel}
</Badge>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
</div>
);
};

View file

@ -0,0 +1,25 @@
import { MemberCard } from './MemberCard';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
}
export const MemberList = ({ members }: MemberListProps): React.JSX.Element => {
if (members.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Участники не найдены
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{members.map((member) => (
<MemberCard key={member.name} member={member} />
))}
</div>
);
};

View file

@ -0,0 +1,9 @@
export const STEP_ORDER = ['validating', 'spawning', 'monitoring', 'verifying', 'ready'] as const;
export type ProvisioningStep = (typeof STEP_ORDER)[number];
export const STEP_LABELS: Record<ProvisioningStep, string> = {
validating: 'Validate',
spawning: 'Start CLI',
monitoring: 'Wait for files',
verifying: 'Verify',
ready: 'Ready',
};

View file

@ -0,0 +1,103 @@
import { useMemo, useState } from 'react';
import { TaskRow } from './TaskRow';
import type { TeamTask } from '@shared/types';
interface TaskListProps {
tasks: TeamTask[];
}
export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
const [ownerFilter, setOwnerFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const ownerOptions = useMemo(() => {
return Array.from(
new Set(tasks.map((task) => task.owner).filter((owner): owner is string => !!owner))
);
}, [tasks]);
const filteredTasks = useMemo(() => {
return tasks.filter((task) => {
const ownerOk = ownerFilter === 'all' || task.owner === ownerFilter;
const statusOk = statusFilter === 'all' || task.status === statusFilter;
return ownerOk && statusOk;
});
}, [tasks, ownerFilter, statusFilter]);
if (tasks.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Нет задач в этой команде
</div>
);
}
const showFilters = tasks.length > 200;
return (
<div className="overflow-hidden rounded-md border border-[var(--color-border)]">
{showFilters ? (
<div className="flex flex-wrap gap-2 border-b border-[var(--color-border)] p-2">
<select
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text)]"
value={ownerFilter}
aria-label="Filter tasks by owner"
onChange={(event) => setOwnerFilter(event.target.value)}
>
<option value="all">All owners</option>
{ownerOptions.map((owner) => (
<option key={owner} value={owner}>
{owner}
</option>
))}
</select>
<select
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text)]"
value={statusFilter}
aria-label="Filter tasks by status"
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">All statuses</option>
<option value="pending">pending</option>
<option value="in_progress">in_progress</option>
<option value="completed">completed</option>
<option value="deleted">deleted</option>
</select>
<p className="self-center text-[11px] text-[var(--color-text-muted)]">
Showing {filteredTasks.length} of {tasks.length}
</p>
</div>
) : null}
<table className="min-w-full table-fixed">
<thead className="bg-[var(--color-surface-raised)]">
<tr>
<th className="w-16 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-muted)]">
ID
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-muted)]">
Subject
</th>
<th className="w-40 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-muted)]">
Owner
</th>
<th className="w-32 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-muted)]">
Status
</th>
<th className="w-28 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-muted)]">
Blocked By
</th>
<th className="w-28 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-muted)]">
Blocks
</th>
</tr>
</thead>
<tbody>
{filteredTasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</tbody>
</table>
</div>
);
};

View file

@ -0,0 +1,33 @@
import type { TeamTask } from '@shared/types';
interface TaskRowProps {
task: TeamTask;
}
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
return (
<tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? '\u2014'}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.status}</td>
<td className="px-3 py-2 text-xs">
{blockedByIds.length > 0 ? (
<span className="text-yellow-300">{blockedByIds.map((id) => `#${id}`).join(', ')}</span>
) : (
<span className="text-[var(--color-text-muted)]">{'\u2014'}</span>
)}
</td>
<td className="px-3 py-2 text-xs">
{blocksIds.length > 0 ? (
<span className="text-blue-400">{blocksIds.map((id) => `#${id}`).join(', ')}</span>
) : (
<span className="text-[var(--color-text-muted)]">{'\u2014'}</span>
)}
</td>
</tr>
);
};

View file

@ -0,0 +1,34 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import { cn } from '@renderer/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-border-emphasis)] focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-[var(--color-text)] text-[var(--color-surface)] shadow',
secondary:
'border-transparent bg-[var(--color-surface-raised)] text-[var(--color-text-secondary)]',
destructive: 'border-transparent bg-red-500 text-white shadow',
outline: 'border-[var(--color-border)] text-[var(--color-text)]',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
const Badge = ({ className, variant, ...props }: BadgeProps): React.JSX.Element => {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
};
// eslint-disable-next-line react-refresh/only-export-components -- Standard shadcn export pattern
export { Badge, badgeVariants };
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -0,0 +1,54 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@renderer/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-[var(--color-text)] text-[var(--color-surface)] shadow hover:bg-[var(--color-text)]/90',
destructive: 'bg-red-500 text-white shadow-sm hover:bg-red-500/90',
outline:
'border border-[var(--color-border)] bg-transparent shadow-sm hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]',
secondary:
'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm hover:bg-[var(--color-surface-raised)]/80',
ghost: 'hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]',
link: 'text-[var(--color-text)] underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = 'Button';
// eslint-disable-next-line react-refresh/only-export-components -- Standard shadcn export pattern
export { Button, buttonVariants };
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -0,0 +1,138 @@
import * as React from 'react';
import { cn } from '@renderer/lib/utils';
import { Command as CommandPrimitive } from 'cmdk';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
interface ComboboxOption {
value: string;
label: string;
description?: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
className?: string;
renderOption?: (option: ComboboxOption, isSelected: boolean, query: string) => React.ReactNode;
}
export const Combobox = ({
options,
value,
onValueChange,
placeholder = 'Select...',
searchPlaceholder = 'Search...',
emptyMessage = 'Nothing found.',
disabled = false,
className,
renderOption,
}: ComboboxProps): React.JSX.Element => {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const listboxId = React.useId();
const selectedOption = options.find((opt) => opt.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
className={cn(
'flex h-8 w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
>
<span className="min-w-0 truncate text-left">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
sideOffset={4}
>
<CommandPrimitive
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
shouldFilter={false}
>
<div className="flex items-center border-b border-[var(--color-border)] px-2">
<CommandPrimitive.Input
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
className="flex h-8 w-full border-0 bg-transparent py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
</div>
<CommandPrimitive.List id={listboxId} className="max-h-52 overflow-auto p-1">
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
{emptyMessage}
</CommandPrimitive.Empty>
{options
.filter((opt) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
opt.label.toLowerCase().includes(q) ||
opt.value.toLowerCase().includes(q) ||
(opt.description?.toLowerCase().includes(q) ?? false)
);
})
.map((option) => {
const isSelected = option.value === value;
return (
<CommandPrimitive.Item
key={option.value}
value={option.value}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
setSearch('');
}}
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
{renderOption ? (
renderOption(option, isSelected, search)
) : (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{option.label}
</p>
{option.description ? (
<p className="truncate text-[var(--color-text-muted)]">
{option.description}
</p>
) : null}
</div>
</>
)}
</CommandPrimitive.Item>
);
})}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
</Popover>
);
};

View file

@ -0,0 +1,108 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@renderer/lib/utils';
import { X } from 'lucide-react';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
<X className="size-4 text-[var(--color-text-muted)]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight text-[var(--color-text)]',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-[var(--color-text-muted)]', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@renderer/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View file

@ -0,0 +1,21 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@renderer/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const labelVariants = cva(
'text-sm font-medium leading-none text-[var(--color-text)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -0,0 +1,31 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@renderer/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -0,0 +1,150 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { cn } from '@renderer/lib/utils';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-[var(--color-border)] bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-[var(--color-surface)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="size-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="size-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-[var(--color-text-muted)]', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-[var(--color-surface-raised)] focus:text-[var(--color-text)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-[var(--color-border)]', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@renderer/lib/utils';
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-[var(--color-border)] bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View file

@ -16,15 +16,15 @@ import { createRepositorySlice } from './slices/repositorySlice';
import { createSessionDetailSlice } from './slices/sessionDetailSlice';
import { createSessionSlice } from './slices/sessionSlice';
import { createSubagentSlice } from './slices/subagentSlice';
import { createTeamSlice } from './slices/teamSlice';
import { createTabSlice } from './slices/tabSlice';
import { createTabUISlice } from './slices/tabUISlice';
import { createTeamSlice } from './slices/teamSlice';
import { createUISlice } from './slices/uiSlice';
import { createUpdateSlice } from './slices/updateSlice';
import type { DetectedError } from '../types/data';
import type { AppState } from './types';
import type { UpdaterStatus } from '@shared/types';
import type { TeamChangeEvent, UpdaterStatus } from '@shared/types';
// =============================================================================
// Store Creation
@ -63,10 +63,19 @@ export const useStore = create<AppState>()((...args) => ({
*/
export function initializeNotificationListeners(): () => void {
const cleanupFns: (() => void)[] = [];
useStore.getState().subscribeProvisioningProgress();
cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress();
});
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const SESSION_REFRESH_DEBOUNCE_MS = 150;
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
const TEAM_REFRESH_THROTTLE_MS = 800;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
if (!projectId) return null;
const separatorIndex = projectId.indexOf('::');
@ -169,6 +178,16 @@ export function initializeNotificationListeners(): () => void {
);
};
const isTeamVisibleInAnyPane = (teamName: string): boolean => {
const { paneLayout } = useStore.getState();
return paneLayout.panes.some((pane) => {
if (!pane.activeTabId) return false;
return pane.tabs.some(
(tab) => tab.id === pane.activeTabId && tab.type === 'team' && tab.teamName === teamName
);
});
};
// Listen for task-list file changes to refresh currently viewed session metadata
if (api.onTodoChange) {
const cleanup = api.onTodoChange((event) => {
@ -270,6 +289,48 @@ export function initializeNotificationListeners(): () => void {
}
}
if (api.teams?.onTeamChange) {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
// Throttled refresh of summary list (keeps TeamListView current without flooding).
if (!teamListRefreshTimer) {
teamListRefreshTimer = setTimeout(() => {
teamListRefreshTimer = null;
void useStore.getState().fetchTeams();
}, TEAM_LIST_REFRESH_THROTTLE_MS);
}
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
// Throttle (not debounce): keep at most one pending detail refresh.
// Debounce would delay indefinitely while inbox messages keep arriving.
if (teamRefreshTimer) {
return;
}
teamRefreshTimer = setTimeout(() => {
teamRefreshTimer = null;
const current = useStore.getState();
void current.refreshTeamData(event.teamName);
}, TEAM_REFRESH_THROTTLE_MS);
});
if (typeof cleanup === 'function') {
cleanupFns.push(() => {
cleanup();
if (teamRefreshTimer) {
clearTimeout(teamRefreshTimer);
teamRefreshTimer = null;
}
if (teamListRefreshTimer) {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;
}
});
}
}
// Listen for updater status events from main process
if (api.updater?.onStatus) {
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {

View file

@ -2,21 +2,89 @@ import { api } from '@renderer/api';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import type { AppState } from '../types';
import type { TeamSummary } from '@shared/types';
import type {
CreateTaskRequest,
SendMessageRequest,
SendMessageResult,
TeamCreateRequest,
TeamData,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
UpdateKanbanPatch,
} from '@shared/types';
import type { StateCreator } from 'zustand';
function mapSendMessageError(error: unknown): string {
const message =
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
if (message.includes('Failed to verify inbox write')) {
return 'Message was written but not verified (race). Please try again.';
}
return message || 'Failed to send message';
}
function mapReviewError(error: unknown): string {
const message =
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
if (message.includes('Task status update verification failed')) {
return 'Failed to update task status (possible agent conflict).';
}
return message || 'Failed to perform review action';
}
export interface TeamSlice {
teams: TeamSummary[];
teamsLoading: boolean;
teamsError: string | null;
selectedTeamName: string | null;
selectedTeamData: TeamData | null;
selectedTeamLoading: boolean;
selectedTeamError: string | null;
sendingMessage: boolean;
sendMessageError: string | null;
lastSendMessageResult: SendMessageResult | null;
reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>;
activeProvisioningRunId: string | null;
provisioningError: string | null;
provisioningProgressUnsubscribe: (() => void) | null;
fetchTeams: () => Promise<void>;
openTeamsTab: () => void;
openTeamTab: (teamName: string) => void;
selectTeam: (teamName: string) => Promise<void>;
refreshTeamData: (teamName: string) => Promise<void>;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
cancelProvisioning: (runId: string) => Promise<void>;
getProvisioningStatus: (runId: string) => Promise<void>;
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
subscribeProvisioningProgress: () => void;
unsubscribeProvisioningProgress: () => void;
}
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
teams: [],
teamsLoading: false,
teamsError: null,
selectedTeamName: null,
selectedTeamData: null,
selectedTeamLoading: false,
selectedTeamError: null,
sendingMessage: false,
sendMessageError: null,
lastSendMessageResult: null,
reviewActionError: null,
provisioningRuns: {},
activeProvisioningRunId: null,
provisioningError: null,
provisioningProgressUnsubscribe: null,
fetchTeams: async () => {
set({ teamsLoading: true, teamsError: null });
@ -50,4 +118,231 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
label: 'Teams',
});
},
openTeamTab: (teamName: string) => {
if (!teamName.trim()) {
return;
}
const state = get();
const allTabs = state.getAllPaneTabs();
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (existing) {
state.setActiveTab(existing.id);
return;
}
state.openTab({
type: 'team',
label: teamName,
teamName,
});
},
selectTeam: async (teamName: string) => {
set({
selectedTeamName: teamName,
selectedTeamLoading: true,
selectedTeamError: null,
reviewActionError: null,
});
try {
const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName));
set({
selectedTeamName: teamName,
selectedTeamData: data,
selectedTeamLoading: false,
selectedTeamError: null,
});
} catch (error) {
set({
selectedTeamLoading: false,
selectedTeamData: null,
selectedTeamError:
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to fetch team data',
});
}
},
refreshTeamData: async (teamName: string) => {
const state = get();
if (state.selectedTeamName !== teamName) {
return;
}
// Silent refresh — update data without showing loading skeleton.
// Only selectTeam() sets loading: true (for initial load).
try {
const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName));
// Re-check after async: the user might have navigated away.
if (get().selectedTeamName !== teamName) {
return;
}
set({
selectedTeamData: data,
selectedTeamError: null,
});
} catch (error) {
if (get().selectedTeamName !== teamName) {
return;
}
set({
selectedTeamError:
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to refresh team data',
});
}
},
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
try {
set({ reviewActionError: null });
await unwrapIpc('team:updateKanban', () => api.teams.updateKanban(teamName, taskId, patch));
await get().refreshTeamData(teamName);
} catch (error) {
set({
reviewActionError: mapReviewError(error),
});
throw error;
}
},
sendTeamMessage: async (teamName: string, request: SendMessageRequest) => {
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
try {
const result = await unwrapIpc('team:sendMessage', () =>
api.teams.sendMessage(teamName, request)
);
set({
sendingMessage: false,
sendMessageError: null,
lastSendMessageResult: result,
});
await get().refreshTeamData(teamName);
} catch (error) {
set({
sendingMessage: false,
lastSendMessageResult: null,
sendMessageError: mapSendMessageError(error),
});
}
},
requestReview: async (teamName: string, taskId: string) => {
try {
set({ reviewActionError: null });
await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId));
await get().refreshTeamData(teamName);
} catch (error) {
set({
reviewActionError: mapReviewError(error),
});
throw error;
}
},
createTeamTask: async (teamName: string, request: CreateTaskRequest) => {
const task = await unwrapIpc('team:createTask', () => api.teams.createTask(teamName, request));
await get().refreshTeamData(teamName);
return task;
},
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
await unwrapIpc('team:updateTaskStatus', () =>
api.teams.updateTaskStatus(teamName, taskId, status)
);
await get().refreshTeamData(teamName);
},
deleteTeam: async (teamName: string) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
const state = get();
if (state.selectedTeamName === teamName) {
set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null });
}
await get().fetchTeams();
},
createTeam: async (request: TeamCreateRequest) => {
set({ provisioningError: null });
try {
if (typeof api.teams.createTeam !== 'function') {
throw new Error(
'Current preload version does not support team:create. Restart the dev app.'
);
}
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
set({
activeProvisioningRunId: response.runId,
provisioningError: null,
});
await get().getProvisioningStatus(response.runId);
return response.runId;
} catch (error) {
set({
provisioningError:
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to create team',
});
throw error;
}
},
getProvisioningStatus: async (runId: string) => {
const progress = await unwrapIpc('team:provisioningStatus', () =>
api.teams.getProvisioningStatus(runId)
);
get().onProvisioningProgress(progress);
},
cancelProvisioning: async (runId: string) => {
await unwrapIpc('team:cancelProvisioning', () => api.teams.cancelProvisioning(runId));
},
onProvisioningProgress: (progress: TeamProvisioningProgress) => {
set((state) => ({
provisioningRuns: {
...state.provisioningRuns,
[progress.runId]: progress,
},
activeProvisioningRunId: progress.runId,
provisioningError: progress.state === 'failed' ? (progress.error ?? null) : null,
}));
if (progress.state === 'ready' || progress.state === 'disconnected') {
void get().fetchTeams();
}
},
subscribeProvisioningProgress: () => {
const existing = get().provisioningProgressUnsubscribe;
if (existing) {
return;
}
if (!api.teams?.onProvisioningProgress) {
return;
}
const unsubscribe = api.teams.onProvisioningProgress((_event, progress) => {
get().onProvisioningProgress(progress);
});
set({ provisioningProgressUnsubscribe: unsubscribe });
},
unsubscribeProvisioningProgress: () => {
const unsubscribe = get().provisioningProgressUnsubscribe;
if (unsubscribe) {
unsubscribe();
set({ provisioningProgressUnsubscribe: null });
}
},
});

View file

@ -14,9 +14,9 @@ import type { RepositorySlice } from './slices/repositorySlice';
import type { SessionDetailSlice } from './slices/sessionDetailSlice';
import type { SessionSlice } from './slices/sessionSlice';
import type { SubagentSlice } from './slices/subagentSlice';
import type { TeamSlice } from './slices/teamSlice';
import type { TabSlice } from './slices/tabSlice';
import type { TabUISlice } from './slices/tabUISlice';
import type { TeamSlice } from './slices/teamSlice';
import type { UISlice } from './slices/uiSlice';
import type { UpdateSlice } from './slices/updateSlice';

View file

@ -76,7 +76,7 @@ export interface Tab {
id: string;
/** Type of content displayed in this tab */
type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams';
type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams' | 'team';
/** Session ID (required when type === 'session') */
sessionId?: string;
@ -84,6 +84,9 @@ export interface Tab {
/** Project ID (required when type === 'session') */
projectId?: string;
/** Team name (required when type === 'team') */
teamName?: string;
/** Display name for the tab (max 50 chars) */
label: string;

View file

@ -0,0 +1,79 @@
type StructuredAgentMessage = Record<string, unknown>;
const NOISE_TYPES = new Set([
'idle_notification',
'shutdown_approved',
'teammate_terminated',
'shutdown_request',
]);
const TYPE_LABELS: Record<string, string> = {
idle_notification: 'Idle',
shutdown_approved: 'Shutdown confirmed',
teammate_terminated: 'Terminated',
shutdown_request: 'Shutdown requested',
shutdown_response: 'Shutdown response',
message: 'Message',
broadcast: 'Broadcast',
};
export function parseStructuredAgentMessage(content: string): StructuredAgentMessage | null {
const trimmed = content.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as StructuredAgentMessage;
}
} catch {
return null;
}
return null;
}
export function getMessageTypeLabel(type: string | null): string | null {
if (!type) {
return null;
}
return TYPE_LABELS[type] ?? type;
}
function getStringField(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
export function detectOperationalNoise(content: string, teammateId: string): string | null {
const trimmed = content.trim();
const parsed = parseStructuredAgentMessage(trimmed);
const type = getStringField(parsed?.type);
if (type && NOISE_TYPES.has(type)) {
const message = getStringField(parsed?.message);
return message ?? getMessageTypeLabel(type) ?? type;
}
if (teammateId === 'system') {
return trimmed.length < 200 ? trimmed : null;
}
return null;
}
export function getStructuredMessageSummary(parsed: StructuredAgentMessage): string {
const explicitSummary = getStringField(parsed.summary);
if (explicitSummary) {
return explicitSummary;
}
const message = getStringField(parsed.message);
if (message) {
return message;
}
const type = getStringField(parsed.type);
return getMessageTypeLabel(type) ?? 'Structured message';
}

View file

@ -0,0 +1,17 @@
/**
* Formats an agent type string into a human-readable role label.
*
* Returns `null` for the default "general-purpose" type so callers can
* conditionally hide the role entirely.
*/
export function formatAgentRole(agentType: string | undefined): string | null {
if (!agentType || agentType === 'general-purpose') {
return null;
}
// Capitalise first letter of every word, replace hyphens with spaces.
return agentType
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

View file

@ -13,7 +13,21 @@ import type {
NotificationTrigger,
TriggerTestResult,
} from './notifications';
import type { TeamSummary } from './team';
import type {
CreateTaskRequest,
SendMessageRequest,
SendMessageResult,
TeamChangeEvent,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
UpdateKanbanPatch,
} from './team';
import type { WaterfallData } from './visualization';
import type {
ConversationGroup,
@ -312,6 +326,24 @@ export interface HttpServerAPI {
export interface TeamsAPI {
list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>;
deleteTeam: (teamName: string) => Promise<void>;
prepareProvisioning: (cwd?: string) => Promise<TeamProvisioningPrepareResult>;
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
cancelProvisioning: (runId: string) => Promise<void>;
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>;
aliveList: () => Promise<string[]>;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void
) => () => void;
}
// =============================================================================

View file

@ -1,5 +1,10 @@
export interface TeamMember {
name: string;
agentId?: string;
agentType?: string;
role?: string;
color?: string;
joinedAt?: number;
}
export interface TeamConfig {
@ -9,9 +14,151 @@ export interface TeamConfig {
}
export interface TeamSummary {
name: string;
teamName: string;
displayName: string;
description: string;
memberCount: number;
taskCount: number;
lastActivity: string | null;
}
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
export interface TeamTask {
id: string;
subject: string;
description?: string;
activeForm?: string;
owner?: string;
status: TeamTaskStatus;
blocks?: string[];
blockedBy?: string[];
}
export interface InboxMessage {
from: string;
to?: string;
text: string;
timestamp: string;
read: boolean;
summary?: string;
color?: string;
messageId?: string;
}
export interface SendMessageRequest {
member: string;
text: string;
summary?: string;
from?: string;
}
export interface SendMessageResult {
deliveredToInbox: boolean;
messageId: string;
}
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
export type KanbanReviewStatus = 'pending' | 'error';
export interface KanbanTaskState {
column: Extract<KanbanColumnId, 'review' | 'approved'>;
reviewStatus?: KanbanReviewStatus;
reviewer?: string | null;
errorDescription?: string;
movedAt: string;
}
export interface KanbanState {
teamName: string;
reviewers: string[];
tasks: Record<string, KanbanTaskState>;
}
export type UpdateKanbanPatch =
| { op: 'set_column'; column: Extract<KanbanColumnId, 'review' | 'approved'> }
| { op: 'remove' }
| { op: 'request_changes'; comment?: string };
export interface ResolvedTeamMember {
name: string;
status: MemberStatus;
currentTaskId: string | null;
taskCount: number;
lastActiveAt: string | null;
messageCount: number;
color?: string;
agentType?: string;
}
export interface TeamData {
teamName: string;
config: TeamConfig;
tasks: TeamTask[];
members: ResolvedTeamMember[];
messages: InboxMessage[];
kanbanState: KanbanState;
warnings?: string[];
}
export interface CreateTaskRequest {
subject: string;
description?: string;
owner?: string;
blockedBy?: string[];
}
export interface TeamChangeEvent {
type: 'config' | 'inbox' | 'task';
teamName: string;
detail?: string;
}
export type TeamProvisioningState =
| 'idle'
| 'validating'
| 'spawning'
| 'monitoring'
| 'verifying'
| 'ready'
| 'disconnected'
| 'failed'
| 'cancelled';
export interface TeamProvisioningMemberInput {
name: string;
role?: string;
}
export interface TeamCreateRequest {
teamName: string;
displayName?: string;
description?: string;
members: TeamProvisioningMemberInput[];
cwd: string;
}
export interface TeamCreateResponse {
runId: string;
}
export interface TeamProvisioningPrepareResult {
ready: boolean;
message: string;
warnings?: string[];
}
export interface TeamProvisioningProgress {
runId: string;
teamName: string;
state: Exclude<TeamProvisioningState, 'idle'>;
message: string;
startedAt: string;
updatedAt: string;
pid?: number;
error?: string;
warnings?: string[];
cliLogsTail?: string;
}

View file

@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./src/renderer/index.html',
'./src/renderer/**/*.{js,ts,jsx,tsx}',
@ -48,6 +49,7 @@ module.exports = {
}
},
plugins: [
require('@tailwindcss/typography')
require('@tailwindcss/typography'),
require('tailwindcss-animate')
]
}

View file

@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest';
import {
coercePageLimit,
coerceSearchMaxResults,
validateFromField,
validateMemberName,
validateProjectId,
validateSearchQuery,
validateSessionId,
validateTaskId,
validateTeamName,
} from '../../../src/main/ipc/guards';
describe('ipc guards', () => {
@ -51,4 +55,18 @@ describe('ipc guards', () => {
expect(coercePageLimit(500, 20)).toBe(200);
expect(coercePageLimit(0, 20)).toBe(20);
});
it('validates team/task/member/from fields', () => {
expect(validateTeamName('team-1').valid).toBe(true);
expect(validateTaskId('123').valid).toBe(true);
expect(validateMemberName('alice_1').valid).toBe(true);
expect(validateFromField('team-lead').valid).toBe(true);
});
it('rejects traversal and invalid chars for team-related fields', () => {
expect(validateTeamName('../escape').valid).toBe(false);
expect(validateTaskId('12/34').valid).toBe(false);
expect(validateMemberName('alice bob').valid).toBe(false);
expect(validateFromField('../../etc').valid).toBe(false);
});
});

188
test/main/ipc/teams.test.ts Normal file
View file

@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_LIST: 'team:list',
TEAM_GET_DATA: 'team:getData',
TEAM_DELETE_TEAM: 'team:deleteTeam',
TEAM_PREPARE_PROVISIONING: 'team:prepareProvisioning',
TEAM_CREATE: 'team:create',
TEAM_CREATE_TASK: 'team:createTask',
TEAM_PROVISIONING_STATUS: 'team:provisioningStatus',
TEAM_CANCEL_PROVISIONING: 'team:cancelProvisioning',
TEAM_PROVISIONING_PROGRESS: 'team:provisioningProgress',
TEAM_SEND_MESSAGE: 'team:sendMessage',
TEAM_REQUEST_REVIEW: 'team:requestReview',
TEAM_UPDATE_KANBAN: 'team:updateKanban',
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
TEAM_PROCESS_SEND: 'team:processSend',
TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList',
}));
import {
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
TEAM_CREATE,
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_DATA,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
} from '../../../src/preload/constants/ipcChannels';
import {
initializeTeamHandlers,
registerTeamHandlers,
removeTeamHandlers,
} from '../../../src/main/ipc/teams';
describe('ipc teams handlers', () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
const ipcMain = {
handle: vi.fn((channel: string, fn: (...args: unknown[]) => Promise<unknown>) => {
handlers.set(channel, fn);
}),
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel);
}),
};
const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
getTeamData: vi.fn(async () => ({ teamName: 'my-team' })),
deleteTeam: vi.fn(async () => undefined),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
requestReview: vi.fn(async () => undefined),
updateKanban: vi.fn(async () => undefined),
updateTaskStatus: vi.fn(async () => undefined),
};
const provisioningService = {
prepareForProvisioning: vi.fn(async () => ({
ready: true,
message: 'CLI прогрет и готов к запуску',
})),
createTeam: vi.fn(async () => ({ runId: 'run-1' })),
getProvisioningStatus: vi.fn(async () => ({
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Starting',
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})),
cancelProvisioning: vi.fn(async () => undefined),
sendMessageToTeam: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true),
getAliveTeams: vi.fn(() => ['my-team']),
};
beforeEach(() => {
handlers.clear();
vi.clearAllMocks();
initializeTeamHandlers(service as never, provisioningService as never);
registerTeamHandlers(ipcMain as never);
});
it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(true);
expect(handlers.has(TEAM_CREATE)).toBe(true);
expect(handlers.has(TEAM_CREATE_TASK)).toBe(true);
expect(handlers.has(TEAM_PROVISIONING_STATUS)).toBe(true);
expect(handlers.has(TEAM_CANCEL_PROVISIONING)).toBe(true);
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
});
it('returns success false on invalid sendMessage args', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, '../bad', {
member: 'alice',
text: 'hi',
})) as { success: boolean };
expect(result.success).toBe(false);
});
it('calls service and returns success on happy paths', async () => {
const listResult = (await handlers.get(TEAM_LIST)!({} as never)) as {
success: boolean;
data: unknown[];
};
expect(listResult.success).toBe(true);
expect(service.listTeams).toHaveBeenCalledTimes(1);
const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, {
teamName: 'my-team',
members: [{ name: 'alice' }],
cwd: '/',
})) as { success: boolean };
expect(createResult.success).toBe(true);
expect(provisioningService.createTeam).toHaveBeenCalledTimes(1);
const statusResult = (await handlers.get(TEAM_PROVISIONING_STATUS)!({} as never, 'run-1')) as {
success: boolean;
};
expect(statusResult.success).toBe(true);
expect(provisioningService.getProvisioningStatus).toHaveBeenCalledWith('run-1');
const cancelResult = (await handlers.get(TEAM_CANCEL_PROVISIONING)!({} as never, 'run-1')) as {
success: boolean;
};
expect(cancelResult.success).toBe(true);
expect(provisioningService.cancelProvisioning).toHaveBeenCalledWith('run-1');
const reviewResult = (await handlers.get(TEAM_REQUEST_REVIEW)!(
{} as never,
'my-team',
'12'
)) as {
success: boolean;
};
expect(reviewResult.success).toBe(true);
expect(service.requestReview).toHaveBeenCalledWith('my-team', '12');
const kanbanResult = (await handlers.get(TEAM_UPDATE_KANBAN)!({} as never, 'my-team', '12', {
op: 'set_column',
column: 'approved',
})) as { success: boolean };
expect(kanbanResult.success).toBe(true);
expect(service.updateKanban).toHaveBeenCalledWith('my-team', '12', {
op: 'set_column',
column: 'approved',
});
});
it('removes handlers', () => {
removeTeamHandlers(ipcMain as never);
expect(handlers.has(TEAM_LIST)).toBe(false);
expect(handlers.has(TEAM_GET_DATA)).toBe(false);
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(false);
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(false);
expect(handlers.has(TEAM_CREATE)).toBe(false);
expect(handlers.has(TEAM_CREATE_TASK)).toBe(false);
expect(handlers.has(TEAM_PROVISIONING_STATUS)).toBe(false);
expect(handlers.has(TEAM_CANCEL_PROVISIONING)).toBe(false);
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
});
});

View file

@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import type { TeamTask } from '../../../../src/shared/types/team';
describe('TeamDataService', () => {
it('runs kanban garbage-collect only after tasks are loaded', async () => {
const order: string[] = [];
const tasks: TeamTask[] = [
{
id: '12',
subject: 'Task',
status: 'pending',
},
];
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{
getTasks: vi.fn(async () => {
order.push('tasks');
return tasks;
}),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => {
order.push('gc');
}),
} as never
);
await service.getTeamData('my-team');
expect(order).toEqual(['tasks', 'gc']);
});
it('skips kanban garbage-collect when tasks fail to load', async () => {
const garbageCollect = vi.fn(async () => undefined);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{
getTasks: vi.fn(async () => {
throw new Error('tasks failed');
}),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect,
} as never
);
const result = await service.getTeamData('my-team');
expect(garbageCollect).not.toHaveBeenCalled();
expect(result.warnings).toContain('Tasks failed to load');
});
});

View file

@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
const dirs = new Map<string, string[]>();
const readdir = vi.fn(async (dirPath: string) => {
const entries = dirs.get(dirPath);
if (!entries) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return entries;
});
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(filePath);
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return data;
});
return { files, dirs, readdir, readFile };
});
vi.mock('fs', () => ({
promises: {
readdir: hoisted.readdir,
readFile: hoisted.readFile,
},
}));
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/mock/teams',
}));
import { TeamInboxReader } from '../../../../src/main/services/team/TeamInboxReader';
describe('TeamInboxReader', () => {
const reader = new TeamInboxReader();
const inboxDir = '/mock/teams/my-team/inboxes';
beforeEach(() => {
hoisted.files.clear();
hoisted.dirs.clear();
hoisted.readdir.mockClear();
hoisted.readFile.mockClear();
});
it('listInboxNames filters only visible json files', async () => {
hoisted.dirs.set(inboxDir, ['alice.json', '.hidden.json', 'bob.json', 'note.txt']);
const names = await reader.listInboxNames('my-team');
expect(names).toEqual(['alice', 'bob']);
});
it('getMessagesFor returns empty for corrupted JSON', async () => {
hoisted.files.set('/mock/teams/my-team/inboxes/alice.json', '{bad');
const messages = await reader.getMessagesFor('my-team', 'alice');
expect(messages).toEqual([]);
});
it('getMessages merges and sorts by newest timestamp', async () => {
hoisted.dirs.set(inboxDir, ['alice.json', 'bob.json']);
hoisted.files.set(
'/mock/teams/my-team/inboxes/alice.json',
JSON.stringify([
{
from: 'alice',
text: 'older',
timestamp: '2026-01-01T00:00:00.000Z',
read: false,
},
])
);
hoisted.files.set(
'/mock/teams/my-team/inboxes/bob.json',
JSON.stringify([
{
from: 'bob',
text: 'newer',
timestamp: '2026-01-02T00:00:00.000Z',
read: false,
},
])
);
const merged = await reader.getMessages('my-team');
expect(merged).toHaveLength(2);
expect(merged[0].text).toBe('newer');
expect(merged[1].text).toBe('older');
});
});

View file

@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
let idCounter = 0;
let dropWrites = 0;
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(filePath);
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return data;
});
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
if (dropWrites > 0) {
dropWrites -= 1;
files.set(filePath, '[]');
return;
}
files.set(filePath, data);
});
return {
files,
readFile,
atomicWrite,
nextId: () => `msg-${++idCounter}`,
resetCounter: () => {
idCounter = 0;
},
setDropWrites: (count: number) => {
dropWrites = count;
},
};
});
vi.mock('crypto', async (importOriginal) => {
const actual = await importOriginal<typeof import('crypto')>();
return {
...actual,
randomUUID: hoisted.nextId,
};
});
vi.mock('fs', () => ({
promises: {
readFile: hoisted.readFile,
},
}));
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/mock/teams',
}));
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
atomicWriteAsync: hoisted.atomicWrite,
}));
import { TeamInboxWriter } from '../../../../src/main/services/team/TeamInboxWriter';
describe('TeamInboxWriter', () => {
const writer = new TeamInboxWriter();
const inboxPath = '/mock/teams/my-team/inboxes/alice.json';
beforeEach(() => {
hoisted.files.clear();
hoisted.readFile.mockClear();
hoisted.atomicWrite.mockClear();
hoisted.resetCounter();
hoisted.setDropWrites(0);
});
it('writes message with metadata and verifies messageId', async () => {
const result = await writer.sendMessage('my-team', {
member: 'alice',
text: 'hello',
summary: 'greeting',
});
const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record<string, unknown>[];
expect(result.deliveredToInbox).toBe(true);
expect(typeof result.messageId).toBe('string');
expect(persisted).toHaveLength(1);
expect(persisted[0]).toMatchObject({
from: 'user',
text: 'hello',
read: false,
summary: 'greeting',
messageId: result.messageId,
});
expect(typeof persisted[0].timestamp).toBe('string');
});
it('retries write when verify cannot find messageId', async () => {
hoisted.setDropWrites(2);
const result = await writer.sendMessage('my-team', {
member: 'alice',
text: 'retry me',
});
expect(typeof result.messageId).toBe('string');
expect(hoisted.atomicWrite).toHaveBeenCalledTimes(3);
});
it('throws after retries when verify keeps failing', async () => {
hoisted.setDropWrites(5);
await expect(
writer.sendMessage('my-team', {
member: 'alice',
text: 'will fail',
})
).rejects.toThrow('Failed to verify inbox write');
expect(hoisted.atomicWrite).toHaveBeenCalledTimes(3);
});
it('keeps both messages on parallel writes to same inbox', async () => {
await Promise.all([
writer.sendMessage('my-team', { member: 'alice', text: 'first' }),
writer.sendMessage('my-team', { member: 'alice', text: 'second' }),
]);
const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as { text: string }[];
expect(persisted).toHaveLength(2);
expect(persisted.map((row) => row.text).sort()).toEqual(['first', 'second']);
});
});

View file

@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(filePath);
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return data;
});
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
files.set(filePath, data);
});
return { files, readFile, atomicWrite };
});
vi.mock('fs', () => ({
promises: {
readFile: hoisted.readFile,
},
}));
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/mock/teams',
}));
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
atomicWriteAsync: hoisted.atomicWrite,
}));
import { TeamKanbanManager } from '../../../../src/main/services/team/TeamKanbanManager';
describe('TeamKanbanManager', () => {
const manager = new TeamKanbanManager();
const statePath = '/mock/teams/my-team/kanban-state.json';
beforeEach(() => {
hoisted.files.clear();
hoisted.readFile.mockClear();
hoisted.atomicWrite.mockClear();
});
it('returns default state on ENOENT', async () => {
const result = await manager.getState('my-team');
expect(result).toEqual({
teamName: 'my-team',
reviewers: [],
tasks: {},
});
});
it('returns default state on corrupted JSON', async () => {
hoisted.files.set(statePath, '{bad-json');
const result = await manager.getState('my-team');
expect(result).toEqual({
teamName: 'my-team',
reviewers: [],
tasks: {},
});
});
it('writes review state with movedAt on set_column', async () => {
await manager.updateTask('my-team', '12', { op: 'set_column', column: 'review' });
const persisted = JSON.parse(hoisted.files.get(statePath) ?? '{}') as {
tasks?: Record<string, { column: string; movedAt: string; reviewStatus?: string }>;
};
expect(persisted.tasks?.['12']?.column).toBe('review');
expect(persisted.tasks?.['12']?.reviewStatus).toBe('pending');
expect(typeof persisted.tasks?.['12']?.movedAt).toBe('string');
expect(hoisted.atomicWrite).toHaveBeenCalledTimes(1);
});
it('removes task state on remove', async () => {
hoisted.files.set(
statePath,
JSON.stringify({
teamName: 'my-team',
reviewers: [],
tasks: {
'12': { column: 'review', movedAt: '2026-01-01T00:00:00.000Z' },
},
})
);
await manager.updateTask('my-team', '12', { op: 'remove' });
const persisted = JSON.parse(hoisted.files.get(statePath) ?? '{}') as {
tasks?: Record<string, unknown>;
};
expect(persisted.tasks).toEqual({});
});
it('garbageCollect removes only stale tasks', async () => {
hoisted.files.set(
statePath,
JSON.stringify({
teamName: 'my-team',
reviewers: [],
tasks: {
'12': { column: 'review', movedAt: '2026-01-01T00:00:00.000Z' },
'13': { column: 'approved', movedAt: '2026-01-01T00:00:00.000Z' },
},
})
);
await manager.garbageCollect('my-team', new Set(['12']));
const persisted = JSON.parse(hoisted.files.get(statePath) ?? '{}') as {
tasks?: Record<string, unknown>;
};
expect(Object.keys(persisted.tasks ?? {})).toEqual(['12']);
});
it('garbageCollect does not write when nothing to remove', async () => {
hoisted.files.set(
statePath,
JSON.stringify({
teamName: 'my-team',
reviewers: [],
tasks: {
'12': { column: 'review', movedAt: '2026-01-01T00:00:00.000Z' },
},
})
);
await manager.garbageCollect('my-team', new Set(['12']));
expect(hoisted.atomicWrite).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
let overrideVerifyRead: string | null = null;
let readCount = 0;
const readFile = vi.fn(async (filePath: string) => {
readCount += 1;
if (overrideVerifyRead && readCount >= 2) {
return overrideVerifyRead;
}
const data = files.get(filePath);
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return data;
});
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
files.set(filePath, data);
});
return {
files,
readFile,
atomicWrite,
setVerifyOverride: (value: string | null) => {
overrideVerifyRead = value;
},
resetReadCount: () => {
readCount = 0;
},
};
});
vi.mock('fs', () => ({
promises: {
readFile: hoisted.readFile,
mkdir: vi.fn(async () => undefined),
access: vi.fn(async () => {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}),
},
constants: { F_OK: 0 },
}));
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTasksBasePath: () => '/mock/tasks',
}));
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
atomicWriteAsync: hoisted.atomicWrite,
}));
import { TeamTaskWriter } from '../../../../src/main/services/team/TeamTaskWriter';
describe('TeamTaskWriter', () => {
const writer = new TeamTaskWriter();
const taskPath = '/mock/tasks/my-team/12.json';
beforeEach(() => {
hoisted.files.clear();
hoisted.readFile.mockClear();
hoisted.atomicWrite.mockClear();
hoisted.setVerifyOverride(null);
hoisted.resetReadCount();
});
it('createTask writes CLI-compatible format with description, blocks, blockedBy', async () => {
await writer.createTask('my-team', {
id: '5',
subject: 'Test task',
owner: 'bob',
status: 'pending',
});
const writtenPath = '/mock/tasks/my-team/5.json';
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}') as Record<string, unknown>;
expect(persisted.id).toBe('5');
expect(persisted.subject).toBe('Test task');
expect(persisted.owner).toBe('bob');
expect(persisted.status).toBe('pending');
// CLI requires these fields for Zod schema validation
expect(persisted.description).toBe('');
expect(persisted.blocks).toEqual([]);
expect(persisted.blockedBy).toEqual([]);
});
it('createTask preserves provided description, blocks, blockedBy', async () => {
await writer.createTask('my-team', {
id: '6',
subject: 'Task with details',
description: 'Some description',
status: 'pending',
blocks: ['7'],
blockedBy: ['3'],
});
const writtenPath = '/mock/tasks/my-team/6.json';
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}') as Record<string, unknown>;
expect(persisted.description).toBe('Some description');
expect(persisted.blocks).toEqual(['7']);
expect(persisted.blockedBy).toEqual(['3']);
});
it('updates status and preserves other fields', async () => {
hoisted.files.set(
taskPath,
JSON.stringify({
id: '12',
subject: 'task',
owner: 'alice',
status: 'pending',
})
);
await writer.updateStatus('my-team', '12', 'in_progress');
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}') as Record<string, string>;
expect(persisted).toMatchObject({
id: '12',
subject: 'task',
owner: 'alice',
status: 'in_progress',
});
});
it('throws when verify detects conflicting status', async () => {
hoisted.files.set(
taskPath,
JSON.stringify({
id: '12',
subject: 'task',
status: 'pending',
})
);
hoisted.setVerifyOverride(
JSON.stringify({
id: '12',
subject: 'task',
status: 'pending',
})
);
await expect(writer.updateStatus('my-team', '12', 'in_progress')).rejects.toThrow(
'Task status update verification failed: 12'
);
});
});

View file

@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
onTeamChangeCb: null as ((event: unknown, data: { teamName: string }) => void) | null,
onProvisioningProgressCb: null as
| ((event: unknown, data: { runId: string; teamName: string }) => void)
| null,
}));
vi.mock('@renderer/api', () => ({
api: {
notifications: {
onNew: vi.fn(() => () => undefined),
onUpdated: vi.fn(() => () => undefined),
onClicked: vi.fn(() => () => undefined),
get: vi.fn(async () => ({
notifications: [],
total: 0,
totalCount: 0,
unreadCount: 0,
hasMore: false,
})),
},
teams: {
onTeamChange: vi.fn(
(cb: (event: unknown, data: { teamName: string }) => void): (() => void) => {
hoisted.onTeamChangeCb = cb;
return () => {
hoisted.onTeamChangeCb = null;
};
}
),
onProvisioningProgress: vi.fn(
(cb: (event: unknown, data: { runId: string; teamName: string }) => void): (() => void) => {
hoisted.onProvisioningProgressCb = cb;
return () => {
hoisted.onProvisioningProgressCb = null;
};
}
),
},
},
}));
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
describe('team change throttling', () => {
let cleanup: (() => void) | null = null;
beforeEach(() => {
vi.useFakeTimers();
const fetchTeams = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined);
useStore.setState({
fetchTeams,
refreshTeamData,
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
activeTabId: 't1',
},
],
},
} as never);
cleanup = initializeNotificationListeners();
});
afterEach(() => {
cleanup?.();
cleanup = null;
vi.useRealTimers();
});
it('throttles both team list and detail refresh', async () => {
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
// Fire 3 rapid events
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
// Both are throttled — nothing called synchronously
expect(fetchTeamsSpy).not.toHaveBeenCalled();
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
// Detail refresh fires at 800ms
await vi.advanceTimersByTimeAsync(799);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
// List refresh fires at 2000ms
expect(fetchTeamsSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1200);
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
});
it('allows next refresh after throttle window passes', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
// Second event after throttle window
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from 'zustand';
import { createTeamSlice } from '../../../src/renderer/store/slices/teamSlice';
const hoisted = vi.hoisted(() => ({
list: vi.fn(),
getData: vi.fn(),
createTeam: vi.fn(),
getProvisioningStatus: vi.fn(),
cancelProvisioning: vi.fn(),
sendMessage: vi.fn(),
requestReview: vi.fn(),
updateKanban: vi.fn(),
onProvisioningProgress: vi.fn(() => () => undefined),
}));
vi.mock('@renderer/api', () => ({
api: {
teams: {
list: hoisted.list,
getData: hoisted.getData,
createTeam: hoisted.createTeam,
getProvisioningStatus: hoisted.getProvisioningStatus,
cancelProvisioning: hoisted.cancelProvisioning,
sendMessage: hoisted.sendMessage,
requestReview: hoisted.requestReview,
updateKanban: hoisted.updateKanban,
onProvisioningProgress: hoisted.onProvisioningProgress,
},
},
}));
vi.mock('../../../src/renderer/utils/unwrapIpc', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/renderer/utils/unwrapIpc')>();
return {
...actual,
unwrapIpc: async <T>(_operation: string, fn: () => Promise<T>): Promise<T> => {
try {
return await fn();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new actual.IpcError('mock-op', message, error);
}
},
};
});
function createSliceStore() {
return create<any>()((set, get) => ({
...createTeamSlice(set as never, get as never),
paneLayout: {
focusedPaneId: 'pane-default',
panes: [
{
id: 'pane-default',
widthFraction: 1,
tabs: [],
activeTabId: null,
},
],
},
openTab: vi.fn(),
setActiveTab: vi.fn(),
getAllPaneTabs: vi.fn(() => []),
}));
}
describe('teamSlice actions', () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.list.mockResolvedValue([]);
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
});
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
hoisted.requestReview.mockResolvedValue(undefined);
hoisted.updateKanban.mockResolvedValue(undefined);
hoisted.createTeam.mockResolvedValue({ runId: 'run-1' });
hoisted.getProvisioningStatus.mockResolvedValue({
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Starting',
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
hoisted.cancelProvisioning.mockResolvedValue(undefined);
});
it('maps inbox verify failure to user-friendly text', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write'));
await store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'hello' });
expect(store.getState().sendMessageError).toBe(
'Message was written but not verified (race). Please try again.'
);
});
it('maps task status verify failure in updateKanban and rethrows', async () => {
const store = createSliceStore();
hoisted.updateKanban.mockRejectedValue(new Error('Task status update verification failed: 12'));
await expect(
store.getState().updateKanban('my-team', '12', { op: 'request_changes' })
).rejects.toThrow('Task status update verification failed: 12');
expect(store.getState().reviewActionError).toBe(
'Failed to update task status (possible agent conflict).'
);
});
it('maps task status verify failure in requestReview and rethrows', async () => {
const store = createSliceStore();
hoisted.requestReview.mockRejectedValue(
new Error('Task status update verification failed: 22')
);
await expect(store.getState().requestReview('my-team', '22')).rejects.toThrow(
'Task status update verification failed: 22'
);
expect(store.getState().reviewActionError).toBe(
'Failed to update task status (possible agent conflict).'
);
});
});

View file

@ -19,6 +19,7 @@ export default defineConfig({
'@shared': resolve(__dirname, 'src/shared'),
'@main': resolve(__dirname, 'src/main'),
'@renderer': resolve(__dirname, 'src/renderer'),
'@preload': resolve(__dirname, 'src/preload'),
},
},
});