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/
|
eslint-fix/
|
||||||
remotion/*
|
remotion/*
|
||||||
|
|
||||||
|
.home/
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
## Список
|
## Список
|
||||||
|
|
||||||
- [Итерация 01 — Core Foundation + Team List](./iteration-01-core-team-list.md)
|
- [Итерация 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",
|
"main": "dist-electron/main/index.cjs",
|
||||||
"scripts": {
|
"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",
|
"build": "electron-vite build",
|
||||||
"dist": "electron-builder --mac --win --linux",
|
"dist": "electron-builder --mac --win --linux",
|
||||||
"dist:mac": "electron-builder --mac --publish always",
|
"dist:mac": "electron-builder --mac --publish always",
|
||||||
|
|
@ -53,7 +54,16 @@
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/static": "^9.0.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",
|
"@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",
|
"date-fns": "^3.6.0",
|
||||||
"electron-updater": "^6.7.3",
|
"electron-updater": "^6.7.3",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
|
|
@ -67,6 +77,8 @@
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"ssh-config": "^5.0.4",
|
"ssh-config": "^5.0.4",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"zustand": "^4.5.0"
|
"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_START = 'httpServer:start';
|
||||||
const HTTP_SERVER_STOP = 'httpServer:stop';
|
const HTTP_SERVER_STOP = 'httpServer:stop';
|
||||||
const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
|
const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
|
||||||
|
const TEAM_CHANGE = 'team:change';
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
logger.error('Unhandled promise rejection in main process:', reason);
|
logger.error('Unhandled promise rejection in main process:', reason);
|
||||||
|
|
@ -67,6 +68,7 @@ import {
|
||||||
ServiceContextRegistry,
|
ServiceContextRegistry,
|
||||||
SshConnectionManager,
|
SshConnectionManager,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
|
TeamProvisioningService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
|
|
@ -82,11 +84,13 @@ let notificationManager: NotificationManager;
|
||||||
let updaterService: UpdaterService;
|
let updaterService: UpdaterService;
|
||||||
let sshConnectionManager: SshConnectionManager;
|
let sshConnectionManager: SshConnectionManager;
|
||||||
let teamDataService: TeamDataService;
|
let teamDataService: TeamDataService;
|
||||||
|
let teamProvisioningService: TeamProvisioningService;
|
||||||
let httpServer: HttpServer;
|
let httpServer: HttpServer;
|
||||||
|
|
||||||
// File watcher event cleanup functions
|
// File watcher event cleanup functions
|
||||||
let fileChangeCleanup: (() => void) | null = null;
|
let fileChangeCleanup: (() => void) | null = null;
|
||||||
let todoChangeCleanup: (() => void) | null = null;
|
let todoChangeCleanup: (() => void) | null = null;
|
||||||
|
let teamChangeCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve production renderer index path.
|
* Resolve production renderer index path.
|
||||||
|
|
@ -116,6 +120,10 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
todoChangeCleanup();
|
todoChangeCleanup();
|
||||||
todoChangeCleanup = null;
|
todoChangeCleanup = null;
|
||||||
}
|
}
|
||||||
|
if (teamChangeCleanup) {
|
||||||
|
teamChangeCleanup();
|
||||||
|
teamChangeCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Wire file-change events to renderer and HTTP SSE
|
// Wire file-change events to renderer and HTTP SSE
|
||||||
const fileChangeHandler = (event: unknown): void => {
|
const fileChangeHandler = (event: unknown): void => {
|
||||||
|
|
@ -137,6 +145,16 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
context.fileWatcher.on('todo-change', todoChangeHandler);
|
context.fileWatcher.on('todo-change', todoChangeHandler);
|
||||||
todoChangeCleanup = () => context.fileWatcher.off('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}`);
|
logger.info(`FileWatcher events wired for context: ${context.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,16 +285,24 @@ function initializeServices(): void {
|
||||||
// Initialize updater service
|
// Initialize updater service
|
||||||
updaterService = new UpdaterService();
|
updaterService = new UpdaterService();
|
||||||
teamDataService = new TeamDataService();
|
teamDataService = new TeamDataService();
|
||||||
|
teamProvisioningService = new TeamProvisioningService();
|
||||||
httpServer = new HttpServer();
|
httpServer = new HttpServer();
|
||||||
|
|
||||||
// Initialize IPC handlers with registry
|
// Initialize IPC handlers with registry
|
||||||
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, teamDataService, {
|
initializeIpcHandlers(
|
||||||
rewire: rewireContextEvents,
|
contextRegistry,
|
||||||
full: onContextSwitched,
|
updaterService,
|
||||||
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
sshConnectionManager,
|
||||||
reconfigureLocalContextForClaudeRoot();
|
teamDataService,
|
||||||
},
|
teamProvisioningService,
|
||||||
});
|
{
|
||||||
|
rewire: rewireContextEvents,
|
||||||
|
full: onContextSwitched,
|
||||||
|
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
||||||
|
reconfigureLocalContextForClaudeRoot();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// HTTP Server control IPC handlers
|
// HTTP Server control IPC handlers
|
||||||
ipcMain.handle(HTTP_SERVER_START, async () => {
|
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 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 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 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_QUERY_LENGTH = 512;
|
||||||
const MAX_RESULTS = 200;
|
const MAX_RESULTS = 200;
|
||||||
|
|
@ -109,6 +113,58 @@ export function validateTriggerId(triggerId: unknown): ValidationResult<string>
|
||||||
return { valid: true, value: basic.value };
|
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> {
|
export function validateSearchQuery(query: unknown): ValidationResult<string> {
|
||||||
if (typeof query !== 'string') {
|
if (typeof query !== 'string') {
|
||||||
return { valid: false, error: 'query must be a string' };
|
return { valid: false, error: 'query must be a string' };
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import type {
|
||||||
ServiceContextRegistry,
|
ServiceContextRegistry,
|
||||||
SshConnectionManager,
|
SshConnectionManager,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
|
TeamProvisioningService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
|
||||||
|
|
@ -68,6 +69,7 @@ export function initializeIpcHandlers(
|
||||||
updater: UpdaterService,
|
updater: UpdaterService,
|
||||||
sshManager: SshConnectionManager,
|
sshManager: SshConnectionManager,
|
||||||
teamDataService: TeamDataService,
|
teamDataService: TeamDataService,
|
||||||
|
teamProvisioningService: TeamProvisioningService,
|
||||||
contextCallbacks: {
|
contextCallbacks: {
|
||||||
rewire: (context: ServiceContext) => void;
|
rewire: (context: ServiceContext) => void;
|
||||||
full: (context: ServiceContext) => void;
|
full: (context: ServiceContext) => void;
|
||||||
|
|
@ -82,7 +84,7 @@ export function initializeIpcHandlers(
|
||||||
initializeUpdaterHandlers(updater);
|
initializeUpdaterHandlers(updater);
|
||||||
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
|
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
|
||||||
initializeContextHandlers(registry, contextCallbacks.rewire);
|
initializeContextHandlers(registry, contextCallbacks.rewire);
|
||||||
initializeTeamHandlers(teamDataService);
|
initializeTeamHandlers(teamDataService, teamProvisioningService);
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
import type { TeamDataService } from '../services';
|
import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards';
|
||||||
import type { IpcResult, TeamSummary } from '@shared/types';
|
|
||||||
|
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');
|
const logger = createLogger('IPC:teams');
|
||||||
|
|
||||||
let teamDataService: TeamDataService | null = null;
|
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;
|
teamDataService = service;
|
||||||
|
teamProvisioningService = provisioningService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_LIST, handleListTeams);
|
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');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_LIST);
|
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 {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -29,6 +98,13 @@ function getTeamDataService(): TeamDataService {
|
||||||
return teamDataService;
|
return teamDataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTeamProvisioningService(): TeamProvisioningService {
|
||||||
|
if (!teamProvisioningService) {
|
||||||
|
throw new Error('Team provisioning handlers are not initialized');
|
||||||
|
}
|
||||||
|
return teamProvisioningService;
|
||||||
|
}
|
||||||
|
|
||||||
async function wrapTeamHandler<T>(
|
async function wrapTeamHandler<T>(
|
||||||
operation: string,
|
operation: string,
|
||||||
handler: () => Promise<T>
|
handler: () => Promise<T>
|
||||||
|
|
@ -46,3 +122,401 @@ async function wrapTeamHandler<T>(
|
||||||
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
||||||
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
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 { type FileChangeEvent, type ParsedMessage } from '@main/types';
|
||||||
import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
@ -27,6 +32,7 @@ import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
||||||
import { type NotificationManager } from './NotificationManager';
|
import { type NotificationManager } from './NotificationManager';
|
||||||
|
|
||||||
import type { FileSystemProvider, FsDirent } from './FileSystemProvider';
|
import type { FileSystemProvider, FsDirent } from './FileSystemProvider';
|
||||||
|
import type { TeamChangeEvent } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:FileWatcher');
|
const logger = createLogger('Service:FileWatcher');
|
||||||
|
|
||||||
|
|
@ -54,9 +60,13 @@ interface ActiveSessionFile {
|
||||||
export class FileWatcher extends EventEmitter {
|
export class FileWatcher extends EventEmitter {
|
||||||
private projectsWatcher: fs.FSWatcher | null = null;
|
private projectsWatcher: fs.FSWatcher | null = null;
|
||||||
private todosWatcher: 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 retryTimer: NodeJS.Timeout | null = null;
|
||||||
private projectsPath: string;
|
private projectsPath: string;
|
||||||
private todosPath: string;
|
private todosPath: string;
|
||||||
|
private teamsPath: string;
|
||||||
|
private tasksPath: string;
|
||||||
private dataCache: DataCache;
|
private dataCache: DataCache;
|
||||||
private fsProvider: FileSystemProvider;
|
private fsProvider: FileSystemProvider;
|
||||||
private notificationManager: NotificationManager | null = null;
|
private notificationManager: NotificationManager | null = null;
|
||||||
|
|
@ -96,6 +106,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
super();
|
super();
|
||||||
this.projectsPath = projectsPath ?? getProjectsBasePath();
|
this.projectsPath = projectsPath ?? getProjectsBasePath();
|
||||||
this.todosPath = todosPath ?? getTodosBasePath();
|
this.todosPath = todosPath ?? getTodosBasePath();
|
||||||
|
this.teamsPath = getTeamsBasePath();
|
||||||
|
this.tasksPath = getTasksBasePath();
|
||||||
this.dataCache = dataCache;
|
this.dataCache = dataCache;
|
||||||
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
|
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +175,16 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.todosWatcher = null;
|
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
|
// Clear any pending debounce timers
|
||||||
for (const timer of this.debounceTimers.values()) {
|
for (const timer of this.debounceTimers.values()) {
|
||||||
clearTimeout(timer);
|
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 {
|
private ensureWatchers(): void {
|
||||||
if (!this.isWatching || this.fsProvider.type === 'ssh') {
|
if (!this.isWatching || this.fsProvider.type === 'ssh') {
|
||||||
return;
|
return;
|
||||||
|
|
@ -324,8 +402,10 @@ export class FileWatcher extends EventEmitter {
|
||||||
|
|
||||||
this.startProjectsWatcher();
|
this.startProjectsWatcher();
|
||||||
this.startTodosWatcher();
|
this.startTodosWatcher();
|
||||||
|
this.startTeamsWatcher();
|
||||||
|
this.startTasksWatcher();
|
||||||
|
|
||||||
if (!this.projectsWatcher || !this.todosWatcher) {
|
if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) {
|
||||||
this.scheduleWatcherRetry();
|
this.scheduleWatcherRetry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,13 +421,20 @@ export class FileWatcher extends EventEmitter {
|
||||||
}, WATCHER_RETRY_MS);
|
}, 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) => {
|
watcher.on('error', (error) => {
|
||||||
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
|
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
|
||||||
if (watcherType === 'projects') {
|
if (watcherType === 'projects') {
|
||||||
this.projectsWatcher = null;
|
this.projectsWatcher = null;
|
||||||
} else {
|
} else if (watcherType === 'todos') {
|
||||||
this.todosWatcher = null;
|
this.todosWatcher = null;
|
||||||
|
} else if (watcherType === 'teams') {
|
||||||
|
this.teamsWatcher = null;
|
||||||
|
} else {
|
||||||
|
this.tasksWatcher = null;
|
||||||
}
|
}
|
||||||
this.scheduleWatcherRetry();
|
this.scheduleWatcherRetry();
|
||||||
});
|
});
|
||||||
|
|
@ -358,8 +445,12 @@ export class FileWatcher extends EventEmitter {
|
||||||
}
|
}
|
||||||
if (watcherType === 'projects') {
|
if (watcherType === 'projects') {
|
||||||
this.projectsWatcher = null;
|
this.projectsWatcher = null;
|
||||||
} else {
|
} else if (watcherType === 'todos') {
|
||||||
this.todosWatcher = null;
|
this.todosWatcher = null;
|
||||||
|
} else if (watcherType === 'teams') {
|
||||||
|
this.teamsWatcher = null;
|
||||||
|
} else {
|
||||||
|
this.tasksWatcher = null;
|
||||||
}
|
}
|
||||||
this.scheduleWatcherRetry();
|
this.scheduleWatcherRetry();
|
||||||
});
|
});
|
||||||
|
|
@ -808,6 +899,87 @@ export class FileWatcher extends EventEmitter {
|
||||||
logger.info(`FileWatcher: ${changeType} todo - ${filename}`);
|
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
|
// Catch-Up Scan
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -909,10 +1081,12 @@ export class FileWatcher extends EventEmitter {
|
||||||
/**
|
/**
|
||||||
* Returns watched paths.
|
* Returns watched paths.
|
||||||
*/
|
*/
|
||||||
getWatchedPaths(): { projects: string; todos: string } {
|
getWatchedPaths(): { projects: string; todos: string; teams: string; tasks: string } {
|
||||||
return {
|
return {
|
||||||
projects: this.projectsPath,
|
projects: this.projectsPath,
|
||||||
todos: this.todosPath,
|
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;
|
const memberCount = Array.isArray(config.members) ? config.members.length : 0;
|
||||||
summaries.push({
|
summaries.push({
|
||||||
name: config.name,
|
teamName: entry.name,
|
||||||
|
displayName: config.name,
|
||||||
description: typeof config.description === 'string' ? config.description : '',
|
description: typeof config.description === 'string' ? config.description : '',
|
||||||
memberCount,
|
memberCount,
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
|
|
@ -49,4 +50,18 @@ export class TeamConfigReader {
|
||||||
|
|
||||||
return summaries;
|
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 {
|
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[]> {
|
async listTeams(): Promise<TeamSummary[]> {
|
||||||
return this.configReader.listTeams();
|
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 { TeamConfigReader } from './TeamConfigReader';
|
||||||
export { TeamDataService } from './TeamDataService';
|
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 {
|
export function getTeamsBasePath(): string {
|
||||||
return path.join(getClaudeBasePath(), 'teams');
|
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 */
|
/** List all teams */
|
||||||
export const TEAM_LIST = 'team:list';
|
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_SAVE_LAST_CONNECTION,
|
||||||
SSH_STATUS,
|
SSH_STATUS,
|
||||||
SSH_TEST,
|
SSH_TEST,
|
||||||
|
TEAM_ALIVE_LIST,
|
||||||
|
TEAM_CANCEL_PROVISIONING,
|
||||||
|
TEAM_CHANGE,
|
||||||
|
TEAM_CREATE,
|
||||||
|
TEAM_CREATE_TASK,
|
||||||
|
TEAM_DELETE_TEAM,
|
||||||
|
TEAM_GET_DATA,
|
||||||
TEAM_LIST,
|
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_CHECK,
|
||||||
UPDATER_DOWNLOAD,
|
UPDATER_DOWNLOAD,
|
||||||
UPDATER_INSTALL,
|
UPDATER_INSTALL,
|
||||||
|
|
@ -60,18 +76,30 @@ import type {
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
|
CreateTaskRequest,
|
||||||
ElectronAPI,
|
ElectronAPI,
|
||||||
HttpServerStatus,
|
HttpServerStatus,
|
||||||
|
IpcResult,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
|
SendMessageRequest,
|
||||||
|
SendMessageResult,
|
||||||
SessionsByIdsOptions,
|
SessionsByIdsOptions,
|
||||||
SessionsPaginationOptions,
|
SessionsPaginationOptions,
|
||||||
SshConfigHostEntry,
|
SshConfigHostEntry,
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
SshConnectionStatus,
|
SshConnectionStatus,
|
||||||
SshLastConnection,
|
SshLastConnection,
|
||||||
|
TeamChangeEvent,
|
||||||
|
TeamCreateRequest,
|
||||||
|
TeamCreateResponse,
|
||||||
|
TeamData,
|
||||||
|
TeamProvisioningPrepareResult,
|
||||||
|
TeamProvisioningProgress,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
|
TeamTask,
|
||||||
|
TeamTaskStatus,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
IpcResult,
|
UpdateKanbanPatch,
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -456,6 +484,74 @@ const electronAPI: ElectronAPI = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
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,
|
ConfigAPI,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
ConversationGroup,
|
ConversationGroup,
|
||||||
|
CreateTaskRequest,
|
||||||
ElectronAPI,
|
ElectronAPI,
|
||||||
FileChangeEvent,
|
FileChangeEvent,
|
||||||
HttpServerAPI,
|
HttpServerAPI,
|
||||||
|
|
@ -24,6 +25,8 @@ import type {
|
||||||
Project,
|
Project,
|
||||||
RepositoryGroup,
|
RepositoryGroup,
|
||||||
SearchSessionsResult,
|
SearchSessionsResult,
|
||||||
|
SendMessageRequest,
|
||||||
|
SendMessageResult,
|
||||||
Session,
|
Session,
|
||||||
SessionAPI,
|
SessionAPI,
|
||||||
SessionDetail,
|
SessionDetail,
|
||||||
|
|
@ -36,9 +39,18 @@ import type {
|
||||||
SshConnectionStatus,
|
SshConnectionStatus,
|
||||||
SshLastConnection,
|
SshLastConnection,
|
||||||
SubagentDetail,
|
SubagentDetail,
|
||||||
TeamSummary,
|
TeamChangeEvent,
|
||||||
|
TeamCreateRequest,
|
||||||
|
TeamCreateResponse,
|
||||||
|
TeamData,
|
||||||
|
TeamProvisioningPrepareResult,
|
||||||
|
TeamProvisioningProgress,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
|
TeamSummary,
|
||||||
|
TeamTask,
|
||||||
|
TeamTaskStatus,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
|
UpdateKanbanPatch,
|
||||||
UpdaterAPI,
|
UpdaterAPI,
|
||||||
WaterfallData,
|
WaterfallData,
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
|
|
@ -592,5 +604,68 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
||||||
return [];
|
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,
|
CARD_TEXT_LIGHT,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
|
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -31,57 +32,6 @@ interface TeammateMessageItemProps {
|
||||||
highlightStyle?: React.CSSProperties;
|
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
|
// Resend Detection
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -128,7 +78,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
||||||
|
|
||||||
// Detect operational noise
|
// Detect operational noise
|
||||||
const noiseLabel = useMemo(
|
const noiseLabel = useMemo(
|
||||||
() => detectNoise(teammateMessage.content, teammateMessage.teammateId),
|
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
|
||||||
[teammateMessage.content, teammateMessage.teammateId]
|
[teammateMessage.content, teammateMessage.teammateId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext';
|
||||||
import { DashboardView } from '../dashboard/DashboardView';
|
import { DashboardView } from '../dashboard/DashboardView';
|
||||||
import { NotificationsView } from '../notifications/NotificationsView';
|
import { NotificationsView } from '../notifications/NotificationsView';
|
||||||
import { SettingsView } from '../settings/SettingsView';
|
import { SettingsView } from '../settings/SettingsView';
|
||||||
|
import { TeamDetailView } from '../team/TeamDetailView';
|
||||||
import { TeamListView } from '../team/TeamListView';
|
import { TeamListView } from '../team/TeamListView';
|
||||||
|
|
||||||
import { SessionTabContent } from './SessionTabContent';
|
import { SessionTabContent } from './SessionTabContent';
|
||||||
|
|
@ -44,6 +45,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
|
||||||
{tab.type === 'notifications' && <NotificationsView />}
|
{tab.type === 'notifications' && <NotificationsView />}
|
||||||
{tab.type === 'settings' && <SettingsView />}
|
{tab.type === 'settings' && <SettingsView />}
|
||||||
{tab.type === 'teams' && <TeamListView />}
|
{tab.type === 'teams' && <TeamListView />}
|
||||||
|
{tab.type === 'team' && <TeamDetailView teamName={tab.teamName ?? ''} />}
|
||||||
{tab.type === 'session' && (
|
{tab.type === 'session' && (
|
||||||
<TabUIProvider tabId={tab.id}>
|
<TabUIProvider tabId={tab.id}>
|
||||||
<SessionTabContent tab={tab} isActive={isActive} />
|
<SessionTabContent tab={tab} isActive={isActive} />
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ const TAB_ICONS = {
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
session: FileText,
|
session: FileText,
|
||||||
teams: Users,
|
teams: Users,
|
||||||
|
team: Users,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SortableTab = ({
|
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 => {
|
export const TeamEmptyState = (): React.JSX.Element => {
|
||||||
return (
|
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">
|
<div className="text-center">
|
||||||
<p className="text-lg font-medium text-[var(--color-text)]">Команды не найдены</p>
|
<p className="text-lg font-medium text-[var(--color-text)]">Команды не найдены</p>
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
<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 { isElectronMode } from '@renderer/api';
|
||||||
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||||
import { TeamEmptyState } from './TeamEmptyState';
|
import { TeamEmptyState } from './TeamEmptyState';
|
||||||
|
|
||||||
export const TeamListView = (): React.JSX.Element => {
|
export const TeamListView = (): React.JSX.Element => {
|
||||||
const electronMode = isElectronMode();
|
const electronMode = isElectronMode();
|
||||||
const { teams, teamsLoading, teamsError, fetchTeams } = useStore(
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
const { teams, teamsLoading, teamsError, fetchTeams, openTeamTab, deleteTeam } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
teamsLoading: s.teamsLoading,
|
teamsLoading: s.teamsLoading,
|
||||||
teamsError: s.teamsError,
|
teamsError: s.teamsError,
|
||||||
fetchTeams: s.fetchTeams,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!electronMode) {
|
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) {
|
if (teamsLoading) {
|
||||||
return (
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (teamsError) {
|
if (teamsError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full items-center justify-center p-6">
|
<div className="size-full overflow-auto p-4">
|
||||||
<div className="text-center">
|
{renderHeader()}
|
||||||
<p className="text-sm font-medium text-red-400">Не удалось загрузить команды</p>
|
<div className="flex size-full items-center justify-center p-6">
|
||||||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{teamsError}</p>
|
<div className="text-center">
|
||||||
<button
|
<p className="text-sm font-medium text-red-400">Не удалось загрузить команды</p>
|
||||||
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)]"
|
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{teamsError}</p>
|
||||||
onClick={() => {
|
<Button
|
||||||
void fetchTeams();
|
variant="outline"
|
||||||
}}
|
size="sm"
|
||||||
>
|
className="mt-4"
|
||||||
Повторить
|
onClick={() => {
|
||||||
</button>
|
void fetchTeams();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{createDialogElement}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (teams.length === 0) {
|
if (teams.length === 0) {
|
||||||
return <TeamEmptyState />;
|
return (
|
||||||
|
<div className="size-full overflow-auto p-4">
|
||||||
|
{renderHeader()}
|
||||||
|
<TeamEmptyState />
|
||||||
|
{createDialogElement}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-auto p-4">
|
<div className="size-full overflow-auto p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
{renderHeader()}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<article
|
<div
|
||||||
key={team.name}
|
key={team.teamName}
|
||||||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
|
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)]">
|
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
|
||||||
{team.description || 'Без описания'}
|
{team.description || 'Без описания'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-center gap-3 text-xs text-[var(--color-text-muted)]">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<span>Участников: {team.memberCount}</span>
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
<span>Задач: {team.taskCount}</span>
|
Участников: {team.memberCount}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
|
Задач: {team.taskCount}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{createDialogElement}
|
||||||
</div>
|
</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 { createSessionDetailSlice } from './slices/sessionDetailSlice';
|
||||||
import { createSessionSlice } from './slices/sessionSlice';
|
import { createSessionSlice } from './slices/sessionSlice';
|
||||||
import { createSubagentSlice } from './slices/subagentSlice';
|
import { createSubagentSlice } from './slices/subagentSlice';
|
||||||
import { createTeamSlice } from './slices/teamSlice';
|
|
||||||
import { createTabSlice } from './slices/tabSlice';
|
import { createTabSlice } from './slices/tabSlice';
|
||||||
import { createTabUISlice } from './slices/tabUISlice';
|
import { createTabUISlice } from './slices/tabUISlice';
|
||||||
|
import { createTeamSlice } from './slices/teamSlice';
|
||||||
import { createUISlice } from './slices/uiSlice';
|
import { createUISlice } from './slices/uiSlice';
|
||||||
import { createUpdateSlice } from './slices/updateSlice';
|
import { createUpdateSlice } from './slices/updateSlice';
|
||||||
|
|
||||||
import type { DetectedError } from '../types/data';
|
import type { DetectedError } from '../types/data';
|
||||||
import type { AppState } from './types';
|
import type { AppState } from './types';
|
||||||
import type { UpdaterStatus } from '@shared/types';
|
import type { TeamChangeEvent, UpdaterStatus } from '@shared/types';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Store Creation
|
// Store Creation
|
||||||
|
|
@ -63,10 +63,19 @@ export const useStore = create<AppState>()((...args) => ({
|
||||||
*/
|
*/
|
||||||
export function initializeNotificationListeners(): () => void {
|
export function initializeNotificationListeners(): () => void {
|
||||||
const cleanupFns: (() => void)[] = [];
|
const cleanupFns: (() => void)[] = [];
|
||||||
|
useStore.getState().subscribeProvisioningProgress();
|
||||||
|
cleanupFns.push(() => {
|
||||||
|
useStore.getState().unsubscribeProvisioningProgress();
|
||||||
|
});
|
||||||
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const pendingProjectRefreshTimers = 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 SESSION_REFRESH_DEBOUNCE_MS = 150;
|
||||||
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
|
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 => {
|
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
|
||||||
if (!projectId) return null;
|
if (!projectId) return null;
|
||||||
const separatorIndex = projectId.indexOf('::');
|
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
|
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||||
if (api.onTodoChange) {
|
if (api.onTodoChange) {
|
||||||
const cleanup = api.onTodoChange((event) => {
|
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
|
// Listen for updater status events from main process
|
||||||
if (api.updater?.onStatus) {
|
if (api.updater?.onStatus) {
|
||||||
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
|
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 { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
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';
|
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 {
|
export interface TeamSlice {
|
||||||
teams: TeamSummary[];
|
teams: TeamSummary[];
|
||||||
teamsLoading: boolean;
|
teamsLoading: boolean;
|
||||||
teamsError: string | null;
|
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>;
|
fetchTeams: () => Promise<void>;
|
||||||
openTeamsTab: () => 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) => ({
|
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
|
||||||
teams: [],
|
teams: [],
|
||||||
teamsLoading: false,
|
teamsLoading: false,
|
||||||
teamsError: null,
|
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 () => {
|
fetchTeams: async () => {
|
||||||
set({ teamsLoading: true, teamsError: null });
|
set({ teamsLoading: true, teamsError: null });
|
||||||
|
|
@ -50,4 +118,231 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
label: 'Teams',
|
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 { SessionDetailSlice } from './slices/sessionDetailSlice';
|
||||||
import type { SessionSlice } from './slices/sessionSlice';
|
import type { SessionSlice } from './slices/sessionSlice';
|
||||||
import type { SubagentSlice } from './slices/subagentSlice';
|
import type { SubagentSlice } from './slices/subagentSlice';
|
||||||
import type { TeamSlice } from './slices/teamSlice';
|
|
||||||
import type { TabSlice } from './slices/tabSlice';
|
import type { TabSlice } from './slices/tabSlice';
|
||||||
import type { TabUISlice } from './slices/tabUISlice';
|
import type { TabUISlice } from './slices/tabUISlice';
|
||||||
|
import type { TeamSlice } from './slices/teamSlice';
|
||||||
import type { UISlice } from './slices/uiSlice';
|
import type { UISlice } from './slices/uiSlice';
|
||||||
import type { UpdateSlice } from './slices/updateSlice';
|
import type { UpdateSlice } from './slices/updateSlice';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Type of content displayed in this tab */
|
/** 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') */
|
/** Session ID (required when type === 'session') */
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
|
@ -84,6 +84,9 @@ export interface Tab {
|
||||||
/** Project ID (required when type === 'session') */
|
/** Project ID (required when type === 'session') */
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
||||||
|
/** Team name (required when type === 'team') */
|
||||||
|
teamName?: string;
|
||||||
|
|
||||||
/** Display name for the tab (max 50 chars) */
|
/** Display name for the tab (max 50 chars) */
|
||||||
label: string;
|
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,
|
NotificationTrigger,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
} from './notifications';
|
} 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 { WaterfallData } from './visualization';
|
||||||
import type {
|
import type {
|
||||||
ConversationGroup,
|
ConversationGroup,
|
||||||
|
|
@ -312,6 +326,24 @@ export interface HttpServerAPI {
|
||||||
|
|
||||||
export interface TeamsAPI {
|
export interface TeamsAPI {
|
||||||
list: () => Promise<TeamSummary[]>;
|
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 {
|
export interface TeamMember {
|
||||||
name: string;
|
name: string;
|
||||||
|
agentId?: string;
|
||||||
|
agentType?: string;
|
||||||
|
role?: string;
|
||||||
|
color?: string;
|
||||||
|
joinedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamConfig {
|
export interface TeamConfig {
|
||||||
|
|
@ -9,9 +14,151 @@ export interface TeamConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamSummary {
|
export interface TeamSummary {
|
||||||
name: string;
|
teamName: string;
|
||||||
|
displayName: string;
|
||||||
description: string;
|
description: string;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
taskCount: number;
|
taskCount: number;
|
||||||
lastActivity: string | null;
|
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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
content: [
|
content: [
|
||||||
'./src/renderer/index.html',
|
'./src/renderer/index.html',
|
||||||
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
||||||
|
|
@ -48,6 +49,7 @@ module.exports = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/typography')
|
require('@tailwindcss/typography'),
|
||||||
|
require('tailwindcss-animate')
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
coercePageLimit,
|
coercePageLimit,
|
||||||
coerceSearchMaxResults,
|
coerceSearchMaxResults,
|
||||||
|
validateFromField,
|
||||||
|
validateMemberName,
|
||||||
validateProjectId,
|
validateProjectId,
|
||||||
validateSearchQuery,
|
validateSearchQuery,
|
||||||
validateSessionId,
|
validateSessionId,
|
||||||
|
validateTaskId,
|
||||||
|
validateTeamName,
|
||||||
} from '../../../src/main/ipc/guards';
|
} from '../../../src/main/ipc/guards';
|
||||||
|
|
||||||
describe('ipc guards', () => {
|
describe('ipc guards', () => {
|
||||||
|
|
@ -51,4 +55,18 @@ describe('ipc guards', () => {
|
||||||
expect(coercePageLimit(500, 20)).toBe(200);
|
expect(coercePageLimit(500, 20)).toBe(200);
|
||||||
expect(coercePageLimit(0, 20)).toBe(20);
|
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'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
|
'@preload': resolve(__dirname, 'src/preload'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue