feat(team-managment): stable MVP
This commit is contained in:
parent
70915a152a
commit
753ab14917
84 changed files with 12122 additions and 123 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -46,4 +46,6 @@ temp/
|
|||
|
||||
|
||||
eslint-fix/
|
||||
remotion/*
|
||||
remotion/*
|
||||
|
||||
.home/
|
||||
|
|
@ -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)
|
||||
|
||||
## Принципы
|
||||
|
||||
|
|
|
|||
334
docs/iterations/iteration-02-team-detail-members.md
Normal file
334
docs/iterations/iteration-02-team-detail-members.md
Normal 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 обновился в течение ~300ms–1s
|
||||
|
||||
---
|
||||
|
||||
## Риски и митигации
|
||||
|
||||
- **Имя команды ≠ имя директории**: решено контрактом `TeamSummary.teamName` (dir) + `TeamSummary.displayName` (человекочитаемо).
|
||||
- **fs.watch пропуски событий**: есть existing catch-up scan для sessions; для teams/tasks в этой итерации полагаемся на debounce+coalesce, полноценный catch-up можно добавить позже при необходимости.
|
||||
- **Шумные события**: coalesce 300ms в renderer, чтобы не спамить refresh.
|
||||
|
||||
266
docs/iterations/iteration-03-kanban-board.md
Normal file
266
docs/iterations/iteration-03-kanban-board.md
Normal 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()`):
|
||||
- строка, только цифры, разумный лимит длины (например 1–10)
|
||||
|
||||
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` — быть безопасным при частых вызовах.
|
||||
|
||||
296
docs/iterations/iteration-04-messaging-review.md
Normal file
296
docs/iterations/iteration-04-messaging-review.md
Normal 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 (минимум 1–2 строки)
|
||||
- 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).
|
||||
|
||||
207
docs/iterations/iteration-05-testing-polish.md
Normal file
207
docs/iterations/iteration-05-testing-polish.md
Normal 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с
|
||||
|
||||
361
docs/iterations/iteration-06-team-provisioning.md
Normal file
361
docs/iterations/iteration-06-team-provisioning.md
Normal 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 (итерации 01–04): `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`.
|
||||
|
||||
493
docs/team-management/research-cli-orchestration.md
Normal file
493
docs/team-management/research-cli-orchestration.md
Normal 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/)
|
||||
14
package.json
14
package.json
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
874
pnpm-lock.yaml
874
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
src/main/services/team/ClaudeBinaryResolver.ts
Normal file
75
src/main/services/team/ClaudeBinaryResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
614
src/main/services/team/TeamAgentToolsInstaller.ts
Normal file
614
src/main/services/team/TeamAgentToolsInstaller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
src/main/services/team/TeamInboxReader.ts
Normal file
115
src/main/services/team/TeamInboxReader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
93
src/main/services/team/TeamInboxWriter.ts
Normal file
93
src/main/services/team/TeamInboxWriter.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
136
src/main/services/team/TeamKanbanManager.ts
Normal file
136
src/main/services/team/TeamKanbanManager.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
115
src/main/services/team/TeamMemberResolver.ts
Normal file
115
src/main/services/team/TeamMemberResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1479
src/main/services/team/TeamProvisioningService.ts
Normal file
1479
src/main/services/team/TeamProvisioningService.ts
Normal file
File diff suppressed because it is too large
Load diff
105
src/main/services/team/TeamTaskReader.ts
Normal file
105
src/main/services/team/TeamTaskReader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/main/services/team/TeamTaskWriter.ts
Normal file
90
src/main/services/team/TeamTaskWriter.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/main/services/team/atomicWrite.ts
Normal file
39
src/main/services/team/atomicWrite.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const TAB_ICONS = {
|
|||
settings: Settings,
|
||||
session: FileText,
|
||||
teams: Users,
|
||||
team: Users,
|
||||
} as const;
|
||||
|
||||
export const SortableTab = ({
|
||||
|
|
|
|||
47
src/renderer/components/team/CollapsibleTeamSection.tsx
Normal file
47
src/renderer/components/team/CollapsibleTeamSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
310
src/renderer/components/team/TeamDetailView.tsx
Normal file
310
src/renderer/components/team/TeamDetailView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
172
src/renderer/components/team/TeamProvisioningBanner.tsx
Normal file
172
src/renderer/components/team/TeamProvisioningBanner.tsx
Normal 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)]">→</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
263
src/renderer/components/team/activity/ActivityItem.tsx
Normal file
263
src/renderer/components/team/activity/ActivityItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
src/renderer/components/team/activity/ActivityTimeline.tsx
Normal file
34
src/renderer/components/team/activity/ActivityTimeline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
137
src/renderer/components/team/activity/MessageComposer.tsx
Normal file
137
src/renderer/components/team/activity/MessageComposer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
197
src/renderer/components/team/dialogs/CreateTaskDialog.tsx
Normal file
197
src/renderer/components/team/dialogs/CreateTaskDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
921
src/renderer/components/team/dialogs/CreateTeamDialog.tsx
Normal file
921
src/renderer/components/team/dialogs/CreateTeamDialog.tsx
Normal 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)]">→</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)]">·</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>
|
||||
);
|
||||
};
|
||||
78
src/renderer/components/team/dialogs/ReviewDialog.tsx
Normal file
78
src/renderer/components/team/dialogs/ReviewDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
99
src/renderer/components/team/kanban/KanbanBoard.tsx
Normal file
99
src/renderer/components/team/kanban/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/renderer/components/team/kanban/KanbanColumn.tsx
Normal file
23
src/renderer/components/team/kanban/KanbanColumn.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
191
src/renderer/components/team/kanban/KanbanTaskCard.tsx
Normal file
191
src/renderer/components/team/kanban/KanbanTaskCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
src/renderer/components/team/kanban/ReviewBadge.tsx
Normal file
17
src/renderer/components/team/kanban/ReviewBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
68
src/renderer/components/team/members/MemberCard.tsx
Normal file
68
src/renderer/components/team/members/MemberCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
src/renderer/components/team/members/MemberList.tsx
Normal file
25
src/renderer/components/team/members/MemberList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
src/renderer/components/team/provisioningSteps.ts
Normal file
9
src/renderer/components/team/provisioningSteps.ts
Normal 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',
|
||||
};
|
||||
103
src/renderer/components/team/tasks/TaskList.tsx
Normal file
103
src/renderer/components/team/tasks/TaskList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
src/renderer/components/team/tasks/TaskRow.tsx
Normal file
33
src/renderer/components/team/tasks/TaskRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
src/renderer/components/ui/badge.tsx
Normal file
34
src/renderer/components/ui/badge.tsx
Normal 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 */
|
||||
54
src/renderer/components/ui/button.tsx
Normal file
54
src/renderer/components/ui/button.tsx
Normal 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 */
|
||||
138
src/renderer/components/ui/combobox.tsx
Normal file
138
src/renderer/components/ui/combobox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
src/renderer/components/ui/dialog.tsx
Normal file
108
src/renderer/components/ui/dialog.tsx
Normal 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 */
|
||||
22
src/renderer/components/ui/input.tsx
Normal file
22
src/renderer/components/ui/input.tsx
Normal 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 };
|
||||
21
src/renderer/components/ui/label.tsx
Normal file
21
src/renderer/components/ui/label.tsx
Normal 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 */
|
||||
31
src/renderer/components/ui/popover.tsx
Normal file
31
src/renderer/components/ui/popover.tsx
Normal 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 */
|
||||
150
src/renderer/components/ui/select.tsx
Normal file
150
src/renderer/components/ui/select.tsx
Normal 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 */
|
||||
21
src/renderer/components/ui/textarea.tsx
Normal file
21
src/renderer/components/ui/textarea.tsx
Normal 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 };
|
||||
6
src/renderer/lib/utils.ts
Normal file
6
src/renderer/lib/utils.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
79
src/renderer/utils/agentMessageFormatting.ts
Normal file
79
src/renderer/utils/agentMessageFormatting.ts
Normal 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';
|
||||
}
|
||||
17
src/renderer/utils/formatAgentRole.ts
Normal file
17
src/renderer/utils/formatAgentRole.ts
Normal 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(' ');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
188
test/main/ipc/teams.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
test/main/services/team/TeamDataService.test.ts
Normal file
81
test/main/services/team/TeamDataService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
97
test/main/services/team/TeamInboxReader.test.ts
Normal file
97
test/main/services/team/TeamInboxReader.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
130
test/main/services/team/TeamInboxWriter.test.ts
Normal file
130
test/main/services/team/TeamInboxWriter.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
131
test/main/services/team/TeamKanbanManager.test.ts
Normal file
131
test/main/services/team/TeamKanbanManager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
155
test/main/services/team/TeamTaskWriter.test.ts
Normal file
155
test/main/services/team/TeamTaskWriter.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
121
test/renderer/store/teamChangeThrottle.test.ts
Normal file
121
test/renderer/store/teamChangeThrottle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
test/renderer/store/teamSlice.test.ts
Normal file
133
test/renderer/store/teamSlice.test.ts
Normal 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).'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue