17 KiB
Итерация 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, см. ниже)TeamTaskResolvedTeamMemberTeamData(без 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: stringmemberCount: numbertaskCount: number(в итерации 02 можно оставить 0, либо заполнить после чтения задач)lastActivity: string | null(в итерации 02 остаётсяnull)
Обязательная правка в этой итерации: обновить реализацию team:list и UI, чтобы клик открывал Team tab по teamName, а отображение было по displayName.
TeamTask (читает Claude Code task file)
id: stringsubject: stringdescription?: stringactiveForm?: stringowner?: stringstatus: 'pending' | 'in_progress' | 'completed' | 'deleted'blocks?: string[]blockedBy?: string[]
ResolvedTeamMember (итерация 02)
name: stringstatus: '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: TeamConfigtasks: TeamTask[]members: ResolvedTeamMember[]warnings?: string[](например, “tasks failed to load”)
TeamChangeEvent
type: 'config' | 'inbox' | 'task'teamName: stringdetail?: 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работает, валидирует аргументы, возвращаетTeamDataTEAM_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.tssrc/main/services/team/TeamInboxReader.ts(в итерации 02 — толькоlistInboxNames())src/main/services/team/TeamMemberResolver.tssrc/renderer/components/team/TeamDetailView.tsxsrc/renderer/components/team/members/MemberList.tsxsrc/renderer/components/team/members/MemberCard.tsxsrc/renderer/components/team/tasks/TaskList.tsxsrc/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(+ handlerTEAM_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, emitteam-change)src/main/index.ts(wireteam-changeforwarding + 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 — типы компилируются
-
Shared types
- Обновить
TeamSummaryна{ teamName, displayName, ... }(migration из итерации 01) - Расширить
src/shared/types/team.tsновыми типами (см. “Контракт”) - Расширить
src/shared/types/api.ts(TeamsAPI.getData,TeamsAPI.onTeamChange)
- Обновить
-
pnpm typecheck
CP1 — backend + IPC возвращают TeamData
-
Path helpers
- Добавить
getTasksBasePath()вsrc/main/utils/pathDecoder.ts:return path.join(getClaudeBasePath(), 'tasks')
- Добавить
-
Readers + resolver
TeamConfigReader.listTeams():teamName = entry.name(directory name)displayName = config.namedescription/memberCountкак раньше
TeamConfigReader.getConfig(teamName)читаетconfig.json, возвращаетTeamConfig | nullTeamTaskReader.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'для всех
- строит union имён:
-
TeamDataService.getTeamData(teamName)
configобязателен: если нет → throw Error “Team not found”tasks/inboxNamesгрузятся с graceful fallback иwarnings[]membersстроится резолвером
-
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>
- добавить
- В
-
Preload
src/preload/constants/ipcChannels.ts: добавитьTEAM_GET_DATA,TEAM_CHANGEsrc/preload/index.ts:teams.getData(teamName)черезinvokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName)teams.onTeamChange(cb)подписывается наTEAM_CHANGEи возвращает cleanup
-
Быстрая проверка:
- в DevTools:
await window.electronAPI.teams.getData('<teamName>')
- в DevTools:
-
pnpm typecheck
CP2 — FileWatcher шлёт team-change, store обновляет данные
-
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
- teamsWatcher:
- teamName извлекать как первый сегмент пути
filename.split(/[\\/]/)[0] - Поведение при отсутствии директории:
- если
~/.claude/teamsили~/.claude/tasksотсутствует — это НЕ ошибка, watcher просто не стартует и планирует retry (как todos)
- если
- добавить
- В
-
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)
- добавить forwarding для
- В
-
Renderer store: подписка + throttle
- В
src/renderer/store/index.tsвнутриinitializeNotificationListeners():- подписаться на
api.teams.onTeamChange(если существует) - coalesce 300ms:
- всегда
fetchTeams()(лёгкая операция) - если активен Team tab данного teamName → вызвать
refreshTeamData(teamName)
- всегда
- подписаться на
- В
-
pnpm typecheck
CP3 — UI: Team tab + MemberList + TaskList
-
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
- добавить иконку для
-
TeamSlice
- Расширить
src/renderer/store/slices/teamSlice.ts:selectedTeamName: string | nullselectedTeamData: TeamData | nullselectedTeamLoading: booleanselectedTeamError: string | nullselectTeam(teamName)→ вызываетapi.teams.getDatarefreshTeamData(teamName)→ re-fetch если выбран тот же teamNameopenTeamTab(teamName):- ищет существующий
tab.type==='team' && tab.teamName===teamNameво всех panes → фокусирует - иначе
openTab({ type:'team', label: teamName, teamName })
- ищет существующий
- Расширить
-
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)
- таблица/лист: id, subject, owner, status, blocked (если
-
pnpm test -
Ручная проверка (обязательная)
- Открыть
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.