Merge pull request #8 from 777genius/improvements

improvement
This commit is contained in:
Илия 2026-02-26 21:51:27 +02:00 committed by GitHub
commit 1698319443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 29532 additions and 2127 deletions

View file

@ -46,5 +46,22 @@ components/
└── sidebar/ # Sidebar navigation
```
## Data Access: Store over Props
When data is available in the Zustand store, child components should read it directly via `useStore()` instead of receiving it through props. This avoids unnecessary prop drilling and keeps parent components clean.
```tsx
// Preferred — child reads from store
const ProcessesSection = () => {
const teamName = useStore((s) => s.selectedTeamName);
const data = useStore((s) => s.selectedTeamData);
// ...
};
// Avoid — parent drills store data as props
<ProcessesSection teamName={teamName} processes={data.processes} members={data.members} />
```
Only pass props when the data is NOT in the store (e.g. local state, computed values, callbacks).
## Contexts
- `contexts/TabUIContext.tsx` - Per-tab UI state isolation

View file

@ -6,6 +6,7 @@ This project follows the Contributor Covenant Code of Conduct.
- Be respectful and constructive.
- Assume good intent and discuss ideas, not people.
- Give actionable feedback and accept feedback gracefully.
- If a change significantly affects the UI, discuss the design approach with maintainers or the community first so we can align on the best direction.
## Unacceptable Behavior
- Harassment, discrimination, or personal attacks.
@ -13,7 +14,7 @@ This project follows the Contributor Covenant Code of Conduct.
- Publishing private information without explicit permission.
## Enforcement
Project maintainers are responsible for clarifying and enforcing this code of conduct and may take corrective action for unacceptable behavior.
Maintainers are here to help keep our community welcoming. Theyll clarify expectations when needed and, if necessary, take steps to address behavior that goes against these standards.
## Reporting
Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`.

View file

@ -108,6 +108,7 @@ pnpm dist # macOS + Windows + Linux
## TODO
- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
---

View file

@ -0,0 +1,255 @@
# Continuous Scroll Diff View -- Overview
## 1. Цель
Текущий Review Dialog показывает diff для одного файла за раз. Пользователь переключает файлы через дерево слева. Это создает трение при ревью: нужно кликать каждый файл, терять контекст между файлами, невозможно быстро пролистать все изменения.
**Continuous Scroll Diff View** -- это режим, в котором все файлы changeset-а отображаются в одном непрерывном скролле, аналогично GitHub PR diff view. Каждый файл начинается с заголовка (sticky header), за которым идет diff. Пользователь скроллит вниз и видит все изменения последовательно. File tree слева синхронизируется с текущей видимой позицией (scroll-spy), клик на файл в дереве плавно прокручивает к нему.
---
## 2. Целевой UX
### Что видит пользователь
1. Открывает Review Dialog с несколькими файлами
2. Слева -- file tree (как сейчас), справа -- непрерывный скролл всех файлов
3. Каждый файл начинается со **sticky header** (имя файла, badges, +/-) -- при скролле header "прилипает" к верху
4. Под header -- CodeMirror diff view для этого файла
5. Неизменённые регионы свёрнуты (portionCollapse), с возможностью развернуть порциями ("Expand 100" / "Expand All")
6. Файлы, контент которых ещё не загружен, показывают placeholder с skeleton
7. File tree подсвечивает текущий видимый файл (scroll-spy)
8. Клик по файлу в дереве -> плавный скролл к этому файлу
9. Cmd+Y/N accept/reject работают для видимого файла (или focused editor)
10. "Accept All" / "Reject All" применяются ко ВСЕМ файлам
11. Progress bar показывает "12 of 45 changes reviewed"
12. Auto-viewed помечает файлы по мере скролла
### Когда включается continuous mode
- Когда файлов > 1 в changeset -- continuous mode автоматически
- Когда файл один -- обычный single-file mode (без изменений)
---
## 3. Архитектурные решения
### 3.1. Почему НЕ @tanstack/react-virtual
Виртуализация (react-virtual, react-window и т.д.) работает по принципу: рендерить только элементы в viewport, остальные -- placeholder с фиксированной высотой.
**Проблема для CodeMirror:**
- CodeMirror EditorView требует реального DOM-узла для создания editor instance
- EditorView рассчитывает layout, позиции строк, viewport -- всё завязано на реальный DOM
- При "виртуализации" EditorView нужно destroy/create при входе/выходе из viewport
- destroy теряет undo history, scroll position внутри editor, cursor position
- create -- тяжёлая операция (парсинг, syntax highlighting, merge computation)
**Альтернатива: lazy loading + portionCollapse:**
- Все файлы существуют в DOM одновременно
- Но их контент загружается lazy (Phase 2)
- Неизменённые регионы свёрнуты через portionCollapse (Phase 4)
- Итог: 50 файлов в DOM, но каждый занимает минимум строк (только changed lines + margin)
### 3.2. Почему кастомный portionCollapse
CodeMirror из коробки поддерживает `collapseUnchanged` в `unifiedMergeView`:
```typescript
unifiedMergeView({
collapseUnchanged: { margin: 3, minSize: 4 }
});
```
**Проблема:** встроенный collapse -- monolithic. Кнопка "expand" раскрывает ВСЮ свёрнутую область, без возможности:
- Раскрыть порцию строк (например, 100 строк за одно нажатие)
- Раскрыть полностью по отдельной кнопке
- Показать контекст постепенно
**Решение:** кастомный `portionCollapse.ts` -- StateField + Decoration, который:
- Управляет свёрнутыми регионами как `RangeSet<Decoration>`
- Поддерживает partial expand (portionSize=100 строк за нажатие)
- Полностью заменяет встроенный collapseUnchanged
### 3.3. Lazy loading вместо виртуализации
Файлы загружают контент по мере приближения к viewport:
- IntersectionObserver с `rootMargin: '200% 0px 200% 0px'` на placeholder каждого файла
- Когда placeholder входит в расширенный viewport -- `fetchFileContent()` запускается
- Пока контент грузится -- placeholder показывает skeleton
- После загрузки -- CodeMirrorDiffView рендерится
Это даёт:
- Быстрый первичный рендер (только заголовки + placeholders)
- Предварительная загрузка за 2 viewport-высоты до видимости
- Нет потери undo history (EditorView живёт, пока диалог открыт)
---
## 4. Карта файлов
### 4.1. Новые файлы (8)
| Файл | Путь | Фаза | Ответственность |
|------|------|------|-----------------|
| `FileSectionHeader.tsx` | `src/renderer/components/team/review/FileSectionHeader.tsx` | Phase 1 | Sticky header для каждого файла: имя, badges (+/-), content source, viewed checkbox, file-level decision indicator. Использует `position: sticky; top: 0; z-index: 10`. |
| `FileSectionDiff.tsx` | `src/renderer/components/team/review/FileSectionDiff.tsx` | Phase 1 | Обёртка над CodeMirrorDiffView для одного файла в continuous scroll. Управляет lifecycle EditorView (onEditorViewReady(filePath, view \| null) единый callback), содержит sentinel для auto-viewed, передаёт все props в CodeMirrorDiffView. |
| `FileSectionPlaceholder.tsx` | `src/renderer/components/team/review/FileSectionPlaceholder.tsx` | Phase 1 | Placeholder-скелетон для файла, пока контент не загружен. Фиксированная высота (~200px). Содержит IntersectionObserver trigger для lazy loading (Phase 2). |
| `ContinuousScrollView.tsx` | `src/renderer/components/team/review/ContinuousScrollView.tsx` | Phase 1 | Главный контейнер: рендерит файлы последовательно (FileSectionHeader + FileSectionDiff/Placeholder). Хранит EditorView Map (Phase 5). useImperativeHandle для доступа к Map из родителя. Обрабатывает scroll events для scroll-spy. |
| `useVisibleFileSection.ts` | `src/renderer/hooks/useVisibleFileSection.ts` | Phase 1 | Hook для scroll-spy: IntersectionObserver определяет, какой file section сейчас виден в viewport. Возвращает `activeFilePath`. Учитывает programmatic scroll (flag `isProgrammaticScroll`). |
| `useContinuousScrollNav.ts` | `src/renderer/hooks/useContinuousScrollNav.ts` | Phase 1 | Hook для programmatic navigation: `scrollToFile(filePath)` -- плавный скролл к конкретному файлу. Использует `Element.scrollIntoView({ behavior: 'smooth' })`. Устанавливает `isProgrammaticScroll` flag для подавления scroll-spy. |
| `useLazyFileContent.ts` | `src/renderer/hooks/useLazyFileContent.ts` | Phase 2 | Hook для lazy loading контента файлов: IntersectionObserver с rootMargin для prefetch. Вызывает `fetchFileContent()` из store. Отслеживает loaded/loading state per file. |
| `portionCollapse.ts` | `src/renderer/components/team/review/portionCollapse.ts` | Phase 4 | CodeMirror StateField + Decoration для partial collapse неизменённых regions. Кнопки "Expand 100" (portionSize=100) и "Expand All". Rebuilds decorations после accept/reject. Включает `portionCollapseTheme` со стилями. |
### 4.2. Модифицируемые файлы (8)
| Файл | Путь | Фазы | Изменения |
|------|------|------|-----------|
| `ChangeReviewDialog.tsx` | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | Phase 1, 3, 5 | **Phase 1:** условный рендер ContinuousScrollView vs single-file mode, убирается file header из content area. **Phase 3:** continuousOptions передаётся в useDiffNavigation (10-й параметр). **Phase 5:** handleAcceptAll/RejectAll multi-file, per-file discardCounters, continuousScrollActiveFilePath state, isContinuousMode computed, EditorView Map через ref. |
| `ReviewFileTree.tsx` | `src/renderer/components/team/review/ReviewFileTree.tsx` | Phase 1 | Highlight active file из scroll-spy (не только selected), новый prop `activeFilePath` для visual indicator (отличается от `selectedFilePath`). В continuous mode `activeFilePath` определяется scroll-spy, `selectedFilePath` не используется. |
| `CodeMirrorDiffView.tsx` | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Phase 4 | Замена встроенного `collapseUnchanged` на кастомный portionCollapse extension. Новый prop `usePortionCollapse` (boolean). Добавление portionCollapse StateField в buildExtensions() через отдельный Compartment. |
| `changeReviewSlice.ts` | `src/renderer/store/slices/changeReviewSlice.ts` | Phase 2 | Новый action `prefetchFileContents(teamName, memberName, filePaths)` -- batch-загрузка контента нескольких файлов. Вызывается из useLazyFileContent при пересечении IntersectionObserver. |
| `useDiffNavigation.ts` | `src/renderer/hooks/useDiffNavigation.ts` | Phase 3 | Новый optional param `continuousOptions?: ContinuousNavigationOptions` (10-й параметр). Внутри keyboard handler: `getActiveEditorView()` проверяет focused editor первым, затем activeFilePath, затем первый editor. Cross-file chunk navigation при достижении последнего chunk в файле. Helpers: `isLastChunkInFile()`, `isFirstChunkInFile()`. |
| `ReviewToolbar.tsx` | `src/renderer/components/team/review/ReviewToolbar.tsx` | Phase 5 | Новые props: `isContinuousMode`, `reviewedCount`, `totalHunks`. Tooltip "Accept all changes across all files" в continuous mode. Progress bar компонент. |
| `KeyboardShortcutsHelp.tsx` | `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | Phase 3 | Новые shortcuts: Alt+K (prev change), Alt+ArrowDown/Up (next/prev file), ? (toggle help). |
| `useContinuousScrollNav.ts` | `src/renderer/hooks/useContinuousScrollNav.ts` | Phase 3 | Уточнение scrollToFile: принудительный setActiveFilePath после стабилизации scroll. |
---
## 5. Зависимости между фазами
```
Phase 4 (portionCollapse) ─────────────────────────────────────┐
(изолированный CM extension, можно параллельно с 2/3) │
Phase 1 (Continuous Scroll + Scroll-Spy) ──┬──> Phase 2 ───────┼──> Phase 5
(базовая инфраструктура) │ (Lazy Loading) │ (Polish)
│ │
├──> Phase 3 ────────┘
│ (Navigation)
└──> Phase 5
(EditorView Map + Toolbar)
```
**Детали:**
| Зависимость | Причина |
|-------------|---------|
| Phase 1 -> Phase 2 | useLazyFileContent использует IntersectionObserver на placeholder, созданном в ContinuousScrollView |
| Phase 1 -> Phase 3 | Keyboard navigation в continuous mode требует scroll infrastructure (scrollToFile) и scroll-spy (activeFilePath) |
| Phase 1 -> Phase 5 | EditorView Map живёт в ContinuousScrollView. Accept All/Reject All итерируют по Map. |
| Phase 4 (параллельно) | portionCollapse.ts -- изолированный CM StateField/Extension. Не зависит от ContinuousScrollView. Может разрабатываться и тестироваться отдельно на обычном CodeMirrorDiffView. |
| Phase 5 -> после 1-4 | Финальная полировка, интеграция всех компонентов. Требует: ContinuousScrollView (Phase 1), lazy loading (Phase 2), navigation (Phase 3), portionCollapse (Phase 4). |
**Рекомендованный порядок:**
```
Неделя 1: Phase 1 + Phase 4 (параллельно)
Неделя 2: Phase 2 + Phase 3 (параллельно, после Phase 1)
Неделя 3: Phase 5 (после всех)
```
---
## 6. Критические edge-cases
| # | Кейс | Решение | Фаза |
|---|------|---------|------|
| 1 | **Scroll-spy + programmatic scroll race:** scroll-spy определяет "не тот" файл во время programmatic scroll (scrollToFile) | `isProgrammaticScroll` ref flag. scrollToFile устанавливает flag=true. Scroll-spy игнорирует IntersectionObserver events пока flag=true. `waitForScrollEnd()` (через `scrollend` event или debounced timeout 150ms) сбрасывает flag и берёт финальный видимый файл. | Phase 1 |
| 2 | **50 EditorViews в памяти:** потенциальная проблема с памятью и производительностью при большом количестве файлов | portionCollapse минимизирует DOM-контент каждого editor (свёрнутые regions = 0 DOM-нод). Lazy loading (Phase 2) гарантирует постепенную загрузку. Если профилирование покажет проблемы -- destroy EditorViews далеко за viewport (будущая оптимизация, не в Phase 5). | Phase 5 |
| 3 | **Keyboard Cmd+Y/N -- какой editor:** несколько EditorView на экране, нужно определить целевой | Приоритет: (1) EditorView, содержащий `document.activeElement` (user clicked into it), (2) EditorView для `activeFilePath` из scroll-spy. Реализовано в `resolveActiveEditorView()`. | Phase 5 |
| 4 | **Cross-file hunk navigation:** goToNextChunk в последнем chunk файла -> нужно перейти к следующему файлу | goToNextChunk не выходит за пределы одного EditorView. Для cross-file: определить, что cursor на последнем chunk (`isLastChunkInFile()`), -> scrollToFile(nextFile) + goToNextChunk(nextView). Реализуется в useDiffNavigation Phase 3 рефакторинге. | Phase 3 |
| 5 | **portionCollapse + accept/reject:** после accept chunk-а, неизменённые regions меняются | portionCollapse rebuilds decorations через `EditorView.updateListener`. При изменении doc или original (updateOriginalDoc effect) -- декорации пересчитываются. | Phase 4 |
| 6 | **Auto-viewed threshold 0.85:** sentinel при threshold 1.0 может не срабатывать из-за collapse | Threshold 0.85 для 1px sentinel элемента. portionCollapse может значительно уменьшить высоту файла, из-за чего sentinel может быть "видим" до полного просмотра. 0.85 дает margin. Sentinel размещается ПОСЛЕ CodeMirrorDiffView. | Phase 1, 5 |
| 7 | **Lazy loading race: файл не загружен при scrollToFile** | scrollToFile прокручивает к placeholder. useLazyFileContent автоматически запустит загрузку через IntersectionObserver. Placeholder -> skeleton -> loaded diff. Пользователь видит transition. | Phase 2 |
| 8 | **Sticky header z-index stacking:** несколько sticky headers при быстром скролле | Каждый header имеет `z-index: 10`. Только один виден как sticky (ближайший к top). Следующий header "выталкивает" предыдущий. CSS `position: sticky; top: 0` с корректным stacking context. | Phase 1 |
| 9 | **Discard one file в continuous mode:** пересоздание одного EditorView не должно сломать остальные | Per-file `discardCounters: Record<string, number>`. Key FileSectionDiff: `${filePath}:${discardCounters[filePath]}`. Инкремент counter только для одного файла -> React пересоздает только этот компонент. | Phase 5 |
| 10 | **Accept All + scroll position:** Accept All меняет высоту всех editors, scroll может "прыгнуть" | Браузер корректирует scroll для элементов выше viewport автоматически. Для элементов в viewport -- пользователь видит изменения, что ожидаемо. Не корректируем scroll искусственно. | Phase 5 |
| 11 | **File с unavailable content в continuous mode** | FileSectionDiff проверяет `contentSource`. Если `unavailable` -- рендерит fallback ReviewDiffContent вместо CodeMirrorDiffView. EditorView не создается -> не попадает в Map. Accept All/Reject All для таких файлов -- только store update. | Phase 1 |
---
## 7. Чеклист верификации
Полный чеклист для тестирования после реализации всех 5 фаз.
### Phase 1: Continuous Scroll + Scroll-Spy
- [ ] ContinuousScrollView рендерит все файлы последовательно
- [ ] Sticky headers "прилипают" при скролле и корректно сменяют друг друга
- [ ] Scroll-spy определяет текущий видимый файл
- [ ] ReviewFileTree подсвечивает видимый файл (не только selected)
- [ ] Клик по файлу в tree -> плавный scroll к этому файлу
- [ ] Programmatic scroll не вызывает "мерцание" в file tree (isProgrammaticScroll flag)
- [ ] Single-file mode (1 файл) -- работает как раньше, без ContinuousScrollView
- [ ] Файлы с `unavailable` content -- показывают fallback
- [ ] Пустой changeset (0 файлов) -- сообщение "No file changes detected"
### Phase 2: Lazy Loading
- [ ] При открытии dialog загружается контент только видимых файлов (1-3 штуки)
- [ ] При скролле вниз -- файлы загружаются за 2 viewport-высоты до видимости
- [ ] Placeholder с skeleton виден пока контент грузится
- [ ] После загрузки -- placeholder заменяется CodeMirrorDiffView
- [ ] Быстрый скролл через много файлов -- не спамит запросы (MAX_CONCURRENT=3 throttle)
- [ ] Повторное посещение файла -- контент уже в кэше (store), нет повторного запроса
### Phase 3: Navigation
- [ ] Alt+J -- переход к следующему change в текущем editor
- [ ] Alt+K -- переход к предыдущему change
- [ ] Alt+ArrowDown -- переход к следующему файлу (smooth scroll)
- [ ] Alt+ArrowUp -- переход к предыдущему файлу (smooth scroll)
- [ ] Cmd+Y -- accept chunk + next chunk
- [ ] Cmd+N -- reject chunk + next chunk
- [ ] Cross-file navigation: после последнего chunk в файле -> переход к первому chunk следующего файла
- [ ] Keyboard shortcuts работают и с focused editor, и без фокуса (fallback на activeFilePath)
- [ ] ? -- toggle shortcuts help dialog
### Phase 4: Portion Collapse
- [ ] Неизменённые regions >= 10 строк свёрнуты по умолчанию (minSize=4 + margin=3 с обеих сторон = 10 строк минимум для создания collapse)
- [ ] Widget "N unchanged lines" виден на месте свёрнутого региона
- [ ] Клик "Expand 100" -- раскрывает 100 строк (portionSize=100)
- [ ] Если строк меньше portionSize -- только кнопка "Expand All" (без "Expand N")
- [ ] Клик "Expand All" -- раскрывает свёрнутый регион полностью
- [ ] Accept chunk -> decorations пересчитываются (новые неизменённые areas корректно collapse)
- [ ] Reject chunk -> decorations пересчитываются
- [ ] Работает в single-file mode (без ContinuousScrollView)
### Phase 5: Polish
- [ ] "Accept All" -> все hunks во всех файлах accepted (store + CM)
- [ ] "Reject All" -> все hunks во всех файлах rejected (store + CM)
- [ ] Tooltip "Accept all changes across all files" (не "in current file")
- [ ] Progress bar "12 of 45 reviewed" обновляется при accept/reject
- [ ] Cmd+Y с focused editor -> accept в этом editor
- [ ] Cmd+Y без фокуса -> accept в activeFilePath editor
- [ ] Cmd+Enter -> save только activeFilePath
- [ ] Discard файла -> только этот EditorView пересоздается
- [ ] Auto-viewed помечает файлы по мере скролла (multiple files per scroll)
- [ ] Auto-viewed toggle off -> скролл не помечает файлы
- [ ] Закрытие dialog -> viewed state сохранён (persistent localStorage)
- [ ] 20+ файлов -- нет видимых лагов при scroll/accept all
### Cross-cutting
- [ ] Escape закрывает dialog
- [ ] Typecheck: `pnpm typecheck` проходит без ошибок
- [ ] Lint: `pnpm lint:fix` без warnings
- [ ] Тесты: `pnpm test` все проходят
- [ ] Нет регрессий в single-file mode
- [ ] macOS: traffic light padding корректен
- [ ] Dark/light theme: все CSS variables работают
---
## 8. Ссылки на файлы фаз
- [Phase 1: Continuous Scroll + Scroll-Spy](./phase-1-continuous-scroll-and-scroll-spy.md)
- [Phase 2: Lazy Loading](./phase-2-lazy-loading.md)
- [Phase 3: Navigation](./phase-3-navigation.md)
- [Phase 4: Portion Collapse](./phase-4-portion-collapse.md)
- [Phase 5: Polish + EditorView Map + Toolbar](./phase-5-polish.md)

View file

@ -0,0 +1,790 @@
# Фаза 2: Lazy Loading контента
## 1. Обзор
**Предпосылка:** В фазе 1 continuous scroll рендерит все файлы одновременно. Но контент файлов (`FileChangeWithContent`) загружается через IPC-вызов `fetchFileContent(teamName, memberName, filePath)` — это сетевой запрос к main process, который читает файл с диска, строит diff и возвращает `originalFullContent` + `modifiedFullContent`.
**Проблема:** При открытии review с 30+ файлами загрузка всех сразу:
- Блокирует main process 30 последовательными IPC-вызовами
- UI показывает 30 skeleton placeholders одновременно
- Пользователь видит контент только после загрузки всех файлов
- Для больших файлов (>10K строк) задержка ощутима
**Решение:** Lazy loading — контент загружается по мере приближения файла к viewport:
- Первые 5 файлов предзагружаются при mount (без ожидания scroll)
- Остальные файлы загружаются при пересечении rootMargin "200% 0px" (2 viewport-высоты до видимости)
- Максимум 3 параллельных загрузки (throttle) — не перегружать main process
- Приоритет: файлы ближе к viewport загружаются раньше
**Результат:** Пользователь видит первые файлы через ~200ms, остальные подгружаются бесшовно при скролле.
---
## 2. Новые файлы
### 2.1. `useLazyFileContent.ts`
**Путь:** `src/renderer/hooks/useLazyFileContent.ts`
**Назначение:** IntersectionObserver-based lazy loading контента файлов через `fetchFileContent` из changeReviewSlice.
#### Interface
```typescript
import type { RefObject } from 'react';
import type { FileChangeWithContent } from '@shared/types';
interface UseLazyFileContentOptions {
/** Имя команды (для fetchFileContent) */
teamName: string;
/** Имя участника (для fetchFileContent) */
memberName: string | undefined;
/** Список всех filePath в порядке рендеринга */
filePaths: string[];
/** Scroll container ref (ContinuousScrollView outer div) */
scrollContainerRef: RefObject<HTMLElement>;
/**
* Загруженный контент из store (для проверки: уже загружен?).
* Тип: Record<string, FileChangeWithContent> из changeReviewSlice.
*/
fileContents: Record<string, FileChangeWithContent>;
/** Флаги загрузки из store (для проверки: уже грузится?) */
fileContentsLoading: Record<string, boolean>;
/**
* Функция загрузки контента из store.
* Сигнатура точно как в changeReviewSlice.fetchFileContent:
* (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
*
* Внутри store уже есть guard от дубликатов (строка 264):
* if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
* Поэтому двойной вызов безопасен.
*/
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
/** Lazy loading включён (false = загрузить всё сразу, для fallback) */
enabled: boolean;
}
interface UseLazyFileContentReturn {
/**
* Регистрация file section для lazy-load наблюдения.
* Возвращает ref callback — передать в div section.
* Пример: <div ref={registerLazyRef(file.filePath)}>
*/
registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void;
}
```
#### Полная реализация (описание)
```typescript
export function useLazyFileContent(
options: UseLazyFileContentOptions
): UseLazyFileContentReturn {
const {
teamName,
memberName,
filePaths,
scrollContainerRef,
fileContents,
fileContentsLoading,
fetchFileContent,
enabled,
} = options;
// === Throttle State ===
// Set: filePath текущих in-flight загрузок
const activeLoads = useRef(new Set<string>());
// Queue: filePath ожидающих загрузки (FIFO, но с приоритетом)
const pendingQueue = useRef<string[]>([]);
// Max параллельных загрузок
const MAX_CONCURRENT = 3;
// Observer ref
const observerRef = useRef<IntersectionObserver | null>(null);
// Element refs
const elementRefs = useRef(new Map<string, HTMLElement>());
// Stable refs для текущих значений (избежание stale closures)
const fileContentsRef = useRef(fileContents);
const fileContentsLoadingRef = useRef(fileContentsLoading);
useEffect(() => {
fileContentsRef.current = fileContents;
fileContentsLoadingRef.current = fileContentsLoading;
}, [fileContents, fileContentsLoading]);
// === Throttled Loader ===
/**
* Проверяет, нужно ли загружать filePath:
* - Не загружен (нет в fileContents)
* - Не грузится (нет в fileContentsLoading или false)
* - Не в activeLoads (не in-flight)
*
* ВАЖНО: проверяем fileContentsRef (ref), а не fileContents (prop) —
* чтобы callback IntersectionObserver видел актуальное состояние.
*/
const shouldLoad = useCallback((filePath: string): boolean => {
if (fileContentsRef.current[filePath]) return false;
if (fileContentsLoadingRef.current[filePath]) return false;
if (activeLoads.current.has(filePath)) return false;
return true;
}, []);
/**
* Запустить загрузку одного файла.
* Возвращает Promise (для chaining).
*/
const loadFile = useCallback(
async (filePath: string): Promise<void> => {
if (!shouldLoad(filePath)) return;
activeLoads.current.add(filePath);
try {
await fetchFileContent(teamName, memberName, filePath);
} finally {
activeLoads.current.delete(filePath);
// После завершения — попробовать следующий из очереди
processQueue();
}
},
[teamName, memberName, fetchFileContent, shouldLoad]
);
/**
* Обработать очередь: запустить загрузки пока slots < MAX_CONCURRENT.
*/
const processQueue = useCallback(() => {
while (
activeLoads.current.size < MAX_CONCURRENT &&
pendingQueue.current.length > 0
) {
const nextPath = pendingQueue.current.shift()!;
if (shouldLoad(nextPath)) {
void loadFile(nextPath);
}
// Если nextPath уже не нужен (загружен за время ожидания) — пропускаем, берём следующий
}
}, [shouldLoad, loadFile]);
/**
* Добавить filePath в очередь загрузки.
* Если есть свободные слоты — загрузить сразу.
* Если нет — добавить в pending queue.
*/
const enqueueLoad = useCallback(
(filePath: string) => {
if (!shouldLoad(filePath)) return;
if (activeLoads.current.size < MAX_CONCURRENT) {
// Есть свободный слот — загружаем сразу
void loadFile(filePath);
} else {
// Очередь заполнена — добавить в pending (если ещё нет)
if (!pendingQueue.current.includes(filePath)) {
pendingQueue.current.push(filePath);
}
}
},
[shouldLoad, loadFile]
);
// === Preload первых N файлов при mount ===
const PRELOAD_COUNT = 5;
useEffect(() => {
if (!enabled) return;
// Загрузить первые 5 файлов сразу
const toPreload = filePaths.slice(0, PRELOAD_COUNT);
for (const fp of toPreload) {
enqueueLoad(fp);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]); // Намеренно: только при mount (enabled = true)
// === IntersectionObserver ===
useEffect(() => {
if (!enabled) return;
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const filePath = entry.target.getAttribute('data-lazy-file');
if (!filePath) continue;
enqueueLoad(filePath);
// После загрузки — перестать наблюдать (загружается один раз)
// Но мы не можем unobserve сразу (загрузка async) — unobserve когда контент загружен
// Проще: observer продолжает наблюдать, shouldLoad() вернёт false для загруженных
}
},
{
root: scrollContainerRef.current,
// 200% от viewport сверху и снизу — предзагрузка за 2 экрана
rootMargin: '200% 0px 200% 0px',
threshold: 0,
}
);
// Зарегистрировать все уже mounted элементы
for (const [, element] of elementRefs.current) {
observerRef.current.observe(element);
}
return () => {
observerRef.current?.disconnect();
observerRef.current = null;
};
}, [enabled, scrollContainerRef, enqueueLoad]);
// === Register ref callback ===
const registerLazyRef = useCallback((filePath: string) => {
return (element: HTMLElement | null) => {
const observer = observerRef.current;
// Cleanup previous
const prev = elementRefs.current.get(filePath);
if (prev && observer) {
observer.unobserve(prev);
}
elementRefs.current.delete(filePath);
// Register new
if (element) {
element.setAttribute('data-lazy-file', filePath);
elementRefs.current.set(filePath, element);
if (observer) {
observer.observe(element);
}
}
};
}, []);
return { registerLazyRef };
}
```
#### Ключевые аспекты
##### rootMargin "200% 0px 200% 0px"
IntersectionObserver `rootMargin` расширяет область наблюдения за пределы видимого viewport. `200%` означает 2x viewport-высоты сверху и снизу.
**Пример:** Viewport = 800px. rootMargin = 200% -> +1600px сверху и снизу. Файл начнёт загружаться когда его section находится в 1600px от видимой области.
**Почему 200%:** Smooth scroll на Chromium покрывает ~300-400px/сек. При viewport 800px пользователь доскроллит до следующей "зоны" за 2-4 секунды. Загрузка файла через IPC занимает ~50-200ms. 200% даёт достаточный запас для предзагрузки.
##### MAX_CONCURRENT = 3
**Почему 3, а не больше:**
- Electron main process обрабатывает IPC последовательно (single thread)
- Каждый `fetchFileContent` читает файл, парсит diff, возвращает контент
- 3 параллельных запроса = main process занят ~100% на файловых операциях
- Больше 3 = запросы встают в очередь IPC, но main process не ускоряется
- Бонус: оставляет "дышать" main process для других IPC (file watcher, config)
##### Preload первых 5 файлов
При mount (открытие диалога) загружаем первые 5 файлов немедленно (без ожидания IntersectionObserver).
**Почему 5:**
- Viewport обычно вмещает 2-3 file sections
- 5 = 2-3 видимых + 2 "за кадром" для плавного scroll
- Preload занимает ~200-500ms (3 параллельно + 2 в очереди)
**Timing:** Preload запускается одновременно с рендерингом DOM. К моменту первого paint IntersectionObserver ещё не успел сработать, но preload уже отправил запросы.
##### Приоритет в очереди
В текущей реализации очередь FIFO (first-in, first-out). Файлы добавляются в порядке пересечения rootMargin — ближайшие к viewport первыми.
**Возможное улучшение (если потребуется):** Реордеринг очереди при scroll event. Но FIFO достаточно для типичного use case (скролл сверху вниз).
##### Repeated observations
IntersectionObserver продолжает наблюдать все элементы, даже загруженные. Это ОК:
1. Callback вызовется для уже загруженного файла
2. `shouldLoad()` проверяет `fileContentsRef.current[filePath]` -> файл есть -> `return false`
3. `enqueueLoad` ничего не делает
Альтернатива `observer.unobserve()` после загрузки добавляет сложности (нужен callback из store, race conditions). Текущий подход проще и не имеет performance penalty (observer callback -- O(1) проверка).
---
## 3. Модификации существующих файлов
### 3.1. `changeReviewSlice.ts` -- НЕ требует изменений
**Решение: `prefetchFileContents` НЕ НУЖЕН.**
Изначально предполагался convenience-метод `prefetchFileContents` для batch-вызова. Однако при ревью обнаружено:
1. `useLazyFileContent` уже реализует preload первых 5 файлов через `enqueueLoad` в useEffect при mount -- это полностью покрывает потребность в batch preload.
2. `fetchFileContent` уже имеет внутренний guard от дубликатов (строка 262-264 в `changeReviewSlice.ts`):
```typescript
const state = get();
// Skip if already loaded or loading
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
```
3. `useLazyFileContent.enqueueLoad` добавляет поверх store guard ещё `activeLoads` ref-трекинг для throttle -- т.е. тройная защита от дубликатов.
Добавление `prefetchFileContents` в store создаст дублирование с `useLazyFileContent` preload и не даст throttle (все запросы уйдут параллельно). **Оставляем store без изменений.**
---
### 3.2. `ContinuousScrollView.tsx`
#### Интеграция `useLazyFileContent`
**Новые props (добавляются к существующим props фазы 1):**
```typescript
interface ContinuousScrollViewProps {
// ... все существующие props из фазы 1 (см. phase-1 документ) ...
// === НОВЫЕ для фазы 2 ===
/** Имя команды */
teamName: string;
/** Имя участника */
memberName: string | undefined;
/**
* Функция загрузки контента из store.
* Сигнатура: (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
* Из changeReviewSlice.fetchFileContent
*/
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
}
```
**Интеграция в компоненте:**
```typescript
export const ContinuousScrollView = (props: ContinuousScrollViewProps) => {
const {
files,
fileContents,
fileContentsLoading,
teamName,
memberName,
fetchFileContent,
scrollContainerRef,
isProgrammaticScroll,
// ... rest из Phase 1
} = props;
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
// Scroll-spy (фаза 1)
const { registerFileSectionRef } = useVisibleFileSection({
onVisibleFileChange: props.onVisibleFileChange,
scrollContainerRef,
isProgrammaticScroll,
});
// Lazy loading (фаза 2)
const { registerLazyRef } = useLazyFileContent({
teamName,
memberName,
filePaths,
scrollContainerRef,
fileContents,
fileContentsLoading,
fetchFileContent,
enabled: true,
});
// Комбинированный ref callback: регистрация в обоих observers
const combinedRef = useCallback(
(filePath: string) => {
const sectionRef = registerFileSectionRef(filePath);
const lazyRef = registerLazyRef(filePath);
return (element: HTMLElement | null) => {
sectionRef(element);
lazyRef(element);
};
},
[registerFileSectionRef, registerLazyRef]
);
// EditorView registration callback (Phase 1, без изменений)
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
props.editorViewMapRef.current.set(filePath, view);
} else {
props.editorViewMapRef.current.delete(filePath);
}
},
[props.editorViewMapRef]
);
return (
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{files.map((file) => {
const filePath = file.filePath;
const content = fileContents[filePath] ?? null;
const isLoading = fileContentsLoading[filePath] ?? false;
const hasContent = content !== null;
return (
<div
key={filePath}
ref={combinedRef(filePath)} // <-- Комбинированный ref (Phase 1 scroll-spy + Phase 2 lazy)
className="border-b border-border"
>
<FileSectionHeader
file={file}
fileContent={content}
fileDecision={props.fileDecisions[filePath]}
hasEdits={filePath in props.editedContents}
applying={props.applying}
onDiscard={props.onDiscard}
onSave={props.onSave}
/>
{/* Контент ещё не загружен — placeholder */}
{!hasContent && isLoading && (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
{/* Контент ещё не начал грузиться — тоже placeholder */}
{!hasContent && !isLoading && (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
{/* Контент загружен — diff */}
{hasContent && (
<FileSectionDiff
file={file}
fileContent={content}
isLoading={false}
collapseUnchanged={props.collapseUnchanged}
onHunkAccepted={props.onHunkAccepted}
onHunkRejected={props.onHunkRejected}
onFullyViewed={props.onFullyViewed}
onContentChanged={props.onContentChanged}
onEditorViewReady={handleEditorViewReady}
discardCounter={props.discardCounter}
autoViewed={props.autoViewed}
isViewed={props.viewedSet.has(filePath)}
/>
)}
</div>
);
})}
{files.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
No file changes detected
</div>
)}
</div>
);
};
```
**Отличие от Phase 1 ContinuousScrollView:** В Phase 1 `ref={registerFileSectionRef(filePath)}` использовался напрямую. В Phase 2 заменён на `ref={combinedRef(filePath)}`, который вызывает оба ref callback (scroll-spy + lazy). Phase 1 рендерил `FileSectionDiff` / `FileSectionPlaceholder` по условию `isLoading` (ternary). Phase 2 добавляет промежуточное состояние "не начал грузиться" (`!hasContent && !isLoading`).
#### Два placeholder состояния
| Состояние | `hasContent` | `isLoading` | Что показывать |
|-----------|-------------|-------------|----------------|
| Не начал грузиться | false | false | `FileSectionPlaceholder` |
| Грузится | false | true | `FileSectionPlaceholder` |
| Загружен | true | - | `FileSectionDiff` |
| Ошибка загрузки | false | false | `FileSectionPlaceholder` (потом retry) |
**Замечание:** "Не начал грузиться" -- файл ещё не попал в rootMargin IntersectionObserver. Placeholder показывается, но без индикатора загрузки. Визуально идентичен "грузится" -- это ОК, пользователь не различает.
**Ошибка загрузки:** `fetchFileContent` в store ставит `fileContentsLoading[fp] = false` (строка 279) и НЕ записывает в `fileContents` (строка 278 -- catch блок). Результат: `hasContent = false, isLoading = false` -- снова placeholder. IntersectionObserver при следующем пересечении вызовет `enqueueLoad` -- retry произойдёт автоматически (при re-scroll).
**Важно:** `shouldLoad()` в `useLazyFileContent` проверяет `fileContentsRef.current[filePath]` -- после ошибки этого ключа нет, поэтому повторный вызов пройдёт. Также `fileContentsLoadingRef.current[filePath]` будет `false` (store сбросил loading). Таким образом retry корректно сработает.
Если нужен явный retry без scroll: добавить кнопку "Retry" в placeholder. Но для фазы 2 автоматический retry через scroll достаточен.
#### `combinedRef` -- объединение двух ref callbacks
```typescript
const combinedRef = useCallback(
(filePath: string) => {
const sectionRef = registerFileSectionRef(filePath);
const lazyRef = registerLazyRef(filePath);
return (element: HTMLElement | null) => {
sectionRef(element);
lazyRef(element);
};
},
[registerFileSectionRef, registerLazyRef]
);
```
**Зачем:** Оба хука (`useVisibleFileSection`, `useLazyFileContent`) используют IntersectionObserver на одном и том же элементе (file section div). Вместо двух отдельных ref -- один объединённый.
**data attributes:** Каждый callback ставит свой атрибут:
- `registerFileSectionRef` -> `data-file-path`
- `registerLazyRef` -> `data-lazy-file`
Оба атрибута на одном элементе -- ОК, они используются разными observers.
---
### 3.3. `ChangeReviewDialog.tsx`
#### Убрать lazy-load useEffect
**Было** (строки 224-237 текущего файла):
```typescript
// Lazy-load file content when file selected
useEffect(() => {
if (!open || !selectedReviewFilePath) return;
if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return;
void fetchFileContent(teamName, memberName, selectedReviewFilePath);
}, [
open,
selectedReviewFilePath,
teamName,
memberName,
fileContents,
fileContentsLoading,
fetchFileContent,
]);
```
**Стало:** Удалить этот useEffect целиком. Загрузка контента теперь полностью делегирована `useLazyFileContent` внутри `ContinuousScrollView`:
- Preload первых 5 файлов при mount
- Остальные подгружаются по IntersectionObserver
#### Передать новые props в ContinuousScrollView
```tsx
<ContinuousScrollView
// ... props из фазы 1 ...
teamName={teamName}
memberName={memberName}
fetchFileContent={fetchFileContent}
/>
```
**Примечание:** `teamName` берётся из props `ChangeReviewDialogProps`, `memberName` оттуда же (optional prop). `fetchFileContent` берётся из `useStore()` (строка 77 текущего файла).
---
## 4. Throttle реализация: детали
### Структура данных
```
┌─────────────────┐
│ activeLoads │ Set<string> -- max 3 элемента
│ (in-flight) │
├─────────────────┤
│ pendingQueue │ string[] -- FIFO очередь
│ (waiting) │
└─────────────────┘
```
### Жизненный цикл загрузки
```
1. IntersectionObserver fires -> enqueueLoad(filePath)
2. shouldLoad() checks:
- fileContentsRef.current[fp]? -> skip (already loaded)
- fileContentsLoadingRef.current[fp]? -> skip (store knows about it)
- activeLoads.has(fp)? -> skip (our local tracking)
3. activeLoads.size < MAX_CONCURRENT?
-> YES: loadFile(fp) immediately
-> NO: pendingQueue.push(fp)
4. loadFile(fp):
- activeLoads.add(fp)
- await fetchFileContent(teamName, memberName, fp)
- activeLoads.delete(fp)
- processQueue() <-- проверить, есть ли ожидающие
5. processQueue():
- while (activeLoads.size < MAX_CONCURRENT && pendingQueue.length > 0)
- shift from queue, check shouldLoad, loadFile
```
### Диаграмма состояний
```
┌──────────┐
IO trigger ──> │ enqueue │
└────┬─────┘
┌────────v────────┐
│ slots available? │
└──┬─────────┬────┘
│ YES │ NO
┌──────v──┐ ┌──v───────┐
│ loadFile │ │ add to │
│ (async) │ │ pending │
└──────┬───┘ │ queue │
│ └──────────┘
┌──────v───┐ ^
│ complete │ │
└──────┬───┘ │
│ │
┌──────v────────┐ │
│ processQueue ├────┘
└───────────────┘
```
### Взаимодействие throttle с store guard
`fetchFileContent` в store (строки 261-282) имеет собственный guard:
```typescript
const state = get();
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
```
`useLazyFileContent` добавляет `activeLoads` ref поверх. Зачем два уровня защиты:
1. **Store guard** предотвращает повторный IPC-вызов для загружаемого/загруженного файла -- но работает через `get()` (синхронный snapshot). Между двумя вызовами `fetchFileContent` в одном event loop tick `fileContentsLoading` ещё не обновлён (Zustand batch).
2. **`activeLoads` ref** покрывает этот micro-timing gap -- `activeLoads.add(fp)` происходит синхронно ДО await, а `shouldLoad()` проверяет ref мгновенно.
Таким образом:
- Store guard: macro-level (между renders)
- activeLoads ref: micro-level (между тиками в одном frame)
- Оба нужны для надёжности
### Приоритет загрузки
**Текущий подход:** FIFO. IntersectionObserver вызывает callbacks в порядке пересечения rootMargin. Для типичного скролла сверху вниз это означает: верхние файлы раньше нижних.
**Потенциальное улучшение (не для фазы 2):**
Если пользователь быстро скроллит вниз (skip middle files), можно реализовать priority queue:
```typescript
// Вместо string[] использовать priority queue:
interface PendingItem {
filePath: string;
priority: number; // расстояние от viewport center
}
// При каждом scroll event -- пересчитать priority для pending items
// Ближайшие к viewport -- выше приоритет
```
Но это оверинжиниринг для фазы 2. FIFO достаточно:
- IntersectionObserver с rootMargin 200% ловит файлы рано
- 3 параллельных загрузки покрывают типичную скорость скролла
- Даже при быстром скролле -- placeholder на 100-200ms, потом контент
### Refs для stale closure prevention
```typescript
const fileContentsRef = useRef(fileContents);
const fileContentsLoadingRef = useRef(fileContentsLoading);
useEffect(() => {
fileContentsRef.current = fileContents;
fileContentsLoadingRef.current = fileContentsLoading;
}, [fileContents, fileContentsLoading]);
```
**Зачем:** `shouldLoad()` замыкает `fileContentsRef` и `fileContentsLoadingRef`. Без ref-трюка callback IntersectionObserver "видит" stale `fileContents` из момента создания observer.
**Альтернатива:** Пересоздавать IntersectionObserver при каждом изменении `fileContents`. Но это = disconnect + observe all elements заново = bad performance.
### Edge case: диалог закрыт во время загрузки
`fetchFileContent` -- async. Если пользователь закроет диалог пока идёт загрузка:
1. `ContinuousScrollView` unmounts -> `useLazyFileContent` cleanup
2. IntersectionObserver disconnect
3. Но `fetchFileContent` всё ещё in-flight в store
4. Store обновит `fileContents` / `fileContentsLoading` -- ОК, store не зависит от компонента
5. `clearChangeReview()` вызывается в useEffect cleanup `ChangeReviewDialog` (строка 189) -- сбросит all state
**Вывод:** Нет утечек и race conditions. Store корректно очищается.
### Edge case: файл уже загружен при re-open
При повторном открытии того же review:
1. `clearChangeReview()` сбрасывает `fileContents = {}` (строка 160)
2. `fetchAgentChanges()` / `fetchTaskChanges()` загружает свежий changeSet
3. `useLazyFileContent` preload + observer начинают с нуля
4. Все файлы загружаются заново (свежие данные)
### Edge case: circular dependency loadFile <-> processQueue
`loadFile` вызывает `processQueue` в finally. `processQueue` вызывает `loadFile`. Потенциальный бесконечный цикл?
Нет -- `loadFile` начинается с `if (!shouldLoad(filePath)) return;`, а `activeLoads.add(fp)` происходит синхронно. `processQueue` берёт из очереди (shift), проверяет `shouldLoad`, и вызывает `loadFile` через `void` (fire-and-forget). Каждый `loadFile` -- это новый async task, не рекурсия в call stack. Queue конечна (max = количество файлов). Цикла нет.
---
## 5. Консистентность с Phase 3
Phase 3 (Navigation) зависит от Phase 2 в следующих аспектах:
1. **EditorView Map** -- Phase 2 использует `editorViewMapRef` из Phase 1. Phase 3 использует тот же Map через `ContinuousNavigationOptions.editorViewRefs`. Важно: Phase 3 `editorViewRefs` это `Map<string, EditorView>` (value из `.current`), а Phase 2 работает с `MutableRefObject<Map>`. Нет конфликта -- Phase 3 читает из `.current` напрямую.
2. **Lazy loading + cross-file navigation** -- когда Phase 3 `goToNextFile()` делает `scrollToFile(nextFilePath)`, файл может быть ещё не загружен. IntersectionObserver с rootMargin 200% должен сработать до того как scroll доедет до файла. Если файл далеко -- placeholder покажется на ~100-200ms, потом контент подгрузится. Это приемлемый UX.
3. **activeFilePath** -- Phase 2 НЕ управляет `activeFilePath`. Scroll-spy из Phase 1 (`useVisibleFileSection`) определяет activeFilePath. Phase 2 только загружает контент. Phase 3 использует activeFilePath для определения "текущего" файла в навигации.
---
## 6. Проверка
### Функциональная проверка
- [ ] Открыть review с 10+ файлами
- [ ] Первые 5 файлов показывают контент в первые ~500ms
- [ ] Файлы 6-10 показывают placeholder, потом контент при подскролле
- [ ] Scroll вниз -- файлы подгружаются бесшовно (placeholder -> diff)
- [ ] Scroll быстро вниз -- плейсхолдеры видны на ~200ms, потом контент
- [ ] Scroll обратно вверх -- уже загруженные файлы показывают diff мгновенно
- [ ] Кликнуть на файл 15 в tree -> smooth scroll + контент загружается
### Throttle проверка
- [ ] Открыть DevTools Network tab (или console log)
- [ ] Убедиться: максимум 3 одновременных IPC-вызова `getFileContent`
- [ ] Остальные ждут в очереди и выполняются последовательно по 3
### Edge cases
- [ ] 0 файлов -- нет ошибок в console
- [ ] 1 файл -- загружается мгновенно (preload)
- [ ] Файл с ошибкой загрузки (main process throw) -- placeholder остаётся, scroll retry работает
- [ ] Закрыть диалог во время загрузки -- нет ошибок, store очищен
- [ ] Переоткрыть диалог -- все файлы загружаются заново
### Performance
- [ ] 30 файлов -- UI не зависает при открытии
- [ ] Main process responsive (file watcher работает) во время загрузки 30 файлов
- [ ] Memory: placeholder -> diff transition не утекает (EditorView create/destroy)
- [ ] Scroll FPS > 30 при 20+ загруженных CodeMirror editors

View file

@ -0,0 +1,996 @@
# Phase 3: Click-to-Scroll + Навигация
## Обзор
Фаза 3 адаптирует навигацию для continuous scroll mode. В текущей реализации (file-at-a-time) каждый файл показывается отдельно: `goToNextFile()` вызывает `onSelectFile()`, который уничтожает текущий EditorView и создаёт новый. В continuous mode все файлы видны одновременно в одном scroll container, поэтому навигация переключается на программный scroll.
**Ключевые изменения:**
- Клик по файлу в sidebar = smooth scroll к секции файла (вместо уничтожения/создания editor)
- Keyboard shortcuts (Alt+ArrowDown/Up) = scroll к следующему/предыдущему файлу
- Cross-file hunk navigation: при достижении последнего hunk файла -- автоматический scroll к следующему файлу
- `useDiffNavigation` работает с `Map<string, EditorView>` вместо одного `editorViewRef`
- Публичный интерфейс `DiffNavigationState` НЕ меняется -- изменяется только внутренняя реализация
**Зависимости:** Phase 1 (ContinuousScrollView, useVisibleFileSection, useContinuousScrollNav) и Phase 2 (lazy loading, EditorView Map из Phase 1).
---
## Модификации
### 1. useDiffNavigation.ts -- полная переработка для continuous mode
**Файл:** `src/renderer/hooks/useDiffNavigation.ts`
#### Текущая сигнатура (без изменений)
```typescript
import { useCallback, useEffect, useRef, useState } from 'react';
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary } from '@shared/types/review';
// --- Return interface НЕ МЕНЯЕТСЯ ---
interface DiffNavigationState {
currentHunkIndex: number;
totalHunks: number;
goToNextHunk: () => void;
goToPrevHunk: () => void;
goToNextFile: () => void;
goToPrevFile: () => void;
goToHunk: (index: number) => void;
acceptCurrentHunk: () => void;
rejectCurrentHunk: () => void;
showShortcutsHelp: boolean;
setShowShortcutsHelp: (show: boolean) => void;
}
```
#### Новый optional параметр continuousOptions
```typescript
// --- НОВАЯ сигнатура (расширение, backward compatible) ---
export function useDiffNavigation(
files: FileChangeSummary[],
selectedFilePath: string | null,
onSelectFile: (path: string) => void,
editorViewRef: React.RefObject<EditorView | null>,
isDialogOpen: boolean,
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
onClose?: () => void,
onSaveFile?: () => void,
continuousOptions?: ContinuousNavigationOptions // <-- НОВЫЙ 10-й параметр
): DiffNavigationState;
```
**Важно:** НЕ используем overloads. Один вариант сигнатуры с optional 10-м параметром. Overloads здесь избыточны -- `continuousOptions` опционален, TypeScript корректно проверяет типы без overload.
#### Новый тип ContinuousNavigationOptions
```typescript
interface ContinuousNavigationOptions {
/**
* Map всех EditorView по filePath. Заполняется в ContinuousScrollView.
* Это НЕ ref -- передаётся сам Map (через .current снаружи).
* Передаётся как value, но мутируется извне (Map reference стабильна).
*/
editorViewRefs: Map<string, EditorView>;
/**
* Текущий видимый файл из scroll-spy (Phase 1 useVisibleFileSection).
* НЕ selectedFilePath -- это activeFilePath.
* Обновляется при скролле.
*/
activeFilePath: string | null;
/**
* Программный scroll к секции файла из useContinuousScrollNav (Phase 1).
* Вызывает scrollIntoView + подавление scroll-spy.
*/
scrollToFile: (filePath: string) => void;
/** Флаг continuous mode -- определяет какую логику использовать. */
enabled: boolean;
}
```
**Дизайн-решение:** Вместо создания отдельного хука (useContinuousDiffNavigation), расширяем существующий через optional 10-й параметр `continuousOptions`. Это позволяет:
1. Не дублировать keyboard handler логику
2. Постепенно мигрировать: `ChangeReviewDialog` просто передаёт `continuousOptions` когда continuous mode включён
3. Сохранить обратную совместимость -- без `continuousOptions` хук работает как раньше
#### Внутренняя реализация -- helper: getActiveEditorView()
```typescript
/**
* Определяет "активный" EditorView для навигации.
*
* Приоритет:
* 1. Focused editor -- если какой-то CM editor сейчас имеет фокус
* 2. activeFilePath editor -- editor файла, определённого scroll-spy как видимый
* 3. Fallback: первый editor в Map
*
* В legacy mode: просто возвращает editorViewRef.current.
*/
function getActiveEditorView(
editorViewRef: React.RefObject<EditorView | null>,
continuousOptions?: ContinuousNavigationOptions
): EditorView | null {
// Legacy mode
if (!continuousOptions?.enabled) {
return editorViewRef.current;
}
const { editorViewRefs, activeFilePath } = continuousOptions;
// 1. Focused editor -- используем view.hasFocus (CM API)
for (const [, view] of editorViewRefs) {
if (view.hasFocus) return view;
}
// 2. activeFilePath editor
if (activeFilePath) {
const view = editorViewRefs.get(activeFilePath);
if (view) return view;
}
// 3. Fallback: первый editor
const firstEntry = editorViewRefs.values().next();
return firstEntry.done ? null : firstEntry.value;
}
```
**ИСПРАВЛЕНИЕ:** Оригинальный вариант использовал `document.activeElement.closest('.cm-editor')` + сравнение с `view.dom`. Это ненадёжно -- CM editor может содержать nested elements, и `closest` не всегда корректно разрешает до внешнего `.cm-editor`. Используем встроенный `view.hasFocus` -- это официальный CM API для проверки фокуса.
#### Внутренняя реализация -- helper: getActiveFilePath()
```typescript
/**
* Определяет путь активного файла для контекста навигации.
*
* В continuous mode: activeFilePath из scroll-spy.
* В legacy mode: selectedFilePath.
*/
function getActiveFilePath(
selectedFilePath: string | null,
continuousOptions?: ContinuousNavigationOptions
): string | null {
if (continuousOptions?.enabled && continuousOptions.activeFilePath) {
return continuousOptions.activeFilePath;
}
return selectedFilePath;
}
```
#### Внутренняя реализация -- helper: getFilePathForView()
```typescript
/**
* Находит filePath для данного EditorView в Map.
* Нужно для определения "в каком файле мы сейчас" при focused editor.
*/
function getFilePathForView(
view: EditorView,
editorViewRefs: Map<string, EditorView>
): string | null {
for (const [filePath, v] of editorViewRefs) {
if (v === view) return filePath;
}
return null;
}
```
#### Внутренняя реализация -- helpers: isLastChunkInFile() / isFirstChunkInFile()
```typescript
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
```
**ВАЖНО: API `getChunks`.**
`getChunks` реэкспортируется из `@codemirror/merge`. Сигнатура:
```typescript
function getChunks(state: EditorState): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null;
```
Где `Chunk` имеет поля:
- `fromA`, `toA` -- диапазон в original document (side A)
- `fromB`, `toB` -- диапазон в modified document (side B)
- `changes` -- внутренние изменения
В `unifiedMergeView` (которую мы используем) side всегда `"b"`. Позиции курсора соответствуют side B.
```typescript
/**
* Проверяет, находится ли курсор на последнем chunk файла.
* Нужно для cross-file navigation: если на последнем chunk -- scroll к следующему файлу.
*
* Алгоритм:
* 1. Получаем chunks из CM state через getChunks()
* 2. Определяем текущую позицию курсора (view.state.selection.main.head)
* 3. Проверяем: курсор находится в или после последнего chunk
*
* ВАЖНО: goToNextChunk -- это StateCommand. Возвращает boolean:
* - true: перешёл к следующему chunk (dispatch вызван)
* - false: нет chunks в документе ИЛИ только один chunk и курсор уже в нём
*
* goToNextChunk возвращает false НЕ когда "нет больше chunks после текущего",
* а когда chunks.length === 0 или chunks.length === 1 && cursor уже в нём.
* При >1 chunks goToNextChunk ВСЕГДА возвращает true (циклическая навигация!).
*
* Поэтому мы НЕ можем полагаться на return value goToNextChunk для определения
* "последний ли это chunk". Нужна отдельная проверка через getChunks().
*/
function isLastChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const chunks = result.chunks;
const lastChunk = chunks[chunks.length - 1];
// Курсор в пределах последнего chunk или после него
// fromB -- начало chunk в modified document
// toB -- конец chunk (1 past end of last line)
return cursorPos >= lastChunk.fromB;
}
/**
* Аналогично для первого chunk.
*/
function isFirstChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const firstChunk = result.chunks[0];
// Курсор в пределах первого chunk или перед ним
return cursorPos <= firstChunk.toB;
}
```
**ИСПРАВЛЕНИЕ:** Уточнено поведение `goToNextChunk` -- это **циклическая** навигация (moveByChunk берёт `chunks[(pos + offset) % chunks.length]`). При >1 chunks всегда возвращает `true`. Поэтому:
- `const moved = goToNextChunk(view); if (!moved)` -- значит 0 или 1 chunk, а НЕ "последний chunk"
- Для определения "последний chunk" нужен `isLastChunkInFile()`
- В `goToNextHunk` правильная логика: **сначала** проверить `isLastChunkInFile`, **потом** решить -- переходить к следующему файлу или вызвать `goToNextChunk`
#### Изменения в goToNextFile()
```typescript
const goToNextFile = useCallback(() => {
if (files.length === 0) return;
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
const nextFilePath = files[nextIdx].filePath;
if (continuousOptions?.enabled) {
// Continuous mode: smooth scroll к следующему файлу
continuousOptions.scrollToFile(nextFilePath);
// НЕ вызываем onSelectFile -- scroll-spy обновит activeFilePath сам
} else {
// Legacy mode: переключение файла
onSelectFile(nextFilePath);
}
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
```
**Важно:** В continuous mode `goToNextFile()` НЕ вызывает `onSelectFile()`. Вместо этого:
1. Вызывается `scrollToFile(nextFilePath)` из `useContinuousScrollNav`
2. `scrollToFile` выполняет `element.scrollIntoView({ behavior: 'smooth' })`
3. `isProgrammaticScroll` подавляет scroll-spy
4. `waitForScrollEnd()` ждёт стабилизации (timeout 500ms, из `navigation/utils.ts`)
5. `isProgrammaticScroll = false`, scroll-spy обнаруживает новый видимый файл
6. `activeFilePath` обновляется через `onVisibleFileChange` callback
#### Изменения в goToPrevFile()
```typescript
const goToPrevFile = useCallback(() => {
if (files.length === 0) return;
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
const prevFilePath = files[prevIdx].filePath;
if (continuousOptions?.enabled) {
continuousOptions.scrollToFile(prevFilePath);
} else {
onSelectFile(prevFilePath);
}
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
```
#### Изменения в goToNextHunk()
```typescript
const goToNextHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (!view) return;
if (continuousOptions?.enabled) {
// Cross-file hunk navigation
if (isLastChunkInFile(view)) {
// Уже на последнем hunk файла -- переход к следующему файлу
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx < files.length - 1) {
const nextFilePath = files[currentIdx + 1].filePath;
continuousOptions.scrollToFile(nextFilePath);
// После scroll -- перейти к первому hunk нового файла
// Используем requestAnimationFrame чтобы дождаться scroll + render
requestAnimationFrame(() => {
const nextView = continuousOptions.editorViewRefs.get(nextFilePath);
if (nextView) {
// Перемещаем курсор в начало файла, потом goToNextChunk
nextView.dispatch({
selection: { anchor: 0 },
});
goToNextChunk(nextView);
}
});
}
// Если это последний файл -- no-op (конец списка)
} else {
// Не последний chunk -- обычная навигация внутри файла
goToNextChunk(view);
}
} else {
// Legacy mode: навигация внутри текущего файла
goToNextChunk(view);
}
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
}, [editorViewRef, totalHunks, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
```
**ИСПРАВЛЕНИЕ (критическое):** Оригинальный вариант вызывал `goToNextChunk(view)` ПЕРЕД проверкой `isLastChunkInFile`. Проблема: `goToNextChunk` -- циклическая навигация. Если курсор на последнем chunk, `goToNextChunk` перейдёт к ПЕРВОМУ chunk (wrap-around), а потом `isLastChunkInFile` вернёт `false`. Результат: cross-file navigation никогда не сработает.
Правильная логика: **сначала** `isLastChunkInFile()`, **потом** решение -- переход к следующему файлу ИЛИ `goToNextChunk()` для навигации внутри файла.
#### Изменения в goToPrevHunk()
```typescript
const goToPrevHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (!view) return;
if (continuousOptions?.enabled) {
if (isFirstChunkInFile(view)) {
// Первый hunk файла -- переход к предыдущему файлу
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx > 0) {
const prevFilePath = files[currentIdx - 1].filePath;
continuousOptions.scrollToFile(prevFilePath);
requestAnimationFrame(() => {
const prevView = continuousOptions.editorViewRefs.get(prevFilePath);
if (prevView) {
// Перемещаем курсор в конец файла, потом goToPreviousChunk
const docLength = prevView.state.doc.length;
prevView.dispatch({
selection: { anchor: docLength },
});
goToPreviousChunk(prevView);
}
});
}
} else {
// Не первый chunk -- обычная навигация назад
goToPreviousChunk(view);
}
} else {
goToPreviousChunk(view);
}
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
}, [editorViewRef, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
```
#### Изменения в acceptCurrentHunk()
```typescript
const acceptCurrentHunk = useCallback(() => {
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
if (activePath && onHunkAccepted) {
onHunkAccepted(activePath, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkAccepted, continuousOptions]);
```
#### Изменения в rejectCurrentHunk()
```typescript
const rejectCurrentHunk = useCallback(() => {
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
if (activePath && onHunkRejected) {
onHunkRejected(activePath, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkRejected, continuousOptions]);
```
#### Keyboard handler -- адаптация
**ВАЖНО: Конфликт с useContinuousScrollNav (Phase 1).**
В Phase 1 `useContinuousScrollNav` регистрирует keyboard listener для Alt+ArrowDown/Up. В Phase 3 `useDiffNavigation` тоже хочет обрабатывать эти клавиши. Два обработчика на одно событие -- конфликт.
**Решение:** Удалить keyboard handler для Alt+Arrow из `useContinuousScrollNav` (Phase 1). Вся keyboard обработка навигации живёт в `useDiffNavigation`. Причина: `useDiffNavigation` уже обрабатывает все shortcuts и имеет доступ к `continuousOptions.scrollToFile`. Дублирование нарушает single-responsibility.
```typescript
useEffect(() => {
if (!isDialogOpen) return;
const handler = (event: KeyboardEvent) => {
// Skip if CM keymap already handled
if (event.defaultPrevented) return;
// Skip inputs/textareas
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
const isMeta = event.metaKey || event.ctrlKey;
// Alt+J -> next change (работает в обоих режимах)
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
goToNextHunk();
return;
}
// Alt+K -> prev change (НОВЫЙ shortcut)
if (event.altKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
goToPrevHunk();
return;
}
// Alt+ArrowDown -> next file (scroll в continuous mode, onSelectFile в legacy)
if (event.altKey && event.key === 'ArrowDown') {
event.preventDefault();
goToNextFile();
return;
}
// Alt+ArrowUp -> prev file
if (event.altKey && event.key === 'ArrowUp') {
event.preventDefault();
goToPrevFile();
return;
}
// Cmd+Enter -> save active file
if (isMeta && event.key === 'Enter') {
event.preventDefault();
onSaveFileRef.current?.();
return;
}
// Cmd+Y -> accept chunk + next (на active editor)
if (isMeta && event.key.toLowerCase() === 'y') {
event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (view) {
acceptChunk(view);
requestAnimationFrame(() => {
if (continuousOptions?.enabled && isLastChunkInFile(view)) {
// Cross-file: scroll к следующему файлу после accept последнего chunk
goToNextFile();
} else {
goToNextChunk(view);
}
});
}
return;
}
// ? -> toggle shortcuts help
if (event.key === '?' && !isMeta && !event.altKey) {
event.preventDefault();
setShowShortcutsHelp((prev) => !prev);
return;
}
// Escape handling
if (event.key === 'Escape') {
if (showShortcutsHelp) {
event.preventDefault();
setShowShortcutsHelp(false);
}
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [
isDialogOpen,
showShortcutsHelp,
editorViewRef,
continuousOptions,
goToNextFile,
goToPrevFile,
goToNextHunk,
goToPrevHunk,
]);
```
**ИСПРАВЛЕНИЕ:** Alt+J/K теперь вызывают `goToNextHunk()` / `goToPrevHunk()` (callback из хука), а не напрямую `goToNextChunk(view)`. Это обеспечивает cross-file навигацию в continuous mode. В оригинале Alt+J вызывал `goToNextChunk` напрямую -- cross-file не работал бы.
#### Полная таблица keyboard shortcuts
| Shortcut | Action | Legacy mode | Continuous mode |
|----------|--------|:-----------:|:---------------:|
| `Alt+J` | Next change (hunk) | goToNextHunk (внутри файла) | goToNextHunk (cross-file) |
| `Alt+K` | Prev change (hunk) | goToPrevHunk (внутри файла) | goToPrevHunk (cross-file) |
| `Alt+ArrowDown` | Next file | goToNextFile (onSelectFile) | goToNextFile (scrollToFile) |
| `Alt+ArrowUp` | Prev file | goToPrevFile (onSelectFile) | goToPrevFile (scrollToFile) |
| `Cmd+Y` | Accept change + next | acceptChunk + goToNextChunk | acceptChunk + cross-file navigation |
| `Cmd+N` | Reject change + next | rejectChunk + goToNextChunk (IPC) | rejectChunk + cross-file navigation (IPC) |
| `Cmd+Enter` | Save file | save selectedFilePath | save activeFilePath |
| `?` | Toggle shortcuts help | toggle | toggle |
| `Escape` | Close help / dialog | close help или dialog | close help или dialog |
| `Ctrl+Alt+ArrowDown` | Next change (CM keymap) | goToNextChunk (built-in) | goToNextChunk (built-in per-editor) |
| `Ctrl+Alt+ArrowUp` | Prev change (CM keymap) | goToPreviousChunk (built-in) | goToPreviousChunk (built-in per-editor) |
**Примечание:** Ctrl+Alt+Arrow -- это встроенный CM keymap, не наш. Он работает per-editor (без cross-file). Это ОК -- пользователи, привыкшие к CM keymap, получают привычное поведение внутри файла. Alt+J/K -- наш shortcut с cross-file.
---
### 2. useContinuousScrollNav.ts -- изменения для Phase 3
**Файл:** `src/renderer/hooks/useContinuousScrollNav.ts`
Phase 1 реализует:
- `scrollToFile(filePath)` -- программный scroll к секции файла
- `isProgrammaticScroll` ref -- подавление scroll-spy при программном scroll
Phase 3 изменения:
1. **Убрать keyboard handler (Alt+Arrow) из useContinuousScrollNav.** Keyboard навигация теперь полностью в `useDiffNavigation`. Это устраняет конфликт двойной регистрации event listener.
2. **Убрать `activeFilePath` и `filePaths` из options** -- они больше не нужны хуку (keyboard handler убран). Упрощённый interface:
```typescript
interface UseContinuousScrollNavOptions {
/** Ref на scroll container */
scrollContainerRef: RefObject<HTMLElement>;
/** Диалог открыт (для cleanup) */
isOpen: boolean;
}
interface UseContinuousScrollNavReturn {
/** Scroll к файлу по filePath (smooth) */
scrollToFile: (filePath: string) => void;
/** Ref-flag: true пока идёт programmatic scroll */
isProgrammaticScroll: RefObject<boolean>;
}
```
3. **scrollToFile -- без `setActiveFilePath`:**
```typescript
const scrollToFile = useCallback(
(filePath: string) => {
const container = scrollContainerRef.current;
if (!container) return;
const section = container.querySelector<HTMLElement>(
`[data-file-path="${CSS.escape(filePath)}"]`
);
if (!section) return;
// Suppress scroll-spy during programmatic scroll
isProgrammaticScroll.current = true;
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Дождаться стабилизации scroll, потом разрешить scroll-spy
void waitForScrollEnd(container, 500).then(() => {
isProgrammaticScroll.current = false;
// scroll-spy сам обнаружит новый видимый файл и обновит activeFilePath
});
},
[scrollContainerRef]
);
```
**ИСПРАВЛЕНИЕ:** Оригинальный вариант вызывал `setActiveFilePath(filePath)` внутри `scrollToFile`. Проблема: `setActiveFilePath` не является частью hook state `useContinuousScrollNav` -- он живёт в parent (`ChangeReviewDialog` как `useState`). Передавать setter внутрь нарушает separation of concerns. Вместо этого: после `isProgrammaticScroll = false` scroll-spy (`useVisibleFileSection`) сам обнаружит видимый файл и вызовет `onVisibleFileChange`, который обновит `activeFilePath` в parent. Задержка ~100ms (debounce scroll-spy), но это ОК -- UI уже показывает правильный файл.
**waitForScrollEnd signature** (из `src/renderer/hooks/navigation/utils.ts`):
```typescript
function waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>
```
- `container` -- scroll container DOM element
- `timeoutMs` -- fallback timeout (default 400ms, мы передаём 500ms для запаса smooth scroll)
- Возвращает Promise, resolve когда scrollTop стабилизировался (3 consecutive frames без изменений)
---
### 3. ChangeReviewDialog.tsx -- интеграция
**Файл:** `src/renderer/components/team/review/ChangeReviewDialog.tsx`
#### Новый state: continuous mode toggle
```typescript
// Новый state для continuous mode (Phase 3)
const [isContinuousMode, setIsContinuousMode] = useState(false);
```
#### EditorView Map для continuous mode
```typescript
// Map всех EditorViews в continuous mode
// Заполняется через callback из ContinuousScrollView (Phase 1)
// Уже существует из Phase 1: editorViewMapRef
const editorViewMapRef = useRef(new Map<string, EditorView>());
```
#### Получение данных из useContinuousScrollNav
```typescript
// useContinuousScrollNav теперь принимает options object (Phase 1 interface,
// упрощённый в Phase 3):
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
isOpen: open,
});
```
#### Передача continuousOptions в useDiffNavigation
```typescript
// Формируем continuousOptions только когда continuous mode включён.
//
// ВАЖНО: НЕ оборачивать editorViewMapRef.current в useMemo deps --
// .current не реактивен. Map reference стабильна (useRef), мутируется извне.
// useDiffNavigation обращается к Map.get() в момент вызова (не при создании options).
// activeFilePath и scrollToFile -- реактивны, они меняются.
const continuousOptions = useMemo(
(): ContinuousNavigationOptions | undefined => {
if (!isContinuousMode) return undefined;
return {
editorViewRefs: editorViewMapRef.current,
activeFilePath: continuousScrollNav.activeFilePath,
scrollToFile: continuousScrollNav.scrollToFile,
enabled: true,
};
},
[isContinuousMode, continuousScrollNav.activeFilePath, continuousScrollNav.scrollToFile]
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
selectedReviewFilePath,
handleSelectFile,
editorViewRef, // Legacy ref (используется если continuousOptions undefined)
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
() => onOpenChange(false),
handleSaveCurrentFile,
continuousOptions // <-- НОВЫЙ 10-й параметр
);
```
**Примечание:** `continuousScrollNav.activeFilePath` -- это state из `useContinuousScrollNav` или state из parent (`ChangeReviewDialog`). В Phase 1 `activeFilePath` управляется через `onVisibleFileChange` callback. Уточнение: `activeFilePath` -- это `useState` в `ChangeReviewDialog`, обновляется через `setActiveFilePath` callback, переданный в `ContinuousScrollView.onVisibleFileChange`.
#### handleSelectFile адаптация
```typescript
const handleSelectFile = useCallback(
(filePath: string | null) => {
if (isContinuousMode && filePath) {
// В continuous mode: scroll к секции вместо переключения
scrollToFile(filePath);
// НЕ вызываем selectReviewFile -- sidebar highlight управляется через activeFilePath
return;
}
// Legacy mode: старая логика
const view = editorViewRef.current;
if (view && selectedReviewFilePath) {
editorStateCache.current.set(selectedReviewFilePath, view.state);
}
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
selectReviewFile(filePath);
},
[isContinuousMode, selectedReviewFilePath, selectReviewFile, scrollToFile]
);
```
#### handleSaveCurrentFile адаптация
```typescript
const handleSaveCurrentFile = useCallback(() => {
// В continuous mode сохраняем activeFilePath (видимый), не selectedReviewFilePath
const targetFile = isContinuousMode
? activeFilePath // из useState в ChangeReviewDialog
: selectedReviewFilePath;
if (targetFile) void saveEditedFile(targetFile);
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, saveEditedFile]);
```
#### handleAcceptAll / handleRejectAll адаптация
```typescript
const handleAcceptAll = useCallback(() => {
if (isContinuousMode) {
// В continuous mode: accept all на ACTIVE file's editor
if (activeFilePath) {
const view = editorViewMapRef.current.get(activeFilePath);
if (view) acceptAllChunks(view);
acceptAllFile(activeFilePath);
}
} else {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, acceptAllFile]);
```
#### Sidebar: подсветка activeFilePath в continuous mode
```typescript
{/* File tree -- selectedFilePath меняется на activeFilePath в continuous mode */}
<ReviewFileTree
files={activeChangeSet.files}
selectedFilePath={
isContinuousMode
? activeFilePath // из scroll-spy
: selectedReviewFilePath // из store
}
onSelectFile={handleSelectFile}
viewedSet={viewedSet}
onMarkViewed={markViewed}
onUnmarkViewed={unmarkViewed}
/>
```
**Примечание:** Phase 1 добавила `activeFilePath` prop в `ReviewFileTree` для мягкой подсветки (border-l). В continuous mode мы просто передаём `activeFilePath` как `selectedFilePath` -- полноценная подсветка (`bg-blue-500/20`). Это проще и визуально понятнее: один выделенный файл в tree.
#### Cmd+N IPC listener адаптация
```typescript
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = isContinuousMode
? getActiveEditorView(editorViewRef, continuousOptions)
: editorViewRef.current;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => {
if (isContinuousMode && isLastChunkInFile(view)) {
// Cross-file: scroll к следующему файлу
diffNav.goToNextFile();
} else {
goToNextChunk(view);
}
});
}
});
return cleanup ?? undefined;
}, [open, isContinuousMode, continuousOptions, diffNav]);
```
**Примечание:** `getActiveEditorView` и `isLastChunkInFile` -- helper функции из `useDiffNavigation`. Для использования в `ChangeReviewDialog` нужно:
- Либо экспортировать helpers из `useDiffNavigation.ts`
- Либо дублировать логику (нежелательно)
- Либо добавить метод в return interface: `diffNav.getActiveView()` / `diffNav.isOnLastChunk()`
**Рекомендация:** Экспортировать `getActiveEditorView` и `isLastChunkInFile` как named exports из `useDiffNavigation.ts`. Они чистые функции, не зависят от hook state.
---
### 4. KeyboardShortcutsHelp.tsx -- новые shortcuts
**Файл:** `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx`
Добавляются новые shortcuts. Текущий массив `shortcuts` (строки 10-18):
```typescript
const shortcuts = [
{ keys: ['\u2325+J'], action: 'Next change' },
{ keys: ['\u2325+K'], action: 'Previous change' }, // НОВЫЙ
{ keys: ['\u2325+\u2193'], action: 'Next file' }, // НОВЫЙ
{ keys: ['\u2325+\u2191'], action: 'Previous file' }, // НОВЫЙ
{ keys: ['\u2318+Y'], action: 'Accept change' },
{ keys: ['\u2318+N'], action: 'Reject change' },
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
{ keys: ['\u2318+Z'], action: 'Undo' },
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
{ keys: ['?'], action: 'Toggle this help' }, // НОВЫЙ
{ keys: ['Esc'], action: 'Close dialog' },
];
```
---
## Return Interface
```typescript
interface DiffNavigationState {
currentHunkIndex: number;
totalHunks: number;
goToNextHunk: () => void;
goToPrevHunk: () => void;
goToNextFile: () => void;
goToPrevFile: () => void;
goToHunk: (index: number) => void;
acceptCurrentHunk: () => void;
rejectCurrentHunk: () => void;
showShortcutsHelp: boolean;
setShowShortcutsHelp: (show: boolean) => void;
}
```
Интерфейс **НЕ меняется**. Все вызовы `diffNav.goToNextFile()`, `diffNav.goToNextHunk()` и т.д. в ChangeReviewDialog продолжают работать без изменений. Внутренняя реализация каждого метода проверяет `continuousOptions?.enabled` и выбирает стратегию.
---
## Edge-cases
### 1. scrollToFile + scroll-spy подавление
**Проблема:** При `scrollToFile(nextFile)` scroll-spy может обнаружить промежуточные файлы (мелькание activeFilePath).
**Решение:** `isProgrammaticScroll` ref в `useContinuousScrollNav`. При программном scroll:
1. `isProgrammaticScroll.current = true` устанавливается ДО `scrollIntoView`
2. Scroll-spy IntersectionObserver проверяет `isProgrammaticScroll.current` в `updateTopmostVisible()` и ИГНОРИРУЕТ обновления
3. После стабилизации scroll (через `waitForScrollEnd(container, 500)`) -- сбрасывается в `false`
4. Scroll-spy автоматически обнаруживает видимый файл на следующем intersection event
**Таймаут:** `waitForScrollEnd` имеет fallback timeout. Сигнатура: `waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>`. Default timeout 400ms. Мы передаём 500ms. Smooth scroll в Chromium занимает ~300-400ms. 500ms достаточно.
### 2. Cross-file hunk navigation: определение границы файла
**Проблема:** Как определить что мы на последнем/первом hunk файла?
**Решение:** Функции `isLastChunkInFile(view)` / `isFirstChunkInFile(view)` используют `getChunks(view.state)` для получения списка chunks, и сравнивают позицию курсора (`view.state.selection.main.head`) с позицией первого/последнего chunk.
**Критическая деталь `goToNextChunk`:**
- `goToNextChunk` -- это `StateCommand` (тип: `(target: { state, dispatch }) => boolean`)
- `EditorView` реализует этот интерфейс (имеет `.state` и `.dispatch()`)
- `goToNextChunk` реализует **циклическую** навигацию: `chunks[(pos + offset) % chunks.length]`
- При >1 chunks `goToNextChunk` **ВСЕГДА** возвращает `true` (перешёл к следующему chunk, даже если wrap-around к первому)
- `false` возвращается ТОЛЬКО когда: chunks.length === 0, или chunks.length === 1 && cursor уже в этом chunk
Поэтому использовать `const moved = goToNextChunk(view); if (!moved)` для определения "последний chunk" -- **некорректно**. Нужна явная проверка `isLastChunkInFile()`.
### 3. Multiple EditorViews: какой active?
**Проблема:** В continuous mode 10+ EditorView одновременно. Какой считать "активным" для keyboard shortcuts?
**Решение:** Приоритет в `getActiveEditorView()`:
1. **Focused editor** -- `view.hasFocus` (CM API). Пользователь кликнул в editor для редактирования.
2. **activeFilePath editor** -- editor файла, определённого scroll-spy как видимый. Пользователь скроллит, но не кликает в editor.
3. **Первый editor** -- fallback, если ни один не подходит.
**Нюанс:** Когда пользователь кликает в sidebar (ReviewFileTree), фокус уходит из CM editor. `view.hasFocus` становится `false` для всех. В этом случае activeFilePath editor используется корректно.
### 4. goToNextChunk на пустом файле (0 chunks)
**Проблема:** Файл целиком новый (`isNewFile: true`) -- весь контент является одним "inserted" chunk. Или файл без diff (identical). `goToNextChunk` возвращает `false` при 0 chunks.
**Решение:** `isLastChunkInFile` и `isFirstChunkInFile` возвращают `true` при 0 chunks. В `goToNextHunk` continuous mode: если `isLastChunkInFile` true и 0 chunks -- переходим к следующему файлу. Это корректно: файл без changes пропускается.
Для new file (1 chunk covering entire file): `isLastChunkInFile` вернёт `true` если курсор >= chunk.fromB. При первом заходе курсор в позиции 0 = chunk.fromB = 0, значит `isLastChunkInFile` true -- сразу переход к следующему файлу. Это может быть нежелательно для больших new files. **Решение:** Для файлов с 1 chunk можно добавить проверку `cursorPos >= lastChunk.toB - 1` (конец chunk, не начало). Но это edge case, оставляем для будущей итерации.
### 5. Cmd+Enter save: какой файл сохраняется?
**Проблема:** В continuous mode несколько файлов видны одновременно. `Cmd+Enter` должен сохранять конкретный файл.
**Решение:** Сохраняется файл из `handleSaveCurrentFile`:
- В continuous mode: `activeFilePath` из scroll-spy
- В legacy mode: `selectedReviewFilePath` из store
`onSaveFileRef.current` в keyboard handler вызывает `handleSaveCurrentFile`, который уже адаптирован.
### 6. Cross-file navigation + requestAnimationFrame timing
**Проблема:** При переходе к следующему файлу, `scrollToFile` триггерит smooth scroll. EditorView нового файла может быть не готов.
**Решение:**
1. В Phase 1/2 ВСЕ EditorView создаются при mount (lazy loading загружает контент, но DOM + EditorView создаются сразу для загруженных файлов)
2. `requestAnimationFrame` используется для задержки `goToNextChunk` после scroll
3. Если EditorView ещё не доступен (файл ещё не загружен через lazy loading) -- `continuousOptions.editorViewRefs.get(filePath)` вернёт `undefined`, navigation no-op
**Потенциальная проблема:** rAF может сработать до завершения smooth scroll. Но для `goToNextChunk` / `goToPreviousChunk` это ОК -- CM сам scrollIntoView к chunk. Визуально: scroll к файлу + мгновенный jump к первому chunk.
### 7. Wrap-around: конец/начало списка файлов
**Поведение:**
- `goToNextFile()` на последнем файле: wrap к первому файлу (index 0). Это текущее поведение legacy mode, сохраняем.
- `goToNextHunk()` на последнем hunk последнего файла: no-op (не wrap). Это отличается от goToNextFile -- hunk navigation останавливается на границе.
- `goToPrevHunk()` на первом hunk первого файла: no-op.
### 8. Editor state cache в continuous mode
**Проблема:** В legacy mode `editorStateCache` хранит EditorState для восстановления undo history при переключении файлов. В continuous mode все editors живут одновременно -- cache не нужен.
**Решение:** `editorStateCache` используется только в legacy mode (`handleSelectFile` проверяет `isContinuousMode`). В continuous mode undo history каждого EditorView сохраняется автоматически (editor не уничтожается при навигации).
### 9. goToNextChunk циклическая навигация vs наше поведение
**Ситуация:** `goToNextChunk` при >1 chunks делает wrap-around (с последнего chunk на первый). Наше cross-file поведение ожидает "стоп на последнем chunk -- перейти к следующему файлу".
**Решение:** Мы НЕ вызываем `goToNextChunk` когда `isLastChunkInFile` true. Поэтому wrap-around не происходит. `goToNextChunk` вызывается только когда мы знаем что есть следующий chunk в текущем файле.
---
## Проверка
### Unit тесты
```
test/renderer/hooks/useDiffNavigation.test.ts
```
**Тест-кейсы:**
1. **goToNextFile в continuous mode** -- вызывает scrollToFile, НЕ вызывает onSelectFile
2. **goToNextFile в legacy mode** -- вызывает onSelectFile, НЕ вызывает scrollToFile
3. **getActiveEditorView: focused editor приоритет** -- mock view.hasFocus
4. **getActiveEditorView: fallback на activeFilePath** -- когда hasFocus false для всех
5. **goToNextHunk: isLastChunkInFile true** -- вызывает scrollToFile для следующего файла, НЕ вызывает goToNextChunk
6. **goToNextHunk: isLastChunkInFile false** -- вызывает goToNextChunk, НЕ переходит к файлу
7. **goToPrevHunk cross-file** -- при isFirstChunkInFile=true, вызывает scrollToFile для предыдущего файла
8. **Keyboard: Alt+ArrowDown** -- вызывает goToNextFile
9. **Keyboard: Alt+ArrowUp** -- вызывает goToPrevFile
10. **Keyboard: Alt+J** -- вызывает goToNextHunk (с cross-file)
11. **Keyboard: Cmd+Y + cross-file** -- acceptChunk + goToNextFile если isLastChunkInFile
12. **handleSaveCurrentFile в continuous mode** -- сохраняет activeFilePath
13. **handleSelectFile в continuous mode** -- вызывает scrollToFile вместо selectReviewFile
14. **isLastChunkInFile: 0 chunks** -- returns true
15. **isLastChunkInFile: cursor before last chunk** -- returns false
16. **isLastChunkInFile: cursor at last chunk.fromB** -- returns true
### Ручная проверка
1. Открыть review dialog в continuous mode с 5+ файлами
2. Клик по файлу в sidebar -- плавный scroll к секции
3. Alt+ArrowDown/Up -- навигация между файлами
4. Alt+J -- переход к следующему hunk
5. На последнем hunk файла: Alt+J -- scroll к следующему файлу, первый hunk
6. Cmd+Y на последнем hunk -- accept + scroll к следующему файлу
7. Cmd+Enter -- сохраняет видимый файл (не первый в списке)
8. Переключить на legacy mode -- все shortcuts работают как раньше
### Интеграция с Phase 1/2
- scrollToFile корректно подавляет scroll-spy (isProgrammaticScroll)
- activeFilePath обновляется после программного scroll (через scroll-spy, не принудительно)
- EditorView Map содержит все созданные editors
- Sidebar highlight синхронизирован с activeFilePath в continuous mode
- Lazy loading не мешает навигации (placeholder для незагруженных файлов)
---
## Файлы
| Файл | Тип | ~LOC изменений |
|------|-----|---:|
| `src/renderer/hooks/useDiffNavigation.ts` | MODIFY | ~200 (helpers + goToNext/Prev переработка + keyboard) |
| `src/renderer/hooks/useContinuousScrollNav.ts` | MODIFY | ~-30 (удаление keyboard handler, упрощение interface) |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | ~60 (continuousOptions, handleSelectFile, handleSave) |
| `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | MODIFY | ~10 (новые shortcuts) |
| `test/renderer/hooks/useDiffNavigation.test.ts` | MODIFY | ~200 (новые тест-кейсы для continuous mode) |
| **Итого** | 0 NEW + 5 MODIFY | ~440 |

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,988 @@
# Phase 5: Polish + EditorView Map + Toolbar адаптация
## 1. Обзор
Финальная фаза Continuous Scroll Diff View. Задачи:
- **EditorView Map** -- централизованный реестр всех EditorView экземпляров в ContinuousScrollView, необходимый для глобальных действий (Accept All, Reject All) и keyboard navigation.
- **Keyboard shortcuts координация** -- Cmd+Y/N/Enter должны корректно определять, с каким EditorView работать, когда на экране отображаются десятки файлов одновременно.
- **Auto-viewed для каждого файла** -- каждый FileSectionDiff отслеживает свой viewed-статус через IntersectionObserver.
- **ReviewToolbar адаптация** -- кнопки "Accept All" и "Reject All" теперь оперируют ВСЕМИ файлами, а не текущим. Добавляется progress indicator.
- **ChangeReviewDialog адаптация** -- handlers переключаются на multi-file режим, per-file discard counters.
- **Cleanup и edge-cases** -- корректная очистка при unmount, batch-обновления для 50+ файлов.
**Предусловия:** Phase 1 (ContinuousScrollView), Phase 2 (lazy loading), Phase 3 (navigation), Phase 4 (portionCollapse) -- все завершены.
---
## 2. EditorView Map в ContinuousScrollView
### 2.1. Структура данных
```typescript
// ContinuousScrollView.tsx (внутри компонента)
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
```
Map хранит `filePath -> EditorView` для каждого смонтированного FileSectionDiff. Используется `useRef`, а не `useState`, потому что:
- EditorView-инстансы не являются React-состоянием
- Изменение Map не должно вызывать ре-рендер ContinuousScrollView
- Доступ к Map нужен синхронно из event handlers
### 2.2. Callback-интерфейс FileSectionDiff
FileSectionDiff использует единый callback для регистрации/дерегистрации EditorView, как определено в Phase 1:
```typescript
// FileSectionDiff.tsx — props interface (из Phase 1, секция 2.2)
interface FileSectionDiffProps {
filePath: string;
original: string;
modified: string;
fileName: string;
readOnly: boolean;
showMergeControls: boolean;
collapseUnchanged: boolean;
discardCounter: number;
// ... другие props
/**
* Вызывается при создании EditorView (view !== null) и при уничтожении (view === null).
* Единый callback по паттерну Phase 1.
*/
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
}
```
**Важно:** Используется ОДИН callback `onEditorViewReady(filePath, view | null)`, а НЕ два отдельных (`onEditorViewReady` + `onEditorViewDestroyed`). Это соответствует дизайну Phase 1 (секция 2.2 FileSectionDiff), где `view === null` сигнализирует об уничтожении EditorView.
### 2.3. Реализация в FileSectionDiff
FileSectionDiff оборачивает CodeMirrorDiffView и управляет lifecycle:
```typescript
// FileSectionDiff.tsx (из Phase 1, секция 2.2)
const localEditorViewRef = useRef<EditorView | null>(null);
// Sync to parent Map при mount/unmount
useEffect(() => {
return () => {
// При unmount сообщить parent что view уничтожен
onEditorViewReady(filePath, null);
};
}, [filePath, onEditorViewReady]);
// Нужен useEffect чтобы проверить ref после рендера CodeMirrorDiffView
useEffect(() => {
if (localEditorViewRef.current) {
onEditorViewReady(filePath, localEditorViewRef.current);
}
});
```
**Важно:** CodeMirrorDiffView устанавливает `editorViewRef.current` синхронно в своём useEffect. Наш вторичный useEffect (без deps) ловит это на следующем render cycle.
**Альтернативная реализация с requestAnimationFrame** (для гарантии синхронизации):
```typescript
useEffect(() => {
const rafId = requestAnimationFrame(() => {
const view = localEditorViewRef.current;
if (view) {
onEditorViewReady(filePath, view);
}
});
return () => {
cancelAnimationFrame(rafId);
if (localEditorViewRef.current) {
onEditorViewReady(filePath, null);
localEditorViewRef.current = null;
}
};
}, [filePath, discardCounter]);
```
### 2.4. Регистрация в ContinuousScrollView
```typescript
// ContinuousScrollView.tsx (единый handler по паттерну Phase 1)
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
editorViewMapRef.current.set(filePath, view);
} else {
editorViewMapRef.current.delete(filePath);
}
},
[]
);
```
Передаётся каждому `FileSectionDiff`:
```tsx
<FileSectionDiff
filePath={file.filePath}
onEditorViewReady={handleEditorViewReady}
// ... другие props
/>
```
### 2.5. Передача Map наружу
ContinuousScrollView передает Map наружу через `useImperativeHandle`:
```typescript
// ContinuousScrollView.tsx
export interface ContinuousScrollViewHandle {
getEditorViewMap: () => Map<string, EditorView>;
getActiveEditorView: () => EditorView | null;
}
const ContinuousScrollView = forwardRef<ContinuousScrollViewHandle, ContinuousScrollViewProps>(
(props, ref) => {
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
useImperativeHandle(ref, () => ({
getEditorViewMap: () => editorViewMapRef.current,
getActiveEditorView: () => {
// Логика определения активного editor (см. секцию 3)
return resolveActiveEditorView(editorViewMapRef.current, props.activeFilePath);
},
}), [props.activeFilePath]);
// ...
}
);
```
В ChangeReviewDialog:
```typescript
const continuousScrollRef = useRef<ContinuousScrollViewHandle>(null);
<ContinuousScrollView ref={continuousScrollRef} ... />
// Использование:
const map = continuousScrollRef.current?.getEditorViewMap();
const activeView = continuousScrollRef.current?.getActiveEditorView();
```
**Решение:** используем `useImperativeHandle` -- он инкапсулирует логику определения активного editor внутри ContinuousScrollView, где есть доступ к scroll-spy данным.
---
## 3. Keyboard shortcuts координация (Cmd+Y/N)
### 3.1. Проблема
В single-file режиме `editorViewRef.current` -- всегда один EditorView. В continuous scroll -- их может быть десятки. Нужно определить, какой EditorView является "активным" для команд accept/reject.
### 3.2. Алгоритм resolveActiveEditorView
```typescript
function resolveActiveEditorView(
editorViewMap: Map<string, EditorView>,
activeFilePath: string
): EditorView | null {
// 1. Приоритет: EditorView, который имеет фокус
const activeEl = document.activeElement;
if (activeEl) {
for (const [, view] of editorViewMap) {
if (view.dom.contains(activeEl)) {
return view;
}
}
}
// 2. Fallback: EditorView для activeFilePath (из scroll-spy)
if (activeFilePath) {
return editorViewMap.get(activeFilePath) ?? null;
}
return null;
}
```
**Логика приоритетов:**
1. Если пользователь кликнул в CodeMirror editor (ставит фокус) -- используем именно этот editor. `document.activeElement` будет внутри `.cm-content` элемента.
2. Если фокус вне editor (например, после скролла мышью) -- используем editor для файла, определенного scroll-spy как видимый (`activeFilePath`).
### 3.3. Интеграция с useDiffNavigation (Phase 3)
Phase 3 уже определяет `continuousOptions?: ContinuousNavigationOptions` как 10-й параметр `useDiffNavigation`. Этот объект включает:
```typescript
interface ContinuousNavigationOptions {
editorViewRefs: Map<string, EditorView>;
activeFilePath: string | null;
scrollToFile: (filePath: string) => void;
enabled: boolean;
}
```
Внутри `useDiffNavigation` Phase 3 реализует helper `getActiveEditorView()`, который определяет активный editor по приоритету: focused > activeFilePath > first editor.
**Phase 5 НЕ добавляет новых параметров в useDiffNavigation.** Вся логика определения активного editor уже заложена в Phase 3 через `continuousOptions`. Phase 5 лишь использует эту инфраструктуру:
```typescript
// В ChangeReviewDialog.tsx — передача continuousOptions (определено Phase 3)
const continuousOptions = useMemo(
(): ContinuousNavigationOptions | undefined => {
if (!isContinuousMode) return undefined;
return {
editorViewRefs: continuousScrollRef.current?.getEditorViewMap() ?? new Map(),
activeFilePath: continuousScrollActiveFilePath,
scrollToFile: scrollToFile,
enabled: true,
};
},
[isContinuousMode, continuousScrollActiveFilePath, scrollToFile]
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
selectedReviewFilePath,
handleSelectFile,
editorViewRef,
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
() => onOpenChange(false),
handleSaveCurrentFile,
continuousOptions // <-- 10-й параметр из Phase 3
);
```
### 3.4. Cmd+Y: Accept + goToNextChunk
Поток действий:
1. `resolveActiveEditorView()` -> получаем EditorView
2. `acceptChunk(view)` -- принимает текущий chunk в этом editor
3. `requestAnimationFrame(() => goToNextChunk(view))` -- прокручивает к следующему chunk
4. **Cross-file transition:** если это был последний chunk в файле, Phase 3 обрабатывает cross-file navigation через `isLastChunkInFile()` и `scrollToFile()`.
### 3.5. Cmd+N: Reject + goToNextChunk
Аналогично Cmd+Y, но вызывает `rejectChunk(view)`. Обработка через IPC-listener `window.electronAPI.review.onCmdN`:
```typescript
// В ChangeReviewDialog.tsx — модификация IPC listener
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = isContinuousMode
? continuousScrollRef.current?.getActiveEditorView() ?? null
: editorViewRef.current;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => goToNextChunk(view));
}
});
return cleanup ?? undefined;
}, [open, isContinuousMode]);
```
### 3.6. Cmd+Enter: Save file
Сохраняет только `activeFilePath`, не все файлы:
```typescript
// Cmd+Enter handler
if (isMeta && event.key === 'Enter') {
event.preventDefault();
if (activeFilePath) {
saveEditedFile(activeFilePath);
}
return;
}
```
Где `activeFilePath` -- из scroll-spy (ContinuousScrollView props).
### 3.7. Alt+J: Next change
```typescript
// Alt+J handler (реализовано в Phase 3 keyboard handler)
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (view) goToNextChunk(view);
return;
}
```
---
## 4. Auto-viewed для каждого файла
### 4.1. Текущий механизм (single-file mode)
В CodeMirrorDiffView.tsx:
- `endSentinelRef` -- невидимый `<div>` после editor
- IntersectionObserver с `threshold: 1.0`
- При пересечении вызывается `onFullyViewed()` callback
- В ChangeReviewDialog: `handleFullyViewed` -> `markViewed(selectedReviewFilePath)`
### 4.2. Continuous mode: per-file sentinel
Каждый `FileSectionDiff` содержит свой sentinel для auto-viewed:
```typescript
// FileSectionDiff.tsx
const endSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!endSentinelRef.current || !autoViewed) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onFullyViewed(filePath);
}
}
},
{ threshold: 0.85 } // НЕ 1.0 — portionCollapse может компактить файл
);
observer.observe(endSentinelRef.current);
return () => observer.disconnect();
}, [filePath, autoViewed, onFullyViewed]);
return (
<div>
<CodeMirrorDiffView ... />
{/* Sentinel для auto-viewed detection */}
<div ref={endSentinelRef} className="h-px shrink-0" />
</div>
);
```
### 4.3. Threshold: 0.85 вместо 1.0
Обоснование:
- `threshold: 1.0` означает "100% элемента видимо". Для sentinel в 1px это работает.
- Но в continuous mode sentinel может быть в viewport из-за подскролла следующего файла, пока текущий файл ещё не полностью просмотрен.
- Решение: sentinel размещаем ПОСЛЕ CodeMirrorDiffView внутри FileSectionDiff. Threshold 0.85 дает некоторый margin для portionCollapse, который может сильно уменьшить высоту файла.
- Sentinel для 1px элемента с threshold 0.85 сработает, когда sentinel "почти полностью" видим -- это надежно.
### 4.4. onFullyViewed callback в ContinuousScrollView
```typescript
// ContinuousScrollView.tsx
const handleFileFullyViewed = useCallback((filePath: string) => {
if (autoViewed && !isViewed(filePath)) {
markViewed(filePath);
}
}, [autoViewed, isViewed, markViewed]);
```
Передается каждому FileSectionDiff:
```tsx
<FileSectionDiff
filePath={file.filePath}
autoViewed={autoViewed}
onFullyViewed={handleFileFullyViewed}
// ...
/>
```
### 4.5. Отличие от single-file mode
В single-file mode за один скролл пользователь видит один файл. В continuous mode несколько файлов могут быть "viewed" за один скролл. Это корректное поведение:
- Маленькие файлы (1-5 строк diff) мгновенно проскакивают viewport
- Их sentinel пересекается с viewport -> onFullyViewed срабатывает
- `markViewed()` идемпотентен (useViewedFiles проверяет через Set)
### 4.6. autoViewed toggle
Toggle в toolbar контролирует глобальный `autoViewed` state. Когда выключен:
- IntersectionObserver все ещё работает, но `handleFileFullyViewed` проверяет `autoViewed` flag и делает early return
- Альтернатива: не создавать IntersectionObserver при `autoViewed === false` (более оптимально)
Предпочтительная реализация (оптимизированная):
```typescript
// FileSectionDiff.tsx
useEffect(() => {
if (!endSentinelRef.current || !autoViewed) return;
// Observer создается только когда autoViewed=true
// ...
}, [filePath, autoViewed, onFullyViewed]);
```
---
## 5. Модификация ReviewToolbar.tsx
### 5.1. Accept All / Reject All -- все файлы
Текущие tooltip:
- "Accept all changes in current file"
- "Reject all changes in current file"
В continuous mode:
- "Accept all changes across all files"
- "Reject all changes across all files"
**Реализация:** ReviewToolbar получает новый prop `isContinuousMode`:
```typescript
interface ReviewToolbarProps {
stats: { pending: number; accepted: number; rejected: number };
changeStats: ChangeStats;
collapseUnchanged: boolean;
applying: boolean;
autoViewed: boolean;
onAutoViewedChange: (auto: boolean) => void;
onAcceptAll: () => void;
onRejectAll: () => void;
onApply: () => void;
onCollapseUnchangedChange: (collapse: boolean) => void;
editedCount?: number;
/** Phase 5: continuous scroll mode -- changes tooltip text */
isContinuousMode?: boolean;
}
```
Tooltip:
```tsx
<TooltipContent side="bottom">
{isContinuousMode
? 'Accept all changes across all files'
: 'Accept all changes in current file'}
</TooltipContent>
```
### 5.2. Progress indicator: "12 of 45 changes reviewed"
Новый UI элемент между change stats и action buttons.
```typescript
// ReviewToolbar.tsx — новый prop
interface ReviewToolbarProps {
// ...
/** Total hunks reviewed (accepted + rejected) */
reviewedCount?: number;
/** Total hunks across all files */
totalHunks?: number;
}
```
Вычисление в ChangeReviewDialog:
```typescript
const reviewProgress = useMemo(() => {
if (!activeChangeSet) return { reviewed: 0, total: 0 };
let total = 0;
let reviewed = 0;
for (const file of activeChangeSet.files) {
for (let i = 0; i < file.snippets.length; i++) {
total++;
const key = `${file.filePath}:${i}`;
const decision = hunkDecisions[key];
if (decision === 'accepted' || decision === 'rejected') {
reviewed++;
}
}
}
return { reviewed, total };
}, [activeChangeSet, hunkDecisions]);
```
Отображение в ReviewToolbar:
```tsx
{/* Progress indicator */}
{totalHunks !== undefined && totalHunks > 0 && (
<div className="flex items-center gap-2 text-xs">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-zinc-700/50">
<div
className="h-full rounded-full bg-blue-500/70 transition-all duration-300"
style={{ width: `${totalHunks > 0 ? (reviewedCount! / totalHunks) * 100 : 0}%` }}
/>
</div>
<span className="text-text-muted">
{reviewedCount} of {totalHunks} reviewed
</span>
</div>
)}
```
**Позиция в toolbar:** после change stats (`+N -M across K files`), перед separator (`<div className="h-4 w-px bg-border" />`).
### 5.3. Итоговый layout toolbar (слева направо)
1. Decision stats badges (pending, accepted, rejected)
2. Change stats (+N -M across K files)
3. Review progress bar ("12 of 45 reviewed")
4. `flex-1` spacer
5. Collapse toggle
6. Auto-viewed toggle
7. Separator
8. Edited count badge (если есть)
9. Separator (если есть edited)
10. Accept All button
11. Reject All button
12. Apply button
---
## 6. Модификация ChangeReviewDialog.tsx
### 6.1. handleAcceptAll -- все файлы
Текущая реализация:
```typescript
const handleAcceptAll = useCallback(() => {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, acceptAllFile]);
```
Continuous mode:
```typescript
const handleAcceptAll = useCallback(() => {
if (isContinuousMode) {
// 1. Store: пометить все hunks во всех файлах как accepted
acceptAll(); // store action — уже помечает ВСЕ файлы
// 2. CM: применить acceptAllChunks к каждому EditorView
const map = continuousScrollRef.current?.getEditorViewMap();
if (map) {
const views = Array.from(map.values());
// Batch: используем requestAnimationFrame для предотвращения layout thrashing
requestAnimationFrame(() => {
for (const view of views) {
acceptAllChunks(view);
}
});
}
} else {
// Single-file mode (без изменений)
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, acceptAll, selectedReviewFilePath, acceptAllFile]);
```
### 6.2. handleRejectAll -- все файлы
```typescript
const handleRejectAll = useCallback(() => {
if (isContinuousMode) {
// 1. Store: пометить все hunks во всех файлах как rejected
rejectAll(); // store action
// 2. CM: применить rejectAllChunks к каждому EditorView
const map = continuousScrollRef.current?.getEditorViewMap();
if (map) {
const views = Array.from(map.values());
requestAnimationFrame(() => {
for (const view of views) {
rejectAllChunks(view);
}
});
}
} else {
const view = editorViewRef.current;
if (view) rejectAllChunks(view);
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, rejectAll, selectedReviewFilePath, rejectAllFile]);
```
### 6.3. handleSaveFile -- по activeFilePath
```typescript
const handleSaveFile = useCallback((filePath: string) => {
void saveEditedFile(filePath);
}, [saveEditedFile]);
// Для toolbar/keyboard: сохраняет activeFilePath
const handleSaveActiveFile = useCallback(() => {
if (isContinuousMode) {
// activeFilePath определяется scroll-spy в ContinuousScrollView
// Передается через state или callback
const activePath = continuousScrollActiveFilePath;
if (activePath) handleSaveFile(activePath);
} else {
if (selectedReviewFilePath) handleSaveFile(selectedReviewFilePath);
}
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleSaveFile]);
```
### 6.4. handleDiscardFile -- per-file
```typescript
const handleDiscardFile = useCallback((filePath: string) => {
// В continuous mode editorStateCache НЕ используется
// (все editors живут одновременно — cache не нужен, см. Phase 1 секция 4.3)
discardFileEdits(filePath);
setDiscardCounters(prev => ({
...prev,
[filePath]: (prev[filePath] ?? 0) + 1
}));
}, [discardFileEdits]);
// Для keyboard/toolbar: discard activeFilePath
const handleDiscardActiveFile = useCallback(() => {
const activePath = isContinuousMode
? continuousScrollActiveFilePath
: selectedReviewFilePath;
if (activePath) handleDiscardFile(activePath);
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleDiscardFile]);
```
**Важно:** `editorStateCache` не используется в continuous mode. Phase 1 (секция 4.3) устанавливает, что в continuous mode все editors живут одновременно и нет необходимости в кеше EditorState. Discard реализуется через `discardCounters` (пересоздание через key).
### 6.5. isContinuousMode state
```typescript
// ChangeReviewDialog.tsx
// Phase 5: continuous scroll mode
// Вычисляется, не является toggle:
const isContinuousMode = (activeChangeSet?.files.length ?? 0) > 1;
```
**Решение:** `isContinuousMode` вычисляется, не является toggle. Continuous mode включается когда файлов > 1. Для одного файла -- обычный single-file mode (без ContinuousScrollView).
### 6.6. activeFilePath из ContinuousScrollView
ContinuousScrollView определяет видимый файл через scroll-spy и сообщает родителю:
```typescript
// ContinuousScrollView.tsx props
interface ContinuousScrollViewProps {
// ...
onActiveFileChange: (filePath: string) => void;
}
```
В ChangeReviewDialog:
```typescript
const [continuousScrollActiveFilePath, setContinuousScrollActiveFilePath] = useState<string | null>(null);
<ContinuousScrollView
onActiveFileChange={setContinuousScrollActiveFilePath}
// ...
/>
```
---
## 7. Per-file discard counter
### 7.1. Проблема
Текущий `discardCounter` -- одно число для всего диалога. При discard оно инкрементируется, и CodeMirrorDiffView пересоздается через `key={filePath}:${discardCounter}`.
В continuous mode каждый файл имеет свой `CodeMirrorDiffView`. Инкремент общего counter пересоздаст ВСЕ EditorView -- это неэффективно и потеряет scroll position.
### 7.2. Решение: Record<string, number>
```typescript
// ChangeReviewDialog.tsx
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
```
### 7.3. Использование в FileSectionDiff key
```tsx
// ContinuousScrollView.tsx — передает counter каждому FileSectionDiff
{files.map(file => (
<FileSectionDiff
key={`${file.filePath}:${discardCounters[file.filePath] ?? 0}`}
filePath={file.filePath}
discardCounter={discardCounters[file.filePath] ?? 0}
// ...
/>
))}
```
Внутри FileSectionDiff, CodeMirrorDiffView:
```tsx
<CodeMirrorDiffView
key={`${filePath}:${discardCounter}`}
// ...
/>
```
### 7.4. Discard action
```typescript
const handleDiscardFile = useCallback((filePath: string) => {
// 1. Удаляем edited content из store
discardFileEdits(filePath);
// 2. Инкрементируем counter ТОЛЬКО для этого файла
setDiscardCounters(prev => ({
...prev,
[filePath]: (prev[filePath] ?? 0) + 1,
}));
}, [discardFileEdits]);
```
Результат: пересоздается ТОЛЬКО EditorView для конкретного файла. Все остальные EditorViews сохраняют состояние.
### 7.5. Обратная совместимость
Для single-file mode (когда ContinuousScrollView не используется) сохраняется существующий `discardCounter: number` без изменений. `discardCounters: Record<string, number>` используется только в continuous mode. Оба варианта сосуществуют в ChangeReviewDialog:
```typescript
// Single-file mode: существующий counter
const [discardCounter, setDiscardCounter] = useState(0);
// Continuous mode: per-file counters
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
```
---
## 8. Cleanup при закрытии
### 8.1. EditorView Map
При unmount ContinuousScrollView:
1. Каждый FileSectionDiff вызывает `onEditorViewReady(filePath, null)` (единый callback)
2. Map автоматически очищается
3. EditorView.destroy() вызывается внутри CodeMirrorDiffView cleanup
```typescript
// ContinuousScrollView.tsx
useEffect(() => {
return () => {
// Safety: на случай если unmount происходит до cleanup дочерних
editorViewMapRef.current.clear();
};
}, []);
```
### 8.2. Store state
`clearChangeReview()` из changeReviewSlice уже сбрасывает:
- `activeChangeSet`
- `hunkDecisions`
- `fileDecisions`
- `fileContents`
- `fileContentsLoading`
- `editedContents`
- `applying`
- `applyError`
Дополнительных действий не требуется.
### 8.3. Viewed state
`viewedSet` persistent через `localStorage` (useViewedFiles -> diffViewedStorage). НЕ очищается при закрытии диалога -- это намеренное поведение (пользователь может закрыть и открыть диалог, и viewed файлы останутся).
### 8.4. discardCounters
React state -- автоматически GC при unmount компонента. Не persistent.
---
## 9. Edge-cases
### 9.1. 50 EditorViews в памяти
**Проблема:** каждый EditorView -- DOM-элемент с syntax highlighting, diff computations, merge extensions.
**Смягчение:**
- portionCollapse (Phase 4) минимизирует видимый контент: свёрнутые regions не рендерят DOM-ноды
- Lazy loading (Phase 2) гарантирует, что контент загружается по мере необходимости, а не все сразу
**Если профилирование покажет проблемы:**
- Будущая оптимизация: destroy EditorView для файлов далеко за пределами viewport
- `onEditorViewReady(filePath, null)` уже в интерфейсе -- переход на destroy/recreate модель не потребует изменения API
- Placeholder вместо destroyed EditorView (высота сохраняется через cached `scrollHeight`)
**Реализация (не в Phase 5, на будущее):**
```typescript
// Идея: IntersectionObserver с rootMargin для pre-destroy
const DESTROY_MARGIN = '2000px'; // destroy если > 2000px от viewport
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const filePath = entry.target.dataset.filePath!;
if (entry.isIntersecting) {
// Восстановить EditorView
} else {
// Destroy EditorView, сохранить высоту
}
}
},
{ rootMargin: DESTROY_MARGIN }
);
```
### 9.2. Accept All + 50 файлов
**Проблема:** `acceptAllChunks` на 50 EditorView может вызвать layout thrashing.
**Решение:**
```typescript
// Batch: один rAF на все view updates
requestAnimationFrame(() => {
const map = continuousScrollRef.current?.getEditorViewMap();
if (!map) return;
for (const view of map.values()) {
acceptAllChunks(view);
}
});
```
Это группирует все DOM-мутации в один frame. CodeMirror batches DOM updates внутри `dispatch()`, так что 50 dispatches в одном rAF -- приемлемо.
**Store:** `acceptAll()` уже batched -- одна транзакция `set()` обновляет все `hunkDecisions` и `fileDecisions`.
### 9.3. Cmd+Y/N без видимых chunks
**Сценарий:** все chunks в текущем файле уже accepted/rejected. Пользователь нажимает Cmd+Y.
**Поведение:** `acceptChunk(view)` от @codemirror/merge не делает ничего, если нет chunk под cursor. `goToNextChunk(view)` аналогично -- no-op.
**Это корректно.** Не нужен дополнительный feedback (звук, toast и т.д.).
### 9.4. File save в continuous mode
**Сценарий:** пользователь нажимает Cmd+Enter. Сохраняется только `activeFilePath`, НЕ все отредактированные файлы.
**Обоснование:**
- Пользователь ожидает "save THIS file", не "save ALL files"
- Для массового save есть Apply All Changes
- Если добавить "Save All Edited" -- это отдельная фича (не в Phase 5)
### 9.5. Scroll position после Accept All / Reject All
**Проблема:** Accept All может значительно изменить высоту контента (deleted chunks исчезают). Scroll position может сместиться.
**Решение:** браузер автоматически корректирует scroll при изменении высоты элементов ВЫШЕ viewport. Для элементов В viewport -- пользователь увидит изменение, что ожидаемо.
Если нужно сохранить позицию:
```typescript
// Перед Accept All
const scrollTop = scrollContainerRef.current?.scrollTop ?? 0;
// ... apply accept all ...
requestAnimationFrame(() => {
scrollContainerRef.current?.scrollTo({ top: scrollTop });
});
```
Но это может быть нежелательно (пользователь хочет видеть результат). **Решение: не корректировать scroll.**
### 9.6. Race condition: onEditorViewReady + component key change
**Сценарий:** discard file -> key меняется -> old FileSectionDiff unmount -> new mount.
**Порядок:**
1. Old component: cleanup effect -> `onEditorViewReady(filePath, null)` -> Map.delete
2. New component: effect -> `onEditorViewReady(filePath, newView)` -> Map.set
React гарантирует cleanup effects ПЕРЕД mount effects. Race condition невозможна.
### 9.7. EditorView для файла с unavailable content
Если `fileContent.contentSource === 'unavailable'`, FileSectionDiff рендерит fallback (ReviewDiffContent), не CodeMirrorDiffView. EditorView не создается -> не попадает в Map.
При Accept All/Reject All -- файлы без EditorView обрабатываются только через store (hunkDecisions). Это корректно.
---
## 10. Проверка
### 10.1. Автоматические тесты
**Unit tests:**
| Тест | Файл | Что проверяет |
|------|------|---------------|
| resolveActiveEditorView с focused editor | `resolveActiveEditorView.test.ts` | Возвращает focused EditorView из Map |
| resolveActiveEditorView fallback на activeFilePath | `resolveActiveEditorView.test.ts` | Возвращает EditorView для activeFilePath |
| resolveActiveEditorView пустая Map | `resolveActiveEditorView.test.ts` | Возвращает null |
| discardCounters per-file increment | `ChangeReviewDialog.test.ts` | Инкремент только для одного файла |
| reviewProgress computation | `ChangeReviewDialog.test.ts` | Корректный подсчет reviewed/total |
| ReviewToolbar tooltip в continuous mode | `ReviewToolbar.test.ts` | "across all files" текст |
**Integration tests:**
| Тест | Что проверяет |
|------|---------------|
| Accept All в continuous mode | Store + все EditorViews обновлены |
| Reject All в continuous mode | Store + все EditorViews обновлены |
| Discard one file | Только один EditorView пересоздан |
| Auto-viewed multiple files | Несколько файлов помечены viewed за один скролл |
| Keyboard Cmd+Y с focused editor | Accept в focused editor, не в activeFilePath |
### 10.2. Ручное тестирование
**Чеклист:**
- [ ] Открыть review dialog с 5+ файлами
- [ ] Проскроллить вниз — auto-viewed помечает файлы по мере скролла
- [ ] Выключить auto-viewed toggle — скролл не помечает файлы
- [ ] Cmd+Y в focused editor — принимает chunk в этом editor
- [ ] Cmd+Y без фокуса — принимает chunk в activeFilePath editor
- [ ] Cmd+N — отклоняет chunk + переходит к следующему
- [ ] Cmd+Enter — сохраняет только текущий файл
- [ ] "Accept All" кнопка — все chunks во всех файлах accepted
- [ ] "Reject All" кнопка — все chunks во всех файлах rejected
- [ ] Discard файла — только этот EditorView пересоздается
- [ ] Progress bar обновляется при accept/reject
- [ ] Закрытие и повторное открытие — viewed state сохранен
- [ ] 20+ файлов — scroll не лагает
- [ ] Accept All + 20 файлов — без видимого зависания
### 10.3. Performance профилирование
- [ ] Chrome DevTools Performance: rAF timing при Accept All с 20 файлов (должен быть < 100ms)
- [ ] Memory: heap snapshot с 20 EditorViews (ожидание: ~50-80MB total)
- [ ] Layout: no forced synchronous layouts при scroll
---
## Приложение: Полный diff изменений по файлам
### Новые файлы
Нет новых файлов в Phase 5 (все компоненты созданы в Phase 1-4).
### Модифицируемые файлы
| Файл | Изменения |
|------|-----------|
| `ContinuousScrollView.tsx` | EditorView Map, useImperativeHandle, onActiveFileChange callback |
| `FileSectionDiff.tsx` | onEditorViewReady(filePath, view \| null) единый callback, per-file sentinel, autoViewed |
| `ChangeReviewDialog.tsx` | isContinuousMode, handleAcceptAll/RejectAll multi-file, discardCounters, continuousScrollActiveFilePath state, EditorView Map через ref |
| `ReviewToolbar.tsx` | isContinuousMode tooltip, progress indicator, reviewedCount/totalHunks props |
| `useDiffNavigation.ts` | Без дополнительных изменений Phase 5 — вся continuous mode логика уже реализована в Phase 3 (continuousOptions, getActiveEditorView, cross-file navigation) |
### Неизменяемые файлы
| Файл | Причина |
|------|---------|
| `CodeMirrorDiffView.tsx` | Без изменений — все обертывается через FileSectionDiff |
| `CodeMirrorDiffUtils.ts` | acceptAllChunks/rejectAllChunks уже поддерживают per-view вызов |
| `changeReviewSlice.ts` | acceptAll()/rejectAll() уже работают со всеми файлами |
| `useViewedFiles.ts` | markViewed() уже поддерживает per-file вызовы |
| `ReviewFileTree.tsx` | Без изменений в Phase 5 (модифицирован в Phase 1) |
| `KeyboardShortcutsHelp.tsx` | Без изменений в Phase 5 (модифицирован в Phase 3) |

View file

@ -0,0 +1,536 @@
# Diff View Feature — Full Audit Report
Date: 2026-02-26
Verified: 2026-02-26 (4 parallel agents cross-checked every bug against actual source code)
Comprehensive audit of the changes/diff viewing feature covering line count reliability,
hunk parsing, stat-to-hunk consistency, and UI rendering.
---
## Architecture Overview
The app uses **two distinct diff strategies**:
1. **Chat Viewer Diffs** (read-only, simple display):
- `DiffViewer.tsx` — custom LCS-based line-by-line diff
- Pure string comparison, NO hunk structure
2. **Team Review Diffs** (interactive, hunk-aware):
- CodeMirror Merge plugin (`@codemirror/merge`)
- `ReviewDiffContent.tsx` — uses `diffLines` from `diff` package
- `CodeMirrorDiffView.tsx` — full merge view with hunk navigation
- `ReviewApplierService.ts` — applies/rejects hunks to disk
### Three independent stat computation paths:
| Service | Algorithm | Used For |
|---------|-----------|----------|
| `ChangeExtractorService` | `diffLines()` from npm `diff` | Team member changes (file badges) |
| `MemberStatsComputer` | `split('\n').length` arithmetic | Session analytics |
| `FileContentResolver` | `diffLines()` from npm `diff` | Full file content diffs (CodeMirror) |
---
## Evaluation Summary
| # | Bug | Real? | Confidence | Status |
|---|-----|-------|------------|--------|
| 1 | Two conflicting line-counting methods | **YES** | 9/10 | Open |
| 2 | Write never counts removals | **YES** | 10/10 | Open |
| 3 | Trailing newline off-by-one | **PARTIAL** | 6/10 | Resolves with #1 |
| 4 | FileContentResolver overwrites stats | **PARTIAL** | 5/10 | Design issue |
| 5 | computeHunkIndexAtPos → 0 fallback | **YES** | 10/10 | Open |
| 6 | Hunk ≠ snippet mapping | **YES** | 9/10 | Open |
| 7 | indexOf duplicates in rejection | **YES** | 10/10 | Open |
| 8 | Skeleton flash after save | **FIXED** | 9/10 | Done |
| 9 | CRLF → false diffs | **YES** | 9/10 | Open |
| 10 | OOM on large files (LCS) | **YES** | 8/10 | Open |
| 11 | Race condition disk vs cache | **YES** | 8/10 | Open |
| 12 | Empty string inconsistency | **YES** | 7/10 | Resolves with #1 |
| 13 | Bash estimation ~30-40% | **PARTIAL** | 6/10 | Design limitation |
| 14 | Echo escape handling wrong | **YES** | 8/10 | Open |
| 15 | portionCollapse edge case | **PARTIAL** | 5/10 | Needs testing |
| 16 | No-newline-at-EOF hidden | **YES** | 8/10 | Open |
| 17 | Three-way merge labels | **NO** | 9/10 | False positive |
| 18 | Zero-change files invisible | **YES** | 8/10 | Open |
| 19 | Viewed threshold mismatch | **YES** | 9/10 | Open |
| 20 | useEffect no deps array | **YES** | 10/10 | Open |
| 21 | Hunk count ≠ snippet count | **YES** | 8/10 | Open |
| 22 | Toolbar off-screen narrow viewport | **YES** | 7/10 | Open |
| 23 | Deleted files not marked | **YES** | 8/10 | Open |
| 24 | write-update reconstruction null | **YES** | 9/10 | Open |
| 25 | Bash relative paths | **YES** | 9/10 | Open |
| 26 | Empty line → space | **YES** | 7/10 | Open |
| 27 | No keyboard nav in tree | **YES** | 8/10 | Feature |
| 28 | Collapse state not persisted | **YES** | 9/10 | Open |
| 29 | No stats summary | **NO** | 3/10 | Feature request |
| 30 | Binary files not detected | **YES** | 8/10 | Open |
| 31 | No max file size | **YES** | 7/10 | Open |
| 32 | Whitespace changes not distinguished | **PARTIAL** | 6/10 | Optional feature |
**Totals**: 24 real bugs, 3 false positives (#4, #17, #29), 5 partial/design (#3, #13, #15, #32, #4), 1 fixed (#8)
---
## CRITICAL BUGS
### 1. Two Conflicting Line-Counting Methods
**Real bug: YES — Confidence: 9/10**
**Impact**: Line count badges in file tree may not match actual hunks in editor.
**Details**:
- `ChangeExtractorService` (`src/main/services/team/ChangeExtractorService.ts:463-473`) uses `diffLines()` — semantic line diffing
- `MemberStatsComputer` (`src/main/services/team/MemberStatsComputer.ts:193-196`) uses naive `split('\n').length``newLines - oldLines`
Example divergence:
```
File: 10 lines rewritten completely (same line count)
diffLines(): added=10, removed=10 (correct — all lines changed)
split arithmetic: added=0, removed=0 (wrong — same line count)
```
**Best fix**: Create unified line-counting utility using `diffLines()` as source of truth. Replace `MemberStatsComputer`'s arithmetic with shared utility.
**Risk**: Line count numbers will change post-fix; must test edge cases.
### 2. Write Operations Never Count Removals
**Real bug: YES — Confidence: 10/10**
**Location**: `MemberStatsComputer.ts:204-214`
```typescript
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
if (writeContent) {
const fileAdded = writeContent.split('\n').length;
linesAdded += fileAdded;
addFileLines(input.file_path, fileAdded, 0); // Always 0 removals!
}
}
```
**Impact**: Write replacing 100-line file with 50-line file shows `+50 / -0` instead of accurate counts.
**Best fix**: Use `FileContentResolver` to access original state before Write, calculate true delta.
**Risk**: Requires coordination with FileContentResolver; may introduce coupling.
### 3. Trailing Newline Off-by-One
**Real bug: PARTIAL — Confidence: 6/10**
Resolves automatically when #1 is fixed (migration to `diffLines()`). Within MemberStatsComputer's own logic, the delta arithmetic is roughly self-consistent (both old and new over-count by 1, so the difference is correct). The issue is only visible when comparing MemberStatsComputer output against ChangeExtractorService output.
### 4. FileContentResolver Overwrites Stats
**Real bug: PARTIAL (design issue) — Confidence: 5/10**
`FileContentResolver.getFileContent()` recalculates stats from full content using `diffLines()`, overwriting input stats. This is actually MORE ACCURATE than snippet-based counts. The "overwrite" is intentional improvement, not a bug. The inconsistency is that file tree badges (pre-CM load) use snippet counts, while CodeMirror view uses recalculated counts.
**Verdict**: Not a code bug. Design choice with minor visual inconsistency during loading.
### 5. `computeHunkIndexAtPos` Returns 0 as Fallback
**Real bug: YES — Confidence: 10/10**
**Location**: `CodeMirrorDiffView.tsx:129-143`
```typescript
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
const chunks = getChunks(state);
if (!chunks) return 0;
let index = 0;
for (const chunk of chunks.chunks) {
if (pos >= chunk.fromB && pos <= chunk.toB) {
return index;
}
index++;
}
return 0; // ← Always returns first hunk if no match!
}
```
**Impact**: Clicking Accept/Reject when cursor is between hunks applies action to the FIRST hunk, not the nearest one. Confirmed: callers at lines 416-420 and 432-435 trust this value unconditionally.
**Best fix**: Find nearest chunk by minimum distance: `Math.min(|pos - chunk.fromB|, |pos - chunk.toB|)` for each chunk, return index of nearest.
**Alternative**: Return -1 for "no match" and require caller handling.
**Risk**: Need tie-breaking rule when cursor is equidistant from two chunks.
### 6. Hunk Index ≠ Snippet Index (False 1:1 Assumption)
**Real bug: YES — Confidence: 9/10**
**Location**: `ReviewApplierService.ts:337-342`
```typescript
const snippetsToReject = hunkIndices
.filter((idx) => idx >= 0 && idx < validSnippets.length)
.map((idx) => validSnippets[idx]);
```
**Problem**: Assumes hunk #N corresponds to snippet #N. But:
- Multiple Edit calls can merge into one hunk in structuredPatch
- One Write call can produce multiple hunks
- MultiEdit creates 1 snippet with multiple logical changes
**Best fix**: Build hunk-to-snippet mapping using position matching. For each hunk, find snippets whose newString appears in that hunk region. Store `hunkIndex -> Set<snippetIndices>`.
**Risk**: Complex implementation, requires re-running diff analysis.
### 7. Snippet Rejection via indexOf — Duplicate Content Bug
**Real bug: YES — Confidence: 10/10**
**Location**: `ReviewApplierService.ts:353`
```typescript
const pos = content.indexOf(snippet.newString);
```
`indexOf()` finds FIRST occurrence only. If identical code patterns exist elsewhere in the file, rejection corrupts the wrong section.
**Best fix**: Position-aware matching: calculate approximate line/column of original edit, search for newString near that position (±5 lines tolerance), require context match.
**Risk**: More complex logic, false negatives if context too strict.
### 8. Skeleton Flash After File Save — FIXED
**Location**: `changeReviewSlice.ts:649-666`
After saving, `fileContents[filePath]` was deleted from cache, causing `hasContent = false` → skeleton placeholder shown until lazy re-fetch completes.
**Fix applied**: Instead of deleting, update `modifiedFullContent` with saved content in-place. `contentSource` set to `'disk-current'`.
---
## HIGH PRIORITY BUGS
### 9. CRLF Line Endings → False Diffs
**Real bug: YES — Confidence: 9/10**
**Location**: `DiffViewer.tsx:297`
```typescript
const oldLines = oldString.split('\n');
```
Windows files with `\r\n` leave trailing `\r` on each line. Every line shows as "changed" even if content is identical.
**Fix**: Use `split(/\r?\n/)` or normalize before diffing.
**Risk**: Very low. Standard regex, no side effects.
### 10. OOM on Large Files (DiffViewer LCS)
**Real bug: YES — Confidence: 8/10**
**Location**: `DiffViewer.tsx:50-68`
LCS algorithm is O(m×n) space. Two 5000-line files = 25M matrix entries ≈ 100MB RAM.
No safeguards, no fallback for large files.
**Best fix**: Add size check: if `m * n > MAX_CELLS` (e.g., 1M), fallback to `diffLines()` from npm `diff` package.
**Risk**: Fallback produces different visual output (semantic vs LCS). Need to test.
### 11. Race Condition: File Disk State vs Cache
**Real bug: YES — Confidence: 8/10**
Stats and hunks come from SEPARATE sources:
- Stats: JSONL tool_use blocks (snapshot at parse time)
- Hunks: current file on disk (read at view time)
3-minute cache TTL on both `ChangeExtractorService` and `FileContentResolver`.
If file changes on disk between fetches, stats and hunks desynchronize.
**No cache invalidation** on FileWatcher events → caches stay stale until TTL expires.
**Best fix**: Hook FileWatcher to evict caches when files change. Or reduce TTL to 30s.
**Risk**: Must ensure invalidation doesn't create new race conditions.
### 12. Empty String Handling Inconsistency
**Real bug: YES — Confidence: 7/10**
**Location**: `MemberStatsComputer.ts:193`
Empty string `''` is falsy → returns 0. But `''.split('\n').length === 1`.
**Resolves with #1** — migrating to `diffLines()` handles this correctly.
### 13. Bash Line Estimation Covers ~30-40% of Patterns
**Real bug: PARTIAL — Confidence: 6/10**
**Location**: `MemberStatsComputer.ts:314-416`
**Verdict**: Fundamental limitation, not a code bug. The JSONL only stores command strings, not execution output. Without running the shell, accurate counting is impossible. Code comments acknowledge this. Best approach: document limitation in UI with tooltip.
### 14. Echo Escape Sequence Handling Wrong
**Real bug: YES — Confidence: 8/10**
**Location**: `MemberStatsComputer.ts:371`
```typescript
added += content.split('\\n').length;
```
Splits on literal `\\n` in quoted string. But `echo "line1\nline2"` does NOT expand `\n` without `-e` flag. Counter is wrong for standard echo.
**Best fix**: Check for `-e` flag before splitting on `\\n`. Without `-e`, treat as single line.
**Risk**: Hacky logic, any change may break other cases. Conservative: don't count echo lines at all.
---
## MEDIUM PRIORITY BUGS
### 15. portionCollapse Line Position Edge Case
**Real bug: PARTIAL — Confidence: 5/10**
**Location**: `portionCollapse.ts:140`
Rare edge case at exact line boundaries. Needs unit tests with edge cases to confirm.
**Verdict**: Low probability, needs testing before fixing.
### 16. No-Newline-At-End-Of-File Not Shown
**Real bug: YES — Confidence: 8/10**
**Location**: `ReviewDiffContent.tsx:46-48`
```typescript
const lines = part.value.replace(/\n$/, '').split('\n');
```
Strips trailing newline. If original has no final newline but modified adds one, diff shows them as identical.
**Best fix**: Add visual indicator for no-newline-at-EOF.
**Risk**: Need to update rendering without breaking existing layout.
### 17. Three-Way Merge Labels Confusing — FALSE POSITIVE
**Confidence: 9/10 that this is NOT a bug**
Labels correctly follow diff3 semantics: `<<<<<<< current` = disk state, `>>>>>>> original` = pre-change state. This is standard and correct. The audit was wrong.
### 18. Zero-Change Files Invisible
**Real bug: YES — Confidence: 8/10**
**Location**: `ChangeStatsBadge.tsx`
```typescript
if (linesAdded === 0 && linesRemoved === 0) return null;
```
Files modified with equal adds/removes (e.g., 5 lines rewritten) show no badge. Missing `modified` boolean flag.
**Best fix**: Add `modified: boolean` flag to `FileChangeSummary`. Show neutral badge for zero-net-change files.
**Risk**: Requires data structure change, but straightforward.
### 19. Viewed File Threshold Mismatch
**Real bug: YES — Confidence: 9/10**
- `FileSectionDiff.tsx`: `threshold: 0.85`
- `CodeMirrorDiffView.tsx`: `threshold: 1.0`
**Best fix**: Standardize on 0.85 everywhere. One-line change.
**Risk**: None.
### 20. Missing Dependency Array in useEffect
**Real bug: YES — Confidence: 10/10**
**Location**: `FileSectionDiff.tsx:50-56`
```typescript
useEffect(() => {
if (localEditorViewRef.current) {
onEditorViewReady(file.filePath, localEditorViewRef.current);
}
}); // ← No dependency array! Runs EVERY render.
```
**Best fix**: Add `[file.filePath, onEditorViewReady]` dependency array.
**Risk**: Low. Need to ensure `onEditorViewReady` is memoized with useCallback.
### 21. Hunk Counts Mismatch in UI
**Real bug: YES — Confidence: 8/10**
`snippets.length` used as fallback before CodeMirror loads, then replaced by `chunks.length`. User sees progress jump (e.g., "1 of 1" → "1 of 5").
**Best fix**: Pre-compute chunk count from `diffLines()` at load time, not from snippet count.
**Risk**: Medium — adds computation step, but improves correctness.
### 22. Merge Toolbar Off-Screen in Narrow Viewport
**Real bug: YES — Confidence: 7/10**
Buttons at `insetInlineEnd: '8px'` get clipped in narrow viewports.
**Best fix**: CSS-only: use `insetInlineStart` or add `maxWidth: '100%'` overflow handling.
**Risk**: None. CSS-only change.
### 23. Deleted Files Not Marked
**Real bug: YES — Confidence: 8/10**
No `isDeleted` flag in `FileChangeSummary`. Deleted files show as regular changes.
**Best fix**: Add `isDeleted: boolean` field. Set when original has content and modified is empty.
**Risk**: Medium — requires data structure change.
### 24. Snippet Reconstruction Returns null for Write-Update
**Real bug: YES — Confidence: 9/10**
**Location**: `FileContentResolver.ts:392-394`
`write-update` can't be reconstructed because JSONL doesn't include `oldString`. Falls back to disk-current which may differ from actual original.
**Best fix**: Store original content when detecting write-update during extraction.
**Risk**: Medium — requires data structure enrichment.
### 25. Bash Relative Paths Not Captured
**Real bug: YES — Confidence: 9/10**
**Location**: `MemberStatsComputer.ts:373-376`
Only captures absolute paths (`startsWith('/')`). Misses relative paths.
**Best fix**: Remove `startsWith('/')` check, just validate non-empty string.
**Risk**: Very low. 1-line fix.
---
## LOW PRIORITY
### 26. DiffViewer Empty Line Rendering
**Real bug: YES — Confidence: 7/10**
```typescript
{line.content || ' '}
```
Empty lines show as single space. Can't distinguish from space-only lines.
**Best fix**: `{line.content ?? ' '}` (only use space if truly undefined).
**Risk**: None. 1-line fix.
### 27. No Keyboard Navigation in File Tree
**Real bug: YES — Confidence: 8/10**
Mouse-only navigation. No WAI-ARIA tree widget support.
**Verdict**: UX feature, ~150 LOC. Not a correctness bug.
### 28. Folder Collapse State Not Persisted
**Real bug: YES — Confidence: 9/10**
State lost on dialog close. Uses `useState` with fresh `Set()`.
**Best fix**: Persist to localStorage or Zustand store.
**Risk**: Low-medium. localStorage has size limits but file tree data is tiny.
### 29. No Diff Stats Summary at Top — FALSE POSITIVE
**Confidence: 3/10 that this is a bug**
Missing feature, not a bug. File-by-file stats are visible. Aggregate summary would be nice UX but isn't a correctness issue.
### 30. Binary Files Not Detected
**Real bug: YES — Confidence: 8/10**
`diffLines()` called on binary content without check. Can cause corrupted display.
**Best fix**: Check for null bytes (`content.includes('\0')`) before diffing. Skip binary files.
**Risk**: Very low. Simple guard.
### 31. No Maximum File Size Handling
**Real bug: YES — Confidence: 7/10**
No progress indicator for large files. Browser freezes for 2-3s on 1MB+ files.
**Best fix**: Add size check, show "file too large" fallback for >5MB.
**Risk**: Low. Graceful degradation.
### 32. Whitespace-Only Changes Not Distinguished
**Real bug: PARTIAL — Confidence: 6/10**
No visual distinction between content vs whitespace-only changes.
**Verdict**: Optional enhancement. Add `ignoreWhitespace` toggle. Not a correctness bug.
---
## Race Condition Severity Matrix
| Scenario | Probability | Severity |
|----------|-------------|----------|
| Disk modification between stat cache and hunk fetch | Medium | High |
| File deletion after stats cached | Low-Medium | High |
| JSONL appended while parsing | Low | Medium |
| Git repo state change (checkout, rebase) | Low | Medium |
| Snippet reconstruction chain broken | Medium | High |
---
## Fix Categories
### Safe to Fix Now (isolated, low-risk, clear approach)
- **#5**: computeHunkIndexAtPos → nearest hunk (local function change)
- **#9**: CRLF normalization `split(/\r?\n/)` (1-line regex change)
- **#19**: Threshold 0.85 vs 1.0 → standardize (1-line constant)
- **#20**: useEffect dependency array (1-line addition)
- **#25**: Bash relative paths (remove `startsWith('/')` check)
- **#26**: Empty line rendering (`||` → `??`)
### Need More Research Before Fixing (multi-file, complex, risky)
- **#1**: Unify line counting (affects 3 services, all consumers)
- **#2**: Write removals (needs original file content during stats)
- **#6**: Hunk↔snippet mapping (algorithm redesign)
- **#7**: indexOf → position-aware matching (could break existing logic)
- **#10**: OOM safeguard (need to test fallback visual consistency)
- **#11**: Cache invalidation (FileWatcher integration complexity)
- **#23**: Deleted files flag (data structure change, migration)
- **#24**: write-update reconstruction (data enrichment needed)
### Feature Requests / Won't Fix
- **#13**: Bash estimation — fundamental limitation
- **#17**: Three-way labels — NOT a bug
- **#27**: Keyboard nav — UX feature
- **#29**: Stats summary — UX feature
- **#32**: Whitespace toggle — optional enhancement
---
## Key Files Reference
| File | Role |
|------|------|
| `src/main/services/team/ChangeExtractorService.ts` | Parses JSONL, aggregates per-file changes |
| `src/main/services/team/MemberStatsComputer.ts` | Session analytics, line counting |
| `src/main/services/team/FileContentResolver.ts` | Full file content resolution |
| `src/main/services/team/ReviewApplierService.ts` | Apply/reject hunks, save files |
| `src/renderer/components/chat/viewers/DiffViewer.tsx` | Simple LCS diff viewer |
| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Advanced CodeMirror merge view |
| `src/renderer/components/team/review/ReviewDiffContent.tsx` | Fallback snippet-based diff |
| `src/renderer/components/team/review/FileSectionDiff.tsx` | Diff section wrapper |
| `src/renderer/components/team/review/FileSectionHeader.tsx` | File header with Save button |
| `src/renderer/components/team/review/ContinuousScrollView.tsx` | Scroll container, skeleton logic |
| `src/renderer/components/team/review/portionCollapse.ts` | Smart collapse of unchanged regions |
| `src/renderer/components/team/review/ReviewFileTree.tsx` | File tree with stats badges |
| `src/renderer/store/slices/changeReviewSlice.ts` | Store: file contents, save, decisions |
| `src/renderer/hooks/useDiffNavigation.ts` | Keyboard navigation between hunks |
| `src/shared/types/review.ts` | Shared types: FileChangeSummary, FileChangeWithContent |

View file

@ -0,0 +1,814 @@
# Diff View — Detailed Fix Plans from Deep Research
Date: 2026-02-26
Source: 4 parallel research agents + 3 deep research agents (Round 2)
Last updated: 2026-02-26 (Round 2 deep research — 3 agents, 280k+ tokens total)
---
## Fix #11: Cache TTL 3min → 30sec
**Confidence: 10/10**
**Effort: 2 lines**
### Files to Change
1. `src/main/services/team/ChangeExtractorService.ts:40`
```typescript
// OLD
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
// NEW
private readonly CACHE_TTL = 30 * 1000; // 30 sec
```
2. `src/main/services/team/FileContentResolver.ts:32`
```typescript
// OLD
private readonly cacheTtl = 3 * 60 * 1000; // 3 мин
// NEW
private readonly cacheTtl = 30 * 1000; // 30 sec
```
### Cache Architecture Details
Both services use `Map<string, CacheEntry>` with TTL:
- `ChangeExtractorService`: key = `${teamName}:${memberName}`, stores `AgentChangeSet` + `mtime` + `expiresAt`
- `FileContentResolver`: key = file path, stores `original | modified | source` + `expiresAt`
- `ChangeExtractorService` stores file `mtime` but NEVER uses it for validation
- `FileContentResolver` has `invalidateFile(filePath)` but only called in ONE place: `review.ts:265` after save
### FileWatcher Coverage
FileWatcher ALREADY watches the right directories:
- `~/.claude/projects/` (JSONL session files)
- `~/.claude/todos/` (todo JSON files)
- `~/.claude/teams/` (team config files)
- `~/.claude/tasks/` (task JSON files)
But NO service-level cache invalidation hooks exist beyond the single `invalidateFile()` call.
### Thundering Herd Risk: NONE
- Each cache entry expires independently, staggered by client refresh timing
- 30sec = 120 cache refreshes/hour per user, negligible CPU
- Each team member has separate cache key; concurrent misses don't cascade
### Future Phase (Optional): FileWatcher Integration
Would require:
1. Add `ChangeExtractorService` and `FileContentResolver` to ServiceContext
2. Wire FileWatcher events to precise cache invalidation
3. Map `${teamName}:${memberName}` cache keys to affected files
4. Complex wiring, not worth it until TTL proves insufficient
---
## Fix #10: OOM Safeguard for DiffViewer LCS
**Confidence: 9/10**
**Effort: ~30 LOC**
### Memory Analysis
LCS matrix: `(m+1) × (n+1)` entries, each number = ~8 bytes in V8:
- 1000×1000 = 1M entries ≈ 8MB ✓ Safe
- 3000×3000 = 9M entries ≈ 72MB ⚠️ Manageable
- 5000×5000 = 25M entries ≈ 200MB ✗ Dangerous
- 10000×10000 = 100M entries ≈ 800MB ✗ OOM
**Recommended threshold: `MAX_CELLS = 1_000_000`** (~1000×1000 lines)
### `diffLines()` Return Format
From npm `diff` package:
```typescript
Array<{
value: string; // The actual line(s) + newline
count?: number; // Number of lines
added?: boolean; // true = new lines
removed?: boolean; // true = removed lines
// If neither added/removed: unchanged context lines
}>
```
### DiffLine Type (DiffViewer)
```typescript
interface DiffLine {
type: 'removed' | 'added' | 'context';
content: string;
lineNumber: number;
}
```
### Implementation
**Import to add** (DiffViewer.tsx top):
```typescript
import { diffLines as semanticDiffLines } from 'diff';
```
**New constant**:
```typescript
/** Max LCS matrix cells before falling back to semantic diff.
* 1M cells ≈ 8MB RAM — safe for all platforms. */
const MAX_LCS_CELLS = 1_000_000;
```
**Fallback function**:
```typescript
/**
* Fallback diff using semantic line-diffing from npm `diff` package.
* Used when LCS matrix would exceed memory threshold.
* Output format matches LCS-based generateDiff().
*/
function generateDiffFallback(oldLines: string[], newLines: string[]): DiffLine[] {
const oldText = oldLines.join('\n');
const newText = newLines.join('\n');
const changes = semanticDiffLines(oldText, newText);
const result: DiffLine[] = [];
let lineNumber = 1;
for (const change of changes) {
// Split change value into individual lines, removing trailing newline
const changeLines = change.value.replace(/\r?\n$/, '').split(/\r?\n/);
for (const content of changeLines) {
if (change.added) {
result.push({ type: 'added', content, lineNumber: lineNumber++ });
} else if (change.removed) {
result.push({ type: 'removed', content, lineNumber: lineNumber++ });
} else {
result.push({ type: 'context', content, lineNumber: lineNumber++ });
}
}
}
return result;
}
```
**Modified `generateDiff()`**:
```typescript
function generateDiff(oldLines: string[], newLines: string[]): DiffLine[] {
// Fallback to semantic diffing for large files to prevent OOM
if (oldLines.length * newLines.length > MAX_LCS_CELLS) {
return generateDiffFallback(oldLines, newLines);
}
// Original LCS-based algorithm
const matrix = computeLCSMatrix(oldLines, newLines);
// ... rest unchanged ...
}
```
### Visual Behavior Difference
| File Pair | Strategy | Visual Quality |
|-----------|----------|---------------|
| < 1000×1000 | LCS | Precise character-level alignment |
| > 1000×1000 | Semantic | Groups consecutive changes differently, but correct |
The fallback is semantically correct but may group consecutive changes differently.
For most real code diffs, the visual difference is negligible.
### Precedent in Codebase
`ReviewDiffContent.tsx` already uses `diffLines()` successfully:
```typescript
const diffResult = diffLines(original ?? '', modified ?? '');
```
---
## Fix #1+#2: Unified Line Counting
**Confidence: 7/10 → UPGRADED to 9.5/10 (after deep research)**
**Effort: ~4-6 hours**
### Deep Research Upgrade (2026-02-26)
Deep research agent (76.1k tokens, 12 tool uses) found a significantly more reliable approach:
1. **UnifiedLineCounter** — единая утилита на `diffLines()` для ALL counting paths
2. **filesSeen tracking** — Set для отслеживания Write (новый файл vs перезапись)
3. **File-history backups** — использование `~/.claude/file-history/` для получения оригинального контента при Write-update (вместо приблизительной оценки "assume ~same amount removed")
4. **buildTimeline fix** — ChangeExtractorService.buildTimeline тоже должен использовать `diffLines()` вместо `split('\n').length`
**Ключевое улучшение**: вместо `linesRemoved += added` (assume ~same) для Write-update, агент обнаружил что можно получить оригинальный контент через тот же FileContentResolver/file-history pipeline и сделать точный diff.
### Current State: 3 Independent Algorithms
#### MemberStatsComputer (`src/main/services/team/MemberStatsComputer.ts`)
**Edit** (lines 189-202):
```typescript
const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0;
const fileAdded = newLines > oldLines ? newLines - oldLines : 0;
const fileRemoved = oldLines > newLines ? oldLines - newLines : 0;
```
- WRONG when content changes but line count stays same
**Write** (lines 204-214):
```typescript
const fileAdded = writeContent.split('\n').length;
linesAdded += fileAdded;
addFileLines(input.file_path, fileAdded, 0); // Always removals = 0!
```
- NEVER counts removals
**MultiEdit** (lines 216-229):
- Same pattern as Write: only additions, no removals
**Bash** (lines 232-243):
- Heuristic `estimateBashLinesChanged()` (~30-40% coverage)
#### ChangeExtractorService (`src/main/services/team/ChangeExtractorService.ts`)
**countLines** (lines 463-473): Uses `diffLines()` — CORRECT
**buildTimeline** (lines 426-427): Uses `split('\n').length` — INCONSISTENT with own countLines!
#### FileContentResolver (`src/main/services/team/FileContentResolver.ts`)
Lines 141-156: Uses `diffLines()` — CORRECT
### Who Consumes These Counts
| Source | Consumer | UI |
|--------|----------|-----|
| MemberStatsComputer | MemberStatsTab.tsx | Session analytics "+X / -Y" |
| ChangeExtractorService | ChangeStatsBadge.tsx | File tree badges |
| ChangeExtractorService | ReviewApplierService.ts | Diff hunks |
| FileContentResolver | CodeMirrorDiffView.tsx | Full file diff display |
### Proposed Fix
**Phase 1: Create UnifiedLineCounter**
```typescript
// src/main/services/team/UnifiedLineCounter.ts
import { diffLines } from 'diff';
export class UnifiedLineCounter {
static countLines(oldStr: string, newStr: string): { added: number; removed: number } {
if (!oldStr && !newStr) return { added: 0, removed: 0 };
const changes = diffLines(oldStr, newStr);
let added = 0;
let removed = 0;
for (const c of changes) {
if (c.added) added += c.count ?? 0;
if (c.removed) removed += c.count ?? 0;
}
return { added, removed };
}
}
```
**Phase 2: Migrate MemberStatsComputer Edit**
```typescript
// Replace lines 189-202
const { added: fileAdded, removed: fileRemoved } = UnifiedLineCounter.countLines(oldStr, newStr);
```
**Phase 3: Fix Write Operations (Bug #2)**
Track file creation vs update during JSONL parse:
```typescript
const filesSeen = new Set<string>();
// In Write handler:
if (toolName === 'Write') {
const filePath = typeof input.file_path === 'string' ? input.file_path : '';
const writeContent = typeof input.content === 'string' ? input.content : '';
const isNewFile = !filesSeen.has(filePath);
filesSeen.add(filePath);
if (writeContent) {
if (isNewFile) {
// New file creation — all lines are additions
const { added } = UnifiedLineCounter.countLines('', writeContent);
linesAdded += added;
if (filePath) addFileLines(filePath, added, 0);
} else {
// File replacement — assume full rewrite (conservative estimate)
const { added } = UnifiedLineCounter.countLines('', writeContent);
linesAdded += added;
linesRemoved += added; // Assume ~same amount removed
if (filePath) addFileLines(filePath, added, added);
}
}
}
```
**Phase 4: Fix buildTimeline in ChangeExtractorService**
```typescript
// Replace lines 426-427
const { added, removed } = UnifiedLineCounter.countLines(s.oldString, s.newString);
// Use `added` and `removed` instead of split('\n').length arithmetic
```
**Phase 5: Keep Bash As-Is**
Fundamental limitation — command string has no execution output.
### Risk Assessment
| Risk | Level | Mitigation |
|------|-------|-----------|
| Write removals estimation inaccurate | HIGH | `filesSeen` Set tracks if file existed before; conservative "full rewrite" estimate |
| Line count numbers change in UI | MEDIUM | Expected — numbers become MORE accurate |
| Historical data shows different numbers | LOW | Accept as one-time correction |
| Circular dependency (MemberStatsComputer → FileContentResolver) | NONE | UnifiedLineCounter is independent utility |
### Open Questions
1. **Write-update without oldString**: `filesSeen` approach assumes sequential JSONL parsing. If messages are out of order, may misclassify. Need to verify JSONL ordering.
2. **Performance**: `diffLines()` is heavier than `split().length`. For sessions with 1000+ Edit calls, could add latency. Need to benchmark.
3. **Bash estimation**: Keep as-is or drop entirely? Current tooltip says "Approximate" — may be enough.
---
## Fix #6+#7: Hunk↔Snippet Mapping + indexOf
**Confidence: 5/10 → UPGRADED to 9/10 (after deep research)**
**Effort: ~150 LOC**
### Deep Research Upgrade (2026-02-26)
Deep research agent (83.8k tokens, 24 tool uses) found a significantly more reliable approach:
1. **HunkSnippetMatcher** — отдельный класс для маппинга с fallback chain:
- Level 1: `contextHash` — хеш ±3 строк контекста вокруг edit site, вычисляется при извлечении snippet в ChangeExtractorService
- Level 2: `structuredPatch()` content overlap — hunk added/removed lines vs snippet newString/oldString
- Level 3: `indexOf` с disambiguation через oldString proximity scoring
2. **contextHash на SnippetDiff** — новое поле, вычисляется один раз при создании snippet, используется для быстрого matching без повторного парсинга
3. **Position-aware rejection** — вместо `content.indexOf(snippet.newString)` (первое вхождение), ищет ВСЕ вхождения и выбирает ближайшее к hunk position
4. **Fallback chain** — если contextHash не матчит → content overlap → indexOf, каждый уровень менее точный но покрывает больше кейсов
**Ключевое улучшение**: добавление `contextHash` поля в SnippetDiff при извлечении (в ChangeExtractorService) даёт O(1) matching вместо O(n×m) content scanning.
### Current Architecture Flow
```
CodeMirrorDiffView.tsx (lines 427, 443)
computeHunkIndexAtPos(state, pos) → hunkIndex: number
onRejectRef.current?.(idx) // onHunkRejected callback
IPC: team:applyReviewDecisions
ReviewApplierService.rejectHunks(filePath, original, modified, hunkIndices, snippets)
trySnippetLevelReject(modified, hunkIndices, snippets)
snippetsToReject = hunkIndices.map(idx => validSnippets[idx]) // ← BUG: 1:1 assumption
content.indexOf(snippet.newString) // ← BUG: first occurrence only
```
### Data Available in Snippets
From `ChangeExtractorService` (SnippetDiff type):
- `oldString` / `newString` — actual content
- `toolName` — Edit, Write, MultiEdit
- `toolUseId` — unique ID
- `timestamp` — when it happened
- `type` — 'edit' | 'write-new' | 'write-update' | 'multi-edit'
- `isError` — whether tool errored
- `replaceAll` — for Edit with replace_all flag
- **NO line numbers** — this is the core problem
### Data Available in Hunks
From `structuredPatch()` (npm `diff` package):
```typescript
interface StructuredPatchHunk {
oldStart: number; // Line number in original (1-based)
oldLines: number; // Line count in original
newStart: number; // Line number in modified (1-based)
newLines: number; // Line count in modified
lines: string[]; // Actual diff lines (+, -, space context)
}
```
From CodeMirror's `getChunks()`:
```typescript
chunks: {
fromA: number // Original doc character position
toA: number // Original doc character position
fromB: number // Modified doc character position
toB: number // Modified doc character position
}[]
```
### Proposed Fix: 3 Phases
#### Phase 1: `buildHunkToSnippetMapping()`
Build explicit mapping using content overlap detection:
```typescript
private buildHunkToSnippetMapping(
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Map<number, Set<number>> {
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return new Map();
const mapping = new Map<number, Set<number>>();
for (const hunkIdx of hunkIndices) {
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
const hunk = patch.hunks[hunkIdx];
const snippetSet = new Set<number>();
// Extract added/removed content from hunk
const addedLines = hunk.lines.filter(l => l.startsWith('+')).map(l => l.slice(1));
const removedLines = hunk.lines.filter(l => l.startsWith('-')).map(l => l.slice(1));
const addedContent = addedLines.join('\n');
const removedContent = removedLines.join('\n');
for (let sIdx = 0; sIdx < snippets.length; sIdx++) {
const snippet = snippets[sIdx];
if (snippet.isError) continue;
const matchesNew = addedContent.includes(snippet.newString);
const matchesOld = removedContent.includes(snippet.oldString);
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
if (matchesNew) snippetSet.add(sIdx);
} else {
// For edits: require both old AND new match for higher confidence
if (matchesNew && matchesOld) {
snippetSet.add(sIdx);
} else if (matchesNew) {
snippetSet.add(sIdx); // Lower confidence fallback
}
}
}
mapping.set(hunkIdx, snippetSet);
}
return mapping;
}
```
#### Phase 2: Position-Aware `findSnippetPosition()`
Replace indexOf with context-aware search:
```typescript
private findSnippetPosition(
snippet: SnippetDiff,
content: string
): number {
const { newString, oldString } = snippet;
// Fast path: newString is unique in content
const firstPos = content.indexOf(newString);
if (firstPos === -1) return -1;
const lastPos = content.lastIndexOf(newString);
if (firstPos === lastPos) return firstPos; // Only one occurrence — safe
// Multiple occurrences — use oldString context to disambiguate
// Search for each occurrence and check if surrounding context matches oldString
const positions: number[] = [];
let searchStart = 0;
while (true) {
const pos = content.indexOf(newString, searchStart);
if (pos === -1) break;
positions.push(pos);
searchStart = pos + 1;
}
// For each candidate position, check if oldString context is nearby
if (oldString) {
for (const pos of positions) {
// Look for oldString within ±1000 chars of this position
// (in the original document, oldString would be at roughly the same position)
const nearbyStart = Math.max(0, pos - 1000);
const nearbyEnd = Math.min(content.length, pos + newString.length + 1000);
const nearby = content.substring(nearbyStart, nearbyEnd);
// If any unique token from oldString appears nearby, this is likely correct
const oldTokens = oldString.split(/\s+/).filter(t => t.length > 3);
const matchScore = oldTokens.filter(t => nearby.includes(t)).length;
if (matchScore > oldTokens.length * 0.5) {
return pos; // >50% of oldString tokens found nearby
}
}
}
// Last resort: return first position with warning
return firstPos;
}
```
#### Phase 3: Update `trySnippetLevelReject()` Signature
```typescript
// Pass `original` through to enable mapping and context matching
private trySnippetLevelReject(
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[],
original: string // NEW parameter
): RejectResult | null {
const validSnippets = snippets.filter(s => !s.isError);
if (validSnippets.length === 0) return null;
// NEW: Build mapping instead of assuming 1:1
const hunkToSnippets = this.buildHunkToSnippetMapping(
original, modified, hunkIndices, validSnippets
);
// Collect all snippets to reject
const snippetIndices = new Set<number>();
for (const indices of hunkToSnippets.values()) {
indices.forEach(idx => snippetIndices.add(idx));
}
const snippetsToReject = Array.from(snippetIndices)
.map(idx => validSnippets[idx])
.filter(Boolean);
// NEW: Position-aware matching
const positioned = snippetsToReject
.map(snippet => ({
snippet,
pos: this.findSnippetPosition(snippet, modified)
}))
.filter(item => item.pos !== -1)
.sort((a, b) => b.pos - a.pos); // Descending for safe replacement
if (positioned.length !== snippetsToReject.length) {
return null; // Fallback to hunk-level
}
let content = modified;
for (const { snippet, pos } of positioned) {
if (snippet.type === 'write-new') continue;
if (snippet.replaceAll) {
content = content.split(snippet.newString).join(snippet.oldString);
} else {
content = content.substring(0, pos) + snippet.oldString +
content.substring(pos + snippet.newString.length);
}
}
return { success: true, newContent: content, hadConflicts: false };
}
```
### Edge Cases & Concerns
| Case | Current Behavior | After Fix |
|------|------------------|-----------|
| Multiple Edit → 1 Hunk | Assumes hunkIdx = snippetIdx (WRONG) | Content overlap mapping (CORRECT) |
| 1 Write → 2 Hunks | Maps to wrong snippet | Maps via content match |
| Duplicate code in file | Corrupts first occurrence | Context-aware disambiguation |
| Short snippet (1 line) | indexOf works | May still match wrong occurrence if context is ambiguous |
| No oldString context | N/A | Falls back to first indexOf match (same as before) |
| replaceAll snippets | Works (replaces all) | Still works (replaceAll logic unchanged) |
### Open Questions
1. **Short snippets**: If `newString` is `"return true;"` and appears 10 times — context matching may fail. Need hunk line range to narrow search.
2. **Performance**: `structuredPatch()` is called again in `buildHunkToSnippetMapping` (already called in `rejectHunks`). Should cache the patch result.
3. **MultiEdit**: Creates 1 snippet but may affect multiple non-contiguous regions. `buildHunkToSnippetMapping` should handle this but needs testing.
4. **Overlapping snippets**: Two snippets touching the same line range. Position-aware replacement from end (descending sort) handles this, but still fragile.
5. **`original` parameter**: Need to thread it through from all callers: `rejectHunks()`, `previewReject()`, `acceptHunks()`.
### Why Confidence is Only 5/10
The core issue is that **snippets have NO line numbers**. All matching is content-based (heuristic). For short/common snippets, disambiguation may fail. A truly robust fix would require:
1. Adding line number tracking to `SnippetDiff` during extraction
2. Or using `structuredPatch` bidirectionally to map hunks to file regions
Both are larger architectural changes that go beyond the current fix scope.
---
## Summary: Implementation Priority
| Fix | Confidence | Effort | Status |
|-----|-----------|--------|--------|
| #11 Cache TTL | 10/10 | 2 lines | DONE (commit d97a757) |
| #10 OOM safeguard | 9/10 | ~30 LOC | DONE (commit d97a757) |
| #1+#2 Line counting | 9.5/10 (was 7) | ~4-6h | Deep research done, pending implementation |
| #6+#7 Hunk mapping | 9/10 (was 5) | ~150 LOC | Deep research done, pending implementation |
### Also Fixed in d97a757
- #5: computeHunkIndexAtPos → nearest hunk (CodeMirrorDiffView.tsx)
- #8: Skeleton flash after save (changeReviewSlice.ts)
- #9: CRLF normalization (DiffViewer.tsx)
- #19: threshold 1.0 → 0.85 (CodeMirrorDiffView.tsx)
- #20: useEffect dependency array (FileSectionDiff.tsx)
- #25: bash relative paths (MemberStatsComputer.ts)
- #26: empty line ?? fix (DiffViewer.tsx)
---
# Round 2: Deep Research (3 parallel agents, 280k+ tokens)
## Agent 1: UnifiedLineCounter — Exact Line Numbers & Code Paths
### 6 точек с неправильным подсчётом строк
| # | Файл | Метод | Строки | Алгоритм | Проблема | Влияет на |
|---|------|-------|--------|----------|----------|-----------|
| 1 | MemberStatsComputer.ts | parseFile (Edit) | 193-196 | `split('\n').length` diff | Не считает реальные diff операции | MemberFullStats → Team stats |
| 2 | MemberStatsComputer.ts | parseFile (Write) | 208 | `split('\n').length` абсолют | `removed` всегда 0 | MemberFullStats → Team stats |
| 3 | MemberStatsComputer.ts | parseFile (NotebookEdit) | 220 | `split('\n').length` абсолют | Аналогично Write | MemberFullStats → Team stats |
| 4 | ChangeExtractorService.ts | buildTimeline() | 426-427 | `split('\n').length` diff | Не использует собственный `countLines()`! | FileEditTimeline → UI |
| 5 | ChangeExtractorService.ts | generateEditSummary() | 445, 449-450 | `split('\n').length` | Дублирует логику подсчёта | FileEditEvent.summary |
| 6 | ChangeExtractorService.ts | aggregateByFile() | 391 | `countLines()``diffLines()` | **ПРАВИЛЬНО** | FileChangeSummary |
### Точные строки для замены
**MemberStatsComputer.ts:193-196 (Edit):**
```typescript
// ТЕКУЩЕЕ (НЕПРАВИЛЬНО):
const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0;
const fileAdded = newLines > oldLines ? newLines - oldLines : 0;
const fileRemoved = oldLines > newLines ? oldLines - newLines : 0;
// ЗАМЕНА: const { added: fileAdded, removed: fileRemoved } = UnifiedLineCounter.countLines(oldStr, newStr);
```
**MemberStatsComputer.ts:208 (Write):**
```typescript
// ТЕКУЩЕЕ (НЕПРАВИЛЬНО):
const fileAdded = writeContent.split('\n').length;
linesAdded += fileAdded;
addFileLines(input.file_path, fileAdded, 0); // removed всегда 0!
// ЗАМЕНА: использовать diffLines('', writeContent) для write-new, отслеживать через filesSeen
```
**MemberStatsComputer.ts:220 (NotebookEdit):**
```typescript
// ТЕКУЩЕЕ (НЕПРАВИЛЬНО):
const fileAdded = src.split('\n').length;
// ЗАМЕНА: аналогично Write
```
**ChangeExtractorService.ts:426-427 (buildTimeline):**
```typescript
// ТЕКУЩЕЕ (НЕПРАВИЛЬНО):
linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length),
linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length),
// ЗАМЕНА: const { added, removed } = this.countLines(s.oldString, s.newString);
```
**ChangeExtractorService.ts:445,449-450 (generateEditSummary):**
```typescript
// ТЕКУЩЕЕ (НЕПРАВИЛЬНО):
const lines = snippet.oldString.split('\n').length;
const added = snippet.newString.split('\n').length;
const removed = snippet.oldString.split('\n').length;
// ЗАМЕНА: использовать this.countLines()
```
### Критичные находки
- `diffLines` НЕ импортирован в MemberStatsComputer — нужно добавить
- `seenFiles` в ChangeExtractorService (строка 207-265) определяет write-new vs write-update, но НЕ учитывает файлы существовавшие ДО сессии
- JSONL парсится строго последовательно — filesSeen паттерн безопасен
- Performance: `diffLines()` для типичных snippets (<50 строк) = микросекунды, no risk
- **НЕТ ТЕСТОВ** для countLines, buildTimeline, generateEditSummary!
---
## Agent 2: HunkSnippetMatcher — Exact Bug Chain
### Полная цепочка бага (от UI до backend)
```
1. UI: CodeMirrorDiffView.tsx → computeHunkIndexAtPos(state, pos) → chunk index
2. UI: FileSectionDiff.tsx:123-124 → onHunkRejected(file.filePath, idx)
3. Store: changeReviewSlice.ts:610 →
for (let i = 0; i < file.snippets.length; i++) {
hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending';
}
// ← СТРОИТ hunkDecs по SNIPPET INDICES, но hunk indices != snippet indices!
4. IPC: review.ts:191-203 → handleRejectHunks(teamName, filePath, original, modified, hunkIndices, snippets)
5. Backend: ReviewApplierService.ts:71 → trySnippetLevelReject(modified, hunkIndices, snippets)
6. Bug #1: строка 340-342 → hunkIndices.map(idx => validSnippets[idx]) // 1:1 ASSUMPTION
7. Bug #2: строка 353 → content.indexOf(snippet.newString) // ПЕРВОЕ ВХОЖДЕНИЕ
8. Fallback: строка 87 → tryHunkLevelReject() (structuredPatch + inverse)
```
### SnippetDiff — полное определение (shared/types/review.ts:1-12)
```typescript
export interface SnippetDiff {
toolUseId: string;
filePath: string;
toolName: 'Edit' | 'Write' | 'MultiEdit';
type: 'edit' | 'write-new' | 'write-update' | 'multi-edit';
oldString: string; // ← ДО изменения (пусто для write-new)
newString: string; // ← ПОСЛЕ изменения
replaceAll: boolean;
timestamp: string;
isError: boolean;
// НЕТ: contextHash, lineNumber, position — core problem!
}
```
### Как создаются snippets (ChangeExtractorService.ts:176-308)
- **Edit** (строки 239-258): берёт `input.old_string`, `input.new_string` из JSONL. НЕТ доступа к полному файлу.
- **Write** (строки 259-277): `newString = input.content` (весь файл!). 1 Write → 1 snippet → N hunks при patch.
- **MultiEdit** (строки 278-302): `for (const edit of edits)` → N snippets с ОДНИМ toolUseId.
### Edge cases
| Case | Текущее поведение | Опасность |
|------|-------------------|-----------|
| `snippet.newString === ""` (deletion) | `indexOf("") = 0` | Всегда находит позицию 0! |
| `newString` встречается 5+ раз | `indexOf` = первое | Неправильная позиция |
| `replaceAll = true` | `content.split(new).join(old)` | OK, но конфликт с другими snippets |
| MultiEdit | N snippets с одним toolUseId | Могут слиться в 1 hunk |
| 1 Write → N hunks | hunkIdx > snippets.length | Out of bounds! |
### Proposed HunkSnippetMatcher Architecture
```typescript
class HunkSnippetMatcher {
// Fallback chain (от самого точного к менее точному):
// 1. contextHash match (если добавлено к SnippetDiff)
// 2. structuredPatch content overlap (hunk lines vs snippet strings)
// 3. indexOf с disambiguation через oldString proximity
matchAll(original, modified, hunks, snippets): Map<hunkIndex, SnippetDiff[]>
// Один hunk может соответствовать нескольким snippets!
// Один snippet может создать несколько hunks (Write)!
}
```
---
## Agent 3: Integration Points & Conflicts
### 3 критичных конфликта между #1+#2 и #6+#7
#### КОНФЛИКТ #1: countLines() в ChangeExtractorService
- #1+#2 исправляет countLines() (строки 463-473)
- #6+#7 зависит от результатов countLines() в aggregateByFile()
- **Решение:** Реализовать #1+#2 ПЕРВЫМ
#### КОНФЛИКТ #2: FileContentResolver переопределяет numbers
- FileContentResolver.getFileContent() (строки 144-156) ПЕРЕСЧИТЫВАЕТ linesAdded/linesRemoved из full content
- Может перезаписать значения из ChangeExtractorService
- **Решение:** Это OK — FileContentResolver использует более точный метод (full content diff)
#### КОНФЛИКТ #3: Порядок snippets
- Если #1+#2 изменит фильтрацию/порядок snippets → hunk indices в #6+#7 сломаются
- **Решение:** #1+#2 НЕ меняет порядок snippets, только алгоритм подсчёта
### Порядок реализации: #1+#2 ПЕРВЫМ, потом #6+#7
### Карта зависимостей SnippetDiff (13 файлов):
```
Создание:
ChangeExtractorService.parseJSONLFile() [строки 177-308]
Агрегация:
ChangeExtractorService.aggregateByFile() [строки 368-415]
IPC передача (5 каналов):
REVIEW_GET_AGENT_CHANGES, REVIEW_GET_TASK_CHANGES,
REVIEW_GET_FILE_CONTENT, REVIEW_REJECT_HUNKS, REVIEW_PREVIEW_REJECT
Потребители:
FileContentResolver.resolveFileContent() — реконструкция
ReviewApplierService.trySnippetLevelReject() — reject/accept
ReviewDiffContent → SnippetDiffView — UI рендеринг
ChangeStatsBadge — отображает +/-
```
### Если добавить contextHash к SnippetDiff:
- shared/types/review.ts — ОБЯЗАТЕЛЬНО (тип)
- ChangeExtractorService.ts — ОБЯЗАТЕЛЬНО (вычисление при создании)
- Остальные 11 файлов — НЕ ТРЕБУЮТ изменений (optional field, JSON-safe)
- Preload/IPC — не требуют изменений (JSON сериализация OK)
### Тестовая инфраструктура:
- MemberStatsComputer.test.ts — ЕСТЬ (75 строк, только Bash эвристика)
- ChangeExtractorService.test.ts — **НЕ СУЩЕСТВУЕТ!**
- ReviewApplierService.test.ts — **НЕ СУЩЕСТВУЕТ!**
- **Нужно создать оба перед реализацией**

View file

@ -0,0 +1,239 @@
# Diff View — Round 3: Deep Research (Remaining Limitations)
Date: 2026-02-26
Source: 3 parallel research agents (~260k tokens total)
---
## Исследуемые проблемы
После реализации UnifiedLineCounter (#1+#2) и HunkSnippetMatcher (#6+#7) осталось 5 ограничений.
Исследованы 3 из них (самые критичные):
| # | Проблема | Уверенность до ресёрча | После ресёрча |
|---|----------|----------------------|---------------|
| A | Content overlap false positives + false negatives | 6/10 | 9/10 — root cause найден |
| B | changeReviewSlice hunk index mismatch | 4/10 | 9.5/10 — полная трассировка |
| C | fileLastContent для Edit (дубли oldStr) | 7/10 | 8.5/10 — JSONL подтверждение |
---
## A. Content Overlap: False Positives + False Negatives
### A1. FALSE NEGATIVES (критичнее)
**Root cause (уточнён Round 3.1)**: НЕ whitespace (предыдущий анализ был неверен — Edit tool хранит точный текст с indentation). Реальная причина: **context lines** в hunk отбрасываются при matching.
**Механизм**:
- `HunkSnippetMatcher` берёт только `+` и `-` строки из хунка, отбрасывая context (` ` prefix)
- `removedContent` = join только `-` строк → контекстные строки МЕЖДУ изменёнными строками теряются
- Snippet `oldString` содержит ВСЕ строки (включая context), т.к. это точная подстрока файла
- `includes()` в обе стороны фейлится: ни `removedContent ⊂ oldString`, ни наоборот
**Concrete proof** (из ресёрча):
```typescript
// Claude's Edit:
// old_string = "interface UserConfig {\n name: string;\n age: number;\n email: string;\n active: boolean;\n premium: boolean;\n}"
// new_string = "interface UserSettings {\n name: string;\n age: number;\n email: string;\n active: boolean;\n isPremium: boolean;\n}"
// structuredPatch() hunk:
// -interface UserConfig { ← removed
// name: string; ← CONTEXT (discarded!)
// age: number; ← CONTEXT (discarded!)
// email: string; ← CONTEXT (discarded!)
// active: boolean; ← CONTEXT (discarded!)
// - premium: boolean; ← removed
// +interface UserSettings { ← added
// + isPremium: boolean; ← added
// removedContent = "interface UserConfig {\n premium: boolean;"
// oldString = "interface UserConfig {\n name: string;\n age: number;\n email: string;\n active: boolean;\n premium: boolean;\n}"
// removedContent.includes(oldString) → NO
// oldString.includes(removedContent) → NO (context lines break contiguity)
// ❌ FALSE NEGATIVE — snippet не матчится к своему хунку
```
**`structuredPatch()` merge threshold**: `context * 2` = 8 строк (default context=4). Хунки мержатся если gap ≤ 8 строк.
**Частота**: ВЫСОКАЯ. Любой Edit где Claude захватывает блок с неизменёнными строками внутри:
- Переименование interface/class + изменение полей
- Смена параметров функции + изменение body
- Конфигурационные объекты (часть полей меняется, часть нет)
**Решение**: Реконструировать "old side" и "new side" хунка включая context lines:
```typescript
// Вместо только +/- строк:
const oldSideContent = hunk.lines
.filter(l => l.startsWith(' ') || l.startsWith('-'))
.map(l => l.slice(1)).join('\n');
const newSideContent = hunk.lines
.filter(l => l.startsWith(' ') || l.startsWith('+'))
.map(l => l.slice(1)).join('\n');
// oldSideContent.includes(snippet.oldString) → TRUE ✓
// newSideContent.includes(snippet.newString) → TRUE ✓
```
**Уверенность**: 9.5/10 что реальный баг. 9/10 что fix через old/new side reconstruction работает.
### A2. FALSE POSITIVES
**Root cause**: Два сниппета с одинаковым `oldString`/`newString` оба матчатся к одному хунку.
**Пример**: Два Edit-а меняют одинаковую строку import в разных местах файла:
```
Snippet 0: oldString="import { X }", newString="import { X, Y }" (line 5)
Snippet 1: oldString="import { X }", newString="import { X, Y }" (line 50)
```
Оба матчатся к хунку, который содержит added line `"import { X, Y }"`.
При reject оба сниппета попадают в rejection set → откатываются ОБА вместо одного.
**Решение**: Confidence scoring + одноразовое присвоение:
- После матча snippet→hunk, убрать snippet из пула кандидатов
- Приоритизация: snippet с ОБОИМИ `matchesNew && matchesOld` > только с одним
- При равных — первый по порядку (сохраняет хронологию Edit-ов)
### A3. Производительность O(n×m)
**Текущее**: H хунков × S сниппетов × `includes()` (O(L) каждый).
**Реальный масштаб**: типичный review — 5-15 файлов, 3-10 хунков × 3-10 сниппетов на файл = 9-100 сравнений. Для `includes()` на строках <1KB это **микросекунды**.
**Вердикт**: НЕ нужно оптимизировать. Проблема может возникнуть при 200+ хунках, но такие файлы нереалистичны для code review.
---
## B. changeReviewSlice: Hunk Index Mismatch
### B1. Суть бага
`hunkDecisions` — это `Record<number, HunkDecision>`, но ключи имеют **двойную семантику**:
- До mount CodeMirror: индекс = `snippets.length` (из API)
- После mount CodeMirror: индекс = `getChunks().length` (из diff алгоритма)
- Это **РАЗНЫЕ числа**.
### B2. Три точки разлома
**Точка 1: Accept All до mount CodeMirror** (`changeReviewSlice.ts:385-399`)
```typescript
const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts);
// fileChunkCounts[filePath] ещё undefined → count = snippets.length (3)
for (let i = 0; i < 3; i++) {
newHunkDecisions[`${filePath}:${i}`] = 'accepted'; // Только 0,1,2
}
```
CodeMirror позже покажет 5 чанков → чанки 3,4 навсегда `pending`.
**Точка 2: Replay после mount** (`CodeMirrorDiffUtils.ts:108-114`)
```typescript
for (let i = 0; i < result.chunks.length; i++) { // 0..4
const key = `${filePath}:${i}`;
const d = hunkDecisions[key]; // Находит только 0,1,2
}
```
**Точка 3: Backend application** (`ReviewApplierService.ts:278-280`)
```typescript
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
.filter(([, d]) => d === 'rejected')
.map(([idx]) => parseInt(idx, 10));
// Индексы [0,1,4,5] → но snippets.length = 3!
```
### B3. Полная трассировка
```
User → "Accept All"
→ acceptAllFile() loops snippets.length (3) → stores decisions {0,1,2}
→ CodeMirror mounts → getChunks() returns 5 chunks
→ replayHunkDecisions() loops 0..4 → only finds 0,1,2 → chunks 3,4 = "pending"
→ User sees mixed state (3 accepted, 2 pending)
→ User clicks "Apply Review"
→ Backend gets hunkDecisions {0,1,2} → indices 3,4 NOT rejected → partial application
```
### B4. Таблица расхождений
| Точка | Источник индексов | Семантика | Пример |
|-------|-------------------|-----------|--------|
| `file.snippets.length` | API | Кол-во сниппетов | 3 |
| `hunkDecisions` (initial) | snippets.length | Snippet-based | {0,1,2} |
| CodeMirror `getChunks()` | Diff algorithm | Structural hunks | 5 chunks |
| UI click handler | CM state | CM chunk index | 0..4 |
| Backend `rejectedHunkIndices` | decisions object | Смешанные! | [0,1,4,5] |
### B5. Решение
**Единый источник правды**: hunkDecisions ВСЕГДА должны индексироваться по CM chunk index.
1. **При первом mount CodeMirror**: записать `fileChunkCounts[filePath]` = chunks.length
2. **Accept All / Reject All**: ЖДАТЬ пока fileChunkCounts доступен (lazy init)
3. **Fallback** если CM ещё не mounted: вычислить `structuredPatch()` на frontend и использовать `patch.hunks.length` как count
4. **Backend**: `rejectedHunkIndices` — это ВСЕГДА индексы в `structuredPatch().hunks`, не в snippets
---
## C. fileLastContent: Дубли oldStr при Edit
### C1. Данные из JSONL
Проверено 29 реальных Edit tool_use блоков:
- **0** содержат line_number или position
- Доступны ТОЛЬКО: `file_path`, `old_string`, `new_string`, `replace_all`
- **Нет способа** узнать какое именно вхождение oldStr редактировалось
### C2. Частота проблемы
- ~3% Edit-ов имеют `oldString` с точными дубликатами (markdown `---`, одинаковые import-ы)
- ~100% содержат **строки**, которые повторяются в файле (но не весь `oldString` целиком)
- **Реальная частота бага**: 5-10% multi-edit сессий где Claude последовательно редактирует разные вхождения одного паттерна
### C3. Пример
```json
// Turn 1: Edit file.ts
{ "old_string": "import { A } from './a';\nimport { B } from './b';",
"new_string": "import { A } from './a';\nimport { B } from './b';\nimport { C } from './c';" }
// Turn 2: Edit file.ts (хочет изменить 2-й import)
{ "old_string": "import { B } from './b';",
"new_string": "import { B as UsedB } from './b';" }
```
Turn 2: `indexOf("import { B } from './b';")` найдёт ПЕРВОЕ вхождение — возможно не то, которое Claude хотел изменить (после изменений Turn 1 есть два вхождения).
### C4. Что НЕЛЬЗЯ сделать
- Нет line number в JSONL → нельзя точно определить вхождение
- Нет tool_result content (не всегда) → нельзя проверить результат
- Нельзя модифицировать формат JSONL → работаем с тем что есть
### C5. Решение
**Прагматичный фикс**: вместо `indexOf()` → sequential application.
Ключевое наблюдение: Claude Code's Edit tool **сам** использует `indexOf()` при `replace_all: false` — т.е. он тоже заменяет ПЕРВОЕ вхождение. Значит наш `indexOf()` **корректен** для однократных Edit-ов.
Проблема возникает только когда предыдущий Edit СОЗДАЛ дубликат (добавил строку, идентичную существующей). Это edge case edge case.
**Вывод**: текущая реализация `indexOf()`**правильная** для подавляющего большинства случаев, т.к. она зеркалит поведение самого Edit tool. Фикс не нужен.
Единственный реальный improvement: после Edit, если `oldStr` НЕ найден в `prev``fileLastContent.delete(editPath)` (invalidate, чтобы не накапливать ошибку).
---
## Приоритеты реализации
| # | Фикс | Сложность | Влияние | Приоритет |
|---|------|-----------|---------|-----------|
| A1 | Whitespace normalization в hasContentOverlap | Низкая (5 строк) | Высокое — фиксит false negatives | **P0** |
| A2 | Confidence scoring + one-shot matching | Средняя (~30 строк) | Среднее — фиксит false positives | **P1** |
| B | changeReviewSlice → CM chunk indices | Высокая (~100 строк) | Критичное — UI показывает неверное состояние | **P0** |
| C | fileLastContent invalidation при miss | Низкая (3 строки) | Низкое — edge case edge case | **P2** |
### Рекомендуемый порядок
1. **A1** (whitespace normalization) — быстрый win, минимальный риск
2. **A2** (confidence scoring) — укрепляет матчинг
3. **B** (changeReviewSlice) — самый сложный, но самый критичный для UX
4. **C** (fileLastContent) — текущая реализация уже корректна, добавить только safeguard

466
docs/research/git.md Normal file
View file

@ -0,0 +1,466 @@
# Исследование: встраивание Git UI в Electron + React приложение
> Дата: 2026-02-25
## TL;DR
**Готового `<GitPanel repo="/path" />` компонента не существует.** Все Git GUI (GitHub Desktop, GitButler, Ungit) — монолитные приложения с тесно связанными компонентами. Реалистичный путь — собрать из кирпичиков или встроить терминал с lazygit.
---
## Оглавление
1. [Git Backend библиотеки](#1-git-backend-библиотеки)
2. [UI-компоненты (npm-пакеты)](#2-ui-компоненты-npm-пакеты)
3. [Open-source Git GUI приложения (референсы)](#3-open-source-git-gui-приложения)
4. [IDE-embedded Git UI (не извлекаемые)](#4-ide-embedded-git-ui)
5. [Подходы к интеграции](#5-подходы-к-интеграции)
6. [Итоговая сравнительная таблица](#6-итоговая-сравнительная-таблица)
7. [Рекомендация](#7-рекомендация)
---
## 1. Git Backend библиотеки
Обеспечивают программный доступ к Git-операциям из Node.js/Electron.
### simple-git ⭐ РЕКОМЕНДУЕТСЯ
- **GitHub**: [simple-git-js/simple-git](https://github.com/simple-git-js/simple-git)
- **Stars**: ~3,550
- **npm downloads**: ~5.8M/week
- **Версия**: 3.32.2 (февраль 2026)
- **Лицензия**: MIT
- **Тип**: CLI wrapper (требует git binary)
- **Особенности**:
- Легковесная обертка вокруг `git` CLI
- ES Modules, CommonJS, TypeScript
- Async/await, promise chaining
- Progress monitoring для clone/checkout
- Concurrency control (`maxConcurrentProcesses`)
- **Плюсы**: Простейший API, самые высокие downloads, отличная TS поддержка, активно поддерживается
- **Минусы**: Требует установленный Git на машине; спавнит shell-процессы
```typescript
import simpleGit, { SimpleGit } from 'simple-git';
const git: SimpleGit = simpleGit('/path/to/repo');
const status = await git.status(); // modified, staged, not_added
const log = await git.log({ maxCount: 50 }); // hash, date, message, author
const diff = await git.diff(['--staged']); // staged diff
const branches = await git.branch(); // all branches
await git.add(['src/file.ts']);
await git.commit('fix: resolve issue');
await git.stash(['push', '-m', 'WIP']);
```
### isomorphic-git
- **GitHub**: [isomorphic-git/isomorphic-git](https://github.com/isomorphic-git/isomorphic-git)
- **Stars**: ~8,100
- **npm downloads**: ~300-600K/week
- **Версия**: 1.37.1 (февраль 2026)
- **Лицензия**: MIT
- **Тип**: Pure JavaScript Git implementation
- **Особенности**:
- Pure JS — zero native dependencies
- Работает в Node.js И в browser/renderer
- Clone, commit, push, pull, fetch, branch, merge, checkout
- 100% совместимость с canonical git
- Читает/пишет `.git` директорию напрямую
- **Плюсы**: Нет нативных зависимостей, работает везде
- **Минусы**: Медленнее нативных реализаций на больших репо; некоторые продвинутые git-фичи отсутствуют
### dugite
- **GitHub**: [desktop/dugite](https://github.com/desktop/dugite)
- **Stars**: ~495
- **npm downloads**: ~3-6K/week
- **Версия**: 2.7.1
- **Лицензия**: MIT
- **Тип**: Бандлит git binary (свой Git в пакете)
- **Особенности**:
- Поставляет скомпилированный Git binary — пользователю НЕ нужен установленный Git
- TypeScript
- Используется GitHub Desktop (проверено в production)
- Создан командой GitHub Desktop
- **Плюсы**: Гарантия наличия Git; battle-tested
- **Минусы**: Увеличивает размер бандла; возможные проблемы с corporate proxy
### nodegit ❌ НЕ РЕКОМЕНДУЕТСЯ
- **GitHub**: [nodegit/nodegit](https://github.com/nodegit/nodegit)
- **Stars**: ~5,750
- **Тип**: Native C++ bindings к libgit2
- **Проблемы**: Плохо поддерживается (последний stable-релиз много лет назад); нативный C++ build ломается; persistent проблемы совместимости с Electron
- **Вердикт**: Не использовать для новых проектов
---
## 2. UI-компоненты (npm-пакеты)
### Diff Viewers
#### @git-diff-view/react ⭐ РЕКОМЕНДУЕТСЯ
- **GitHub**: [MrWangJustToDo/git-diff-view](https://github.com/MrWangJustToDo/git-diff-view)
- **Версия**: 0.0.36 (февраль 2026, активно обновляется)
- **Лицензия**: MIT
- **Особенности**:
- GitHub-parity UI (выглядит как GitHub diff)
- Web Worker для 60fps рендеринга
- Split и unified views
- Zero dependencies, pure CSS
- SSR/RSC support
- **Virtual scrolling** — ~280ms рендер 10K+ строк
- Multi-framework (React, Vue, Solid, Svelte)
- **Плюсы**: Самый активно поддерживаемый; лучшая производительность; GitHub-quality UI
- **Минусы**: Pre-1.0 (v0.0.x)
#### react-diff-view
- **GitHub**: [otakustay/react-diff-view](https://github.com/otakustay/react-diff-view)
- **Stars**: ~977 | **Downloads**: ~140K/week
- **Версия**: 3.3.2
- **Лицензия**: MIT
- **Особенности**:
- Принимает `git diff -U1` output напрямую (самый git-native)
- Split и unified views
- Collapsed code expansion
- Code comments support
- Large diff lazy loading
- Гибкая система decoration/widget
- **Плюсы**: Самый Git-native; хорошая производительность; extensible
#### react-diff-viewer-continued
- **GitHub**: [ralzinov/react-diff-viewer-continued](https://github.com/ralzinov/react-diff-viewer-continued)
- **Версия**: 3.4.0
- **Лицензия**: MIT
- **Описание**: Maintained форк заброшенного react-diff-viewer. Split/inline view, word diff, GitHub-style
#### Monaco DiffEditor (@monaco-editor/react)
- **GitHub**: [suren-atoyan/monaco-react](https://github.com/suren-atoyan/monaco-react)
- **Описание**: VS Code Monaco Editor с встроенным DiffEditor
- **Плюсы**: Production-grade (тот же движок что в VS Code); отличная подсветка синтаксиса
- **Минусы**: Тяжелый бандл; overkill если нужен только просмотр diff
### Commit Graph Visualization
#### @dolthub/gitgraph-react
- **npm**: [@dolthub/gitgraph-react](https://www.npmjs.com/package/@dolthub/gitgraph-react)
- **Описание**: Живой форк архивированного @gitgraph/react, поддерживается DoltHub
- **Плюсы**: Активный форк; декларативный API
- **Минусы**: Кастомизирован под нужды DoltHub
#### @gitgraph/react ❌ АРХИВИРОВАН
- **GitHub**: [nicoespeon/gitgraph.js](https://github.com/nicoespeon/gitgraph.js)
- **Downloads**: ~4,300/week
- **Статус**: Архивирован с 2019. Автор рекомендует Mermaid.js
#### Mermaid.js + @mermaid-js/react-wrapper
- **GitHub**: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)
- **Stars**: ~60,000+
- **Лицензия**: MIT
- **Описание**: Нативный `gitGraph` тип диаграмм. Text-based DSL
- **Плюсы**: Огромное сообщество, активно поддерживается
- **Минусы**: Text-based input; больше для документации/иллюстраций, чем для интерактивных графов
#### commit-graph (CommitGraph)
- **GitHub**: [liuliu-dev/CommitGraph](https://github.com/liuliu-dev/CommitGraph)
- **Описание**: Interactive commit graph с infinite scrolling и pagination
- **Особенности**: `commitSpacing`, `branchSpacing`, `nodeRadius`, `branchColors`, `onCommitClick`
- **Плюсы**: Построен для реальных данных; пагинация
- **Минусы**: Новый, мало adoption
#### @gitkraken/gitkraken-components
- **npm**: v11.0.7 (февраль 2026)
- **Описание**: Shared React-компоненты между GitKraken Desktop и GitLens. Включает `GraphContainer` для commit graph
- **Плюсы**: Production-proven (GitKraken), активно обновляется
- **Минусы**: **Без документации**, требует React 17, undocumented API
### File Tree
#### react-arborist
- **GitHub**: [brimdata/react-arborist](https://github.com/brimdata/react-arborist)
- **Stars**: ~3,542 | **Downloads**: ~225K/week
- **Версия**: 3.4.3 (февраль 2025)
- **Лицензия**: MIT
- **Описание**: Полное tree view (как VS Code sidebar). Selection, multi-select, drag-and-drop, виртуализация, кастомный рендеринг нод
- **Использование**: Для git staging panel с file tree + status indicators
### Terminal Emulator
#### xterm.js (@xterm/xterm)
- **GitHub**: [xtermjs/xterm.js](https://github.com/xtermjs/xterm.js)
- **Описание**: Полный терминальный эмулятор в браузере/Electron. Используется VS Code, Hyper, Wave Terminal
- **React wrapper**: [Qovery/react-xtermjs](https://github.com/Qovery/react-xtermjs)
- **Использование**: Для встраивания lazygit/tig как терминальной панели
---
## 3. Open-source Git GUI приложения
### GitHub Desktop ⭐ ЛУЧШИЙ РЕФЕРЕНС
- **GitHub**: [desktop/desktop](https://github.com/desktop/desktop)
- **Stars**: ~21,000
- **Стек**: Electron + React + TypeScript
- **Git backend**: dugite
- **Лицензия**: MIT
- **Статус**: Активно поддерживается (февраль 2026)
- **Извлекаемость**: Монолитное приложение. Компоненты тесно связаны с внутренним `git-store.ts`. Нельзя npm install, но можно изучить архитектуру и адаптировать паттерны
- **Ключевые файлы для изучения**: `src/ui/diff/`, `src/ui/history/`, `src/lib/stores/git-store.ts`
### Ungit
- **GitHub**: [FredrikNoren/ungit](https://github.com/FredrikNoren/ungit)
- **Stars**: ~10,456
- **Стек**: Node.js web server (Knockout.js)
- **Лицензия**: MIT
- **Описание**: Web-based Git GUI. Запускает HTTP-сервер на localhost. Есть pre-built Electron-пакеты
- **Встраивание**: Можно через iframe/webview, но свой UI (Knockout.js), невозможно стилизовать
### GitButler
- **GitHub**: [gitbutlerapp/gitbutler](https://github.com/gitbutlerapp/gitbutler)
- **Stars**: ~14,000
- **Стек**: Tauri + Svelte + TypeScript + Rust
- **Лицензия**: Fair Source (→ MIT через 2 года)
- **Извлекаемость**: Не React, не Electron. Есть `@gitbutler/ui` но на Svelte
### Sapling ISL (Facebook) — интересная находка
- **GitHub**: [facebook/sapling](https://github.com/facebook/sapling) → `addons/isl/`
- **Стек**: React 18 + Jotai + StyleX + Vite
- **Лицензия**: MIT
- **Описание**: Interactive Smartlog — web GUI для Sapling SCM
- **Компоненты**: Commit tree visualization, drag-and-drop rebase, commit details panel, PR integration
- **Проблемы**: Заточен под Sapling SCM (не Git напрямую); требует isl-server backend
- **Ценность**: Отличный референс React-архитектуры для Git UI
### Другие
| Проект | Стек | Stars | Статус |
|--------|------|-------|--------|
| Thermal | Electron + Vue | - | Не React |
| Gitamine | Electron + React + NodeGit | 142 | Неактивен (2019), GPL v3 |
| LithiumGit | Electron + TypeScript | 20 | Активен, MIT |
| NeatGit | Electron + React + Tailwind + Vite | 3 | Ранняя разработка |
---
## 4. IDE-embedded Git UI
Все **не извлекаемые** для standalone использования.
### Eclipse Theia (@theia/git)
- **Статус**: **DEPRECATED** — рекомендуют использовать VS Code Git extension
- **Проблемы**: InversifyJS DI-контейнер, PhosphorJS/Lumino виджеты (не React), нужна полная Theia среда
- [Обсуждение Copia Automation](https://github.com/eclipse-theia/theia/discussions/15151) — вывод: проще написать свой view
### VS Code Git Extension
- **Архитектура**: Extension Host + webview API. Глубоко интегрирован в workbench. Не React. С VS Code 1.93 Git Graph встроен
- **Извлекаемость**: Невозможна без переписывания workbench
### Другие IDE
| IDE | Вердикт |
|-----|---------|
| Gitpod / OpenVSCode Server | Форк VS Code, не экспортирует компоненты |
| JetBrains Fleet | Proprietary, Kotlin/Skia рендеринг |
| Sourcegraph | Нет git management компонентов, фокус на code search |
---
## 5. Подходы к интеграции
### Подход A: xterm.js + lazygit (~200 LOC)
Быстрейший путь к полному Git UI.
```
Electron Main Process
└── node-pty.spawn('lazygit', [], { cwd: repoPath })
├── stdout → xterm.js (renderer)
└── stdin ← xterm.js keyboard events
```
```typescript
// Main process
import * as pty from 'node-pty';
const ptyProcess = pty.spawn('lazygit', [], {
name: 'xterm-256color',
cols: 120, rows: 40,
cwd: '/path/to/repo',
env: { ...process.env, TERM: 'xterm-256color' }
});
ptyProcess.onData((data) => mainWindow.webContents.send('terminal:data', data));
ipcMain.on('terminal:input', (_, data) => ptyProcess.write(data));
ipcMain.on('terminal:resize', (_, { cols, rows }) => ptyProcess.resize(cols, rows));
```
```tsx
// Renderer (React)
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
function LazyGitTerminal({ repoPath }: { repoPath: string }) {
const termRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const term = new Terminal({
theme: { background: '#141416' },
fontSize: 13
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(termRef.current!);
fitAddon.fit();
term.onData((data) => window.api.terminalInput(data));
window.api.onTerminalData((data: string) => term.write(data));
return () => term.dispose();
}, []);
return <div ref={termRef} className="w-full h-full" />;
}
```
| Критерий | Оценка |
|---|---|
| Сложность | **Низкая** (~200 LOC) |
| React-совместимость | Хорошая (xterm.js оборачивается в компонент) |
| Git-полнота | **Отличная** (lazygit покрывает всё) |
| Кастомизация UI | **Никакая** (черный ящик) |
| Зависимости | `node-pty` (нативный модуль), lazygit должен быть установлен |
### Подход B: Кастомный React UI (из кирпичиков)
```
Electron Main Process
└── GitService (simple-git)
├── IPC: git:status
├── IPC: git:log
├── IPC: git:diff
├── IPC: git:commit
├── IPC: git:branch
├── IPC: git:checkout
├── IPC: git:stash
└── IPC: git:merge
Electron Renderer (React + Zustand)
└── gitSlice (status, log, branches, diff)
├── GitStatusPanel (кастомный)
├── GitLogView + CommitGraph (@dolthub/gitgraph-react)
├── GitDiffViewer (@git-diff-view/react)
├── CommitForm (кастомный)
├── BranchSelector (кастомный)
└── StashPanel (кастомный)
```
| Критерий | Оценка |
|---|---|
| Сложность | **Высокая** (полная реализация), **Средняя** (базовые функции) |
| React-совместимость | **Идеальная** (нативные React-компоненты, Zustand, Tailwind) |
| Git-полнота | Настраиваемая — от status/commit/diff до полного |
| Кастомизация UI | **Полная** |
| Объем работ | ~500-1000 LOC для базового функционала |
### Подход C: Embed Ungit (iframe)
```
Electron Main Process
└── spawn('ungit', ['--port', '9001'])
Renderer
└── <iframe src="http://localhost:9001" />
```
| Критерий | Оценка |
|---|---|
| Сложность | **Низкая** |
| React-совместимость | **Плохая** (чужой UI, Knockout.js) |
| Git-полнота | Хорошая |
| Кастомизация UI | **Никакая** |
---
## 6. Итоговая сравнительная таблица
| Подход | Сложность | React-совместимость | Git-полнота | Кастомизация | Зависимости |
|---|---|---|---|---|---|
| **xterm.js + lazygit** | Низкая | Хорошая | Отличная | Нет | node-pty, lazygit |
| **Кастомный React UI** | Высокая | Идеальная | Настраиваемая | Полная | simple-git, @git-diff-view/react |
| **Embed Ungit** | Низкая | Плохая | Хорошая | Нет | ungit |
| **VS Code SCM API** | Нереальная | Никакая | Отличная | — | — |
---
## 7. Рекомендация
### Гибридная стратегия
**Фаза 1 — Быстрый старт:** xterm.js + lazygit
- Встраиваем lazygit как терминальную панель/вкладку
- Полный git-функционал за ~200 LOC
- Подходит для power-users
**Фаза 2 — Нативный React UI:**
1. `simple-git` как backend через IPC
2. `@git-diff-view/react` для просмотра диффов
3. Кастомные компоненты для status, commit, branches
4. `@dolthub/gitgraph-react` или `commit-graph` для визуализации графа коммитов
5. `react-arborist` для file tree в staging panel
### npm-пакеты для установки
```bash
# Backend
pnpm add simple-git
# UI-компоненты (по мере необходимости)
pnpm add @git-diff-view/react # diff viewer
pnpm add react-arborist # file tree
pnpm add @xterm/xterm @xterm/addon-fit # terminal (для lazygit)
# Commit graph (выбрать один)
pnpm add @dolthub/gitgraph-react # форк gitgraph.js
pnpm add commit-graph # interactive commit graph
```
---
## Источники
- [simple-git](https://github.com/simple-git-js/simple-git)
- [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git)
- [dugite](https://github.com/desktop/dugite)
- [GitHub Desktop](https://github.com/desktop/desktop)
- [Ungit](https://github.com/FredrikNoren/ungit)
- [@git-diff-view/react](https://github.com/MrWangJustToDo/git-diff-view)
- [react-diff-view](https://github.com/otakustay/react-diff-view)
- [react-arborist](https://github.com/brimdata/react-arborist)
- [xterm.js](https://xtermjs.org/)
- [node-pty](https://github.com/microsoft/node-pty)
- [Mermaid.js GitGraph](https://mermaid.js.org/syntax/gitgraph.html)
- [@gitkraken/gitkraken-components](https://www.npmjs.com/package/@gitkraken/gitkraken-components)
- [Sapling ISL](https://github.com/facebook/sapling/tree/main/addons/isl)
- [GitButler](https://github.com/gitbutlerapp/gitbutler)
- [Electron Web Embeds](https://www.electronjs.org/docs/latest/tutorial/web-embeds/)
- [@theia/git](https://www.npmjs.com/package/@theia/git) (deprecated)
- [VS Code SCM API](https://code.visualstudio.com/api/extension-guides/scm-provider)

View file

@ -10,6 +10,10 @@ import type { Plugin } from 'vite'
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'))
const prodDeps = Object.keys(pkg.dependencies || {})
// node-pty is a native addon that cannot be bundled by Rollup.
// It must remain external and be loaded at runtime via require().
const bundledDeps = prodDeps.filter(d => d !== 'node-pty')
// Rollup plugin: stub out native .node addon imports with empty modules.
// ssh2 and cpu-features use optional native bindings that can't be bundled,
// but they have pure JS fallbacks when the native module isn't available.
@ -32,7 +36,7 @@ export default defineConfig({
main: {
plugins: [
externalizeDepsPlugin({
exclude: prodDeps
exclude: bundledDeps
}),
nativeModuleStub()
],
@ -81,6 +85,9 @@ export default defineConfig({
}
},
renderer: {
optimizeDeps: {
include: ['@codemirror/language-data']
},
resolve: {
alias: {
'@renderer': resolve(__dirname, 'src/renderer'),

View file

@ -48,7 +48,8 @@
"standalone": "tsx src/main/standalone.ts",
"standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts",
"standalone:start": "node dist-standalone/index.cjs",
"prepare": "husky"
"prepare": "husky",
"postinstall": "electron-rebuild -f -o node-pty"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": [
@ -60,6 +61,30 @@
]
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/language-data": "^6.5.2",
"@codemirror/merge": "^6.12.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.15",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -75,16 +100,21 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.10.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "^3.6.0",
"diff": "^8.0.3",
"electron-updater": "^6.7.3",
"fastify": "^5.7.4",
"highlight.js": "^11.11.1",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
@ -99,6 +129,7 @@
"zustand": "^4.5.0"
},
"devDependencies": {
"@electron/rebuild": "^4.0.3",
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.39.2",
"@tailwindcss/typography": "^0.5.19",
@ -155,7 +186,8 @@
],
"asar": true,
"asarUnpack": [
"out/renderer/**"
"out/renderer/**",
"**/node_modules/node-pty/**"
],
"extraResources": [
{
@ -214,5 +246,10 @@
}
]
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501",
"pnpm": {
"onlyBuiltDependencies": [
"node-pty"
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -1633,37 +1633,6 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"us/claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125,
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_read_input_token_cost": 3.3e-7,
"cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
"input_cost_per_token": 0.0000033,
"input_cost_per_token_above_200k_tokens": 0.0000066,
"litellm_provider": "anthropic",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.0000165,
"output_cost_per_token_above_200k_tokens": 0.00002475,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"inference_geo": "us"
},
"claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
"cache_read_input_token_cost": 3e-7,
@ -1855,100 +1824,11 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"fast/claude-opus-4-6": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.00003,
"input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.00015,
"output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"us/claude-opus-4-6": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"cache_read_input_token_cost_above_200k_tokens": 0.0000011,
"input_cost_per_token": 0.0000055,
"input_cost_per_token_above_200k_tokens": 0.000011,
"litellm_provider": "anthropic",
"max_input_tokens": 200000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
"output_cost_per_token_above_200k_tokens": 0.00004125,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"fast/us/claude-opus-4-6": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"cache_read_input_token_cost_above_200k_tokens": 0.0000011,
"input_cost_per_token": 0.00003,
"input_cost_per_token_above_200k_tokens": 0.000011,
"litellm_provider": "anthropic",
"max_input_tokens": 200000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.00015,
"output_cost_per_token_above_200k_tokens": 0.00004125,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
"fast": 6
}
},
"claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 0.00000625,
@ -1979,69 +1859,11 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"fast/claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.00003,
"input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.00015,
"output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"us/claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"cache_read_input_token_cost_above_200k_tokens": 0.0000011,
"input_cost_per_token": 0.0000055,
"input_cost_per_token_above_200k_tokens": 0.000011,
"litellm_provider": "anthropic",
"max_input_tokens": 200000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
"output_cost_per_token_above_200k_tokens": 0.00004125,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
"fast": 6
}
},
"claude-sonnet-4-20250514": {
"deprecation_date": "2026-05-14",

View file

@ -9,6 +9,10 @@
* - Manage application lifecycle
*/
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { FileContentResolver } from '@main/services/team/FileContentResolver';
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import {
CONTEXT_CHANGED,
SSH_STATUS,
@ -29,16 +33,21 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { showTeamNativeNotification } from './ipc/teams';
import { HttpServer } from './services/infrastructure/HttpServer';
import { TeamInboxReader } from './services/team/TeamInboxReader';
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
import {
CliInstallerService,
configManager,
LocalFileSystemProvider,
MemberStatsComputer,
NotificationManager,
PtyTerminalService,
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
TaskBoundaryParser,
TeamAgentToolsInstaller,
TeamDataService,
TeamMemberLogsFinder,
@ -46,8 +55,82 @@ import {
UpdaterService,
} from './services';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
// --- Team message notification tracking ---
const teamInboxReader = new TeamInboxReader();
/** Track last-seen message count per inbox file to detect new messages. */
const inboxMessageCounts = new Map<string, number>();
/** Debounce per-inbox to avoid flooding during batch writes. */
const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>();
const INBOX_NOTIFY_DEBOUNCE_MS = 500;
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
const suppressedSources = new Set(['user_sent']);
/** Resolve human-friendly team display name, falling back to raw teamName. */
async function resolveTeamDisplayName(teamName: string): Promise<string> {
try {
if (teamDataService) {
const summary = await teamDataService.listTeams();
const team = summary.find((t) => t.teamName === teamName);
if (team?.displayName) return team.displayName;
}
} catch {
// fallback
}
return teamName;
}
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
// detail is like "inboxes/carol.json" — extract member name
const match = /^inboxes\/(.+)\.json$/.exec(detail);
if (!match) return;
const memberName = match[1];
const key = `${teamName}:${memberName}`;
try {
const messages = await teamInboxReader.getMessagesFor(teamName, memberName);
const prevCount = inboxMessageCounts.get(key) ?? 0;
if (prevCount === 0) {
// First load — seed count, don't notify
inboxMessageCounts.set(key, messages.length);
return;
}
if (messages.length <= prevCount) {
inboxMessageCounts.set(key, messages.length);
return;
}
// Messages are sorted newest-first, so new ones are at the beginning
const newMessages = messages.slice(0, messages.length - prevCount);
inboxMessageCounts.set(key, messages.length);
const teamDisplayName = await resolveTeamDisplayName(teamName);
for (const msg of newMessages) {
// Only notify for messages addressed to the human user
if (msg.to !== 'user') continue;
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
const fromLabel = msg.from || 'Unknown';
const summary = msg.summary || msg.text.slice(0, 60);
showTeamNativeNotification({
title: teamDisplayName,
subtitle: `${fromLabel}: ${summary}`,
body: msg.text,
});
}
} catch (error) {
logger.warn(`Failed to check inbox messages for ${key}:`, error);
}
}
// Window icon path for non-mac platforms.
const getWindowIconPath = (): string | undefined => {
const isDev = process.env.NODE_ENV === 'development';
@ -87,6 +170,8 @@ let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer;
// File watcher event cleanup functions
@ -154,15 +239,54 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
httpServer?.broadcast('team-change', event);
// Auto-relay direct messages to live team lead process (no UI dependency).
// Process inbox change events — relay to lead + native OS notifications.
try {
if (!event || typeof event !== 'object') return;
const row = event as { type?: unknown; teamName?: unknown };
const row = event as { type?: unknown; teamName?: unknown; detail?: unknown };
if (row.type !== 'inbox') return;
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
const teamName = row.teamName.trim();
if (!teamProvisioningService.isTeamAlive(teamName)) return;
void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined);
const detail = typeof row.detail === 'string' ? row.detail : '';
// Auto-relay direct messages to live team lead process (no UI dependency).
if (teamProvisioningService.isTeamAlive(teamName)) {
void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined);
}
// Show native OS notification for new inbox messages (debounced per inbox).
if (detail.startsWith('inboxes/')) {
const timerKey = `${teamName}:${detail}`;
const existing = inboxNotifyTimers.get(timerKey);
if (existing) clearTimeout(existing);
inboxNotifyTimers.set(
timerKey,
setTimeout(() => {
inboxNotifyTimers.delete(timerKey);
void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
}, INBOX_NOTIFY_DEBOUNCE_MS)
);
}
// Show native OS notification for live lead process replies.
// These don't go through inbox files — they're held in-memory by TeamProvisioningService.
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
// Only notify for messages addressed to the human user
if (latest?.to === 'user') {
const fromLabel = latest.from || 'team-lead';
const summary = latest.summary || latest.text.slice(0, 60);
void resolveTeamDisplayName(teamName)
.then((displayName) => {
showTeamNativeNotification({
title: displayName,
subtitle: `${fromLabel}: ${summary}`,
body: latest.text,
});
})
.catch(() => undefined);
}
}
} catch {
// ignore
}
@ -297,12 +421,19 @@ function initializeServices(): void {
// Wire file watcher events for local context
wireFileWatcherEvents(localContext);
// Initialize updater service
// Initialize updater and CLI installer services
updaterService = new UpdaterService();
cliInstallerService = new CliInstallerService();
ptyTerminalService = new PtyTerminalService();
teamDataService = new TeamDataService();
teamProvisioningService = new TeamProvisioningService();
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
const taskBoundaryParser = new TaskBoundaryParser();
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
const gitDiffFallback = new GitDiffFallback();
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
const reviewApplier = new ReviewApplierService();
// Fire-and-forget: warm up CLI and install teamctl.js at startup
void teamProvisioningService.warmup();
@ -310,12 +441,17 @@ function initializeServices(): void {
httpServer = new HttpServer();
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
teamProvisioningService.setTeamChangeEmitter((event) => {
const teamChangeEmitter = (event: TeamChangeEvent): void => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(TEAM_CHANGE, event);
}
httpServer?.broadcast('team-change', event);
});
};
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
// Start periodic health checks for registered CLI processes (every 2s).
// Dead processes get stoppedAt written to processes.json → FileWatcher picks it up.
teamDataService.startProcessHealthPolling();
// Initialize IPC handlers with registry
initializeIpcHandlers(
@ -336,7 +472,13 @@ function initializeServices(): void {
{
httpServer,
startHttpServer: () => startHttpServer(handleModeSwitch),
}
},
changeExtractor,
fileContentResolver,
reviewApplier,
gitDiffFallback,
cliInstallerService,
ptyTerminalService
);
// Forward SSH state changes to renderer and HTTP SSE clients
@ -430,6 +572,11 @@ function shutdownServices(): void {
sshConnectionManager.dispose();
}
// Kill all PTY processes
if (ptyTerminalService) {
ptyTerminalService.killAll();
}
// Remove IPC handlers
removeIpcHandlers();
@ -537,6 +684,13 @@ function createWindow(): void {
return;
}
// Prevent Cmd+N from opening new window; forward to renderer for review shortcuts
if (input.meta && input.key.toLowerCase() === 'n') {
event.preventDefault();
mainWindow.webContents.send('review:cmdN');
return;
}
if (!input.meta) return;
const currentLevel = mainWindow.webContents.getZoomLevel();
@ -571,6 +725,12 @@ function createWindow(): void {
if (updaterService) {
updaterService.setMainWindow(null);
}
if (cliInstallerService) {
cliInstallerService.setMainWindow(null);
}
if (ptyTerminalService) {
ptyTerminalService.setMainWindow(null);
}
});
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
@ -586,6 +746,12 @@ function createWindow(): void {
if (updaterService) {
updaterService.setMainWindow(mainWindow);
}
if (cliInstallerService) {
cliInstallerService.setMainWindow(mainWindow);
}
if (ptyTerminalService) {
ptyTerminalService.setMainWindow(mainWindow);
}
logger.info('Main window created');
}

View file

@ -0,0 +1,79 @@
/**
* IPC Handlers for CLI Installer Operations.
*
* Handlers:
* - cliInstaller:getStatus: Get current CLI installation status
* - cliInstaller:install: Start CLI install/update flow
* - cliInstaller:progress: Progress events (main renderer, not a handler)
*/
import {
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
} from '@preload/constants/ipcChannels';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import type { CliInstallerService } from '../services';
import type { CliInstallationStatus, IpcResult } from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:cliInstaller');
let service: CliInstallerService;
/**
* Initializes CLI installer handlers with the service instance.
*/
export function initializeCliInstallerHandlers(installerService: CliInstallerService): void {
service = installerService;
}
/**
* Registers all CLI installer IPC handlers.
*/
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall);
logger.info('CLI installer handlers registered');
}
/**
* Removes all CLI installer IPC handlers.
*/
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
logger.info('CLI installer handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
async function handleGetStatus(
_event: IpcMainInvokeEvent
): Promise<IpcResult<CliInstallationStatus>> {
try {
const status = await service.getStatus();
return { success: true, data: status };
} catch (error) {
const msg = getErrorMessage(error);
logger.error('Error in cliInstaller:getStatus:', msg);
return { success: false, error: msg };
}
}
async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> {
try {
await service.install();
return { success: true, data: undefined };
} catch (error) {
const msg = getErrorMessage(error);
logger.error('Error in cliInstaller:install:', msg);
return { success: false, error: msg };
}
}

View file

@ -17,6 +17,11 @@
import { createLogger } from '@shared/utils/logger';
import { ipcMain } from 'electron';
import {
initializeCliInstallerHandlers,
registerCliInstallerHandlers,
removeCliInstallerHandlers,
} from './cliInstaller';
import { initializeConfigHandlers, registerConfigHandlers, removeConfigHandlers } from './config';
import {
initializeContextHandlers,
@ -36,6 +41,7 @@ import {
registerProjectHandlers,
removeProjectHandlers,
} from './projects';
import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';
import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search';
import {
initializeSessionHandlers,
@ -49,6 +55,11 @@ import {
removeSubagentHandlers,
} from './subagents';
import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams';
import {
initializeTerminalHandlers,
registerTerminalHandlers,
removeTerminalHandlers,
} from './terminal';
import {
initializeUpdaterHandlers,
registerUpdaterHandlers,
@ -59,7 +70,13 @@ import { registerValidationHandlers, removeValidationHandlers } from './validati
import { registerWindowHandlers, removeWindowHandlers } from './window';
import type {
ChangeExtractorService,
CliInstallerService,
FileContentResolver,
GitDiffFallback,
MemberStatsComputer,
PtyTerminalService,
ReviewApplierService,
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
@ -89,7 +106,13 @@ export function initializeIpcHandlers(
httpServerDeps?: {
httpServer: HttpServer;
startHttpServer: () => Promise<void>;
}
},
changeExtractor?: ChangeExtractorService,
fileContentResolver?: FileContentResolver,
reviewApplier?: ReviewApplierService,
gitDiffFallback?: GitDiffFallback,
cliInstaller?: CliInstallerService,
ptyTerminal?: PtyTerminalService
): void {
// Initialize domain handlers with registry
initializeProjectHandlers(registry);
@ -114,6 +137,20 @@ export function initializeIpcHandlers(
if (httpServerDeps) {
initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer);
}
if (cliInstaller) {
initializeCliInstallerHandlers(cliInstaller);
}
if (ptyTerminal) {
initializeTerminalHandlers(ptyTerminal);
}
if (changeExtractor) {
initializeReviewHandlers({
extractor: changeExtractor,
applier: reviewApplier ?? undefined,
contentResolver: fileContentResolver ?? undefined,
gitFallback: gitDiffFallback ?? undefined,
});
}
// Register all handlers
registerProjectHandlers(ipcMain);
@ -128,7 +165,14 @@ export function initializeIpcHandlers(
registerSshHandlers(ipcMain);
registerContextHandlers(ipcMain);
registerTeamHandlers(ipcMain);
registerReviewHandlers(ipcMain);
registerWindowHandlers(ipcMain);
if (cliInstaller) {
registerCliInstallerHandlers(ipcMain);
}
if (ptyTerminal) {
registerTerminalHandlers(ipcMain);
}
if (httpServerDeps) {
registerHttpServerHandlers(ipcMain);
}
@ -153,7 +197,10 @@ export function removeIpcHandlers(): void {
removeSshHandlers(ipcMain);
removeContextHandlers(ipcMain);
removeTeamHandlers(ipcMain);
removeReviewHandlers(ipcMain);
removeWindowHandlers(ipcMain);
removeCliInstallerHandlers(ipcMain);
removeTerminalHandlers(ipcMain);
removeHttpServerHandlers(ipcMain);
logger.info('All handlers removed');

318
src/main/ipc/review.ts Normal file
View file

@ -0,0 +1,318 @@
/**
* IPC handlers for code review / diff view feature.
*
* Паттерн: module-level state + guard + wrapReviewHandler (как teams.ts)
*/
import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore';
import {
REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT,
REVIEW_CLEAR_DECISIONS,
REVIEW_GET_AGENT_CHANGES,
REVIEW_GET_CHANGE_STATS,
REVIEW_GET_FILE_CONTENT,
REVIEW_GET_GIT_FILE_LOG,
REVIEW_GET_TASK_CHANGES,
REVIEW_LOAD_DECISIONS,
REVIEW_PREVIEW_REJECT,
REVIEW_REJECT_FILE,
REVIEW_REJECT_HUNKS,
REVIEW_SAVE_DECISIONS,
REVIEW_SAVE_EDITED_FILE,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import type { FileContentResolver } from '@main/services/team/FileContentResolver';
import type { GitDiffFallback } from '@main/services/team/GitDiffFallback';
import type { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import type { IpcResult } from '@shared/types/ipc';
import type {
AgentChangeSet,
ApplyReviewRequest,
ApplyReviewResult,
ChangeStats,
ConflictCheckResult,
FileChangeWithContent,
HunkDecision,
RejectResult,
SnippetDiff,
TaskChangeSetV2,
} from '@shared/types/review';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:review');
// --- Module-level state ---
let changeExtractor: ChangeExtractorService | null = null;
let reviewApplier: ReviewApplierService | null = null;
let fileContentResolver: FileContentResolver | null = null;
let gitDiffFallback: GitDiffFallback | null = null;
const reviewDecisionStore = new ReviewDecisionStore();
function getChangeExtractor(): ChangeExtractorService {
if (!changeExtractor) throw new Error('Review handlers not initialized');
return changeExtractor;
}
function getApplier(): ReviewApplierService {
if (!reviewApplier) throw new Error('ReviewApplierService not initialized');
return reviewApplier;
}
function getContentResolver(): FileContentResolver {
if (!fileContentResolver) throw new Error('FileContentResolver not initialized');
return fileContentResolver;
}
// --- Forward-compatible config object ---
export interface ReviewHandlerDeps {
extractor: ChangeExtractorService;
applier?: ReviewApplierService;
contentResolver?: FileContentResolver;
gitFallback?: GitDiffFallback;
}
export function initializeReviewHandlers(deps: ReviewHandlerDeps): void {
changeExtractor = deps.extractor;
if (deps.applier) reviewApplier = deps.applier;
if (deps.contentResolver) fileContentResolver = deps.contentResolver;
if (deps.gitFallback) gitDiffFallback = deps.gitFallback;
}
export function registerReviewHandlers(ipcMain: IpcMain): void {
// Phase 1
ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges);
ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges);
ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats);
// Phase 2
ipcMain.handle(REVIEW_CHECK_CONFLICT, handleCheckConflict);
ipcMain.handle(REVIEW_REJECT_HUNKS, handleRejectHunks);
ipcMain.handle(REVIEW_REJECT_FILE, handleRejectFile);
ipcMain.handle(REVIEW_PREVIEW_REJECT, handlePreviewReject);
ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions);
ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent);
// Editable diff
ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile);
// Phase 4
ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog);
// Decision persistence
ipcMain.handle(REVIEW_LOAD_DECISIONS, handleLoadDecisions);
ipcMain.handle(REVIEW_SAVE_DECISIONS, handleSaveDecisions);
ipcMain.handle(REVIEW_CLEAR_DECISIONS, handleClearDecisions);
}
export function removeReviewHandlers(ipcMain: IpcMain): void {
// Phase 1
ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES);
ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES);
ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS);
// Phase 2
ipcMain.removeHandler(REVIEW_CHECK_CONFLICT);
ipcMain.removeHandler(REVIEW_REJECT_HUNKS);
ipcMain.removeHandler(REVIEW_REJECT_FILE);
ipcMain.removeHandler(REVIEW_PREVIEW_REJECT);
ipcMain.removeHandler(REVIEW_APPLY_DECISIONS);
ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT);
// Editable diff
ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE);
// Phase 4
ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG);
// Decision persistence
ipcMain.removeHandler(REVIEW_LOAD_DECISIONS);
ipcMain.removeHandler(REVIEW_SAVE_DECISIONS);
ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS);
}
// --- Локальный wrapReviewHandler ---
async function wrapReviewHandler<T>(
operation: string,
handler: () => Promise<T>
): Promise<IpcResult<T>> {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Review handler error [${operation}]:`, message);
return { success: false, error: message };
}
}
// --- Phase 1 Handlers ---
async function handleGetAgentChanges(
_event: IpcMainInvokeEvent,
teamName: string,
memberName: string
): Promise<IpcResult<AgentChangeSet>> {
return wrapReviewHandler('getAgentChanges', () =>
getChangeExtractor().getAgentChanges(teamName, memberName)
);
}
async function handleGetTaskChanges(
_event: IpcMainInvokeEvent,
teamName: string,
taskId: string
): Promise<IpcResult<TaskChangeSetV2>> {
return wrapReviewHandler('getTaskChanges', () =>
getChangeExtractor().getTaskChanges(teamName, taskId)
);
}
async function handleGetChangeStats(
_event: IpcMainInvokeEvent,
teamName: string,
memberName: string
): Promise<IpcResult<ChangeStats>> {
return wrapReviewHandler('getChangeStats', () =>
getChangeExtractor().getChangeStats(teamName, memberName)
);
}
// --- Phase 2 Handlers ---
async function handleCheckConflict(
_event: IpcMainInvokeEvent,
filePath: string,
expectedModified: string
): Promise<IpcResult<ConflictCheckResult>> {
return wrapReviewHandler('checkConflict', () =>
getApplier().checkConflict(filePath, expectedModified)
);
}
async function handleRejectHunks(
_event: IpcMainInvokeEvent,
teamName: string,
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Promise<IpcResult<RejectResult>> {
return wrapReviewHandler('rejectHunks', () =>
getApplier().rejectHunks(teamName, filePath, original, modified, hunkIndices, snippets)
);
}
async function handleRejectFile(
_event: IpcMainInvokeEvent,
teamName: string,
filePath: string,
original: string,
modified: string
): Promise<IpcResult<RejectResult>> {
return wrapReviewHandler('rejectFile', () =>
getApplier().rejectFile(teamName, filePath, original, modified)
);
}
async function handlePreviewReject(
_event: IpcMainInvokeEvent,
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Promise<IpcResult<{ preview: string; hasConflicts: boolean }>> {
return wrapReviewHandler('previewReject', () =>
getApplier().previewReject(filePath, original, modified, hunkIndices, snippets)
);
}
async function handleApplyDecisions(
_event: IpcMainInvokeEvent,
request: ApplyReviewRequest
): Promise<IpcResult<ApplyReviewResult>> {
if (!request || !Array.isArray(request.decisions)) {
return { success: false, error: 'Invalid request: decisions array required' };
}
return wrapReviewHandler('applyDecisions', () => getApplier().applyReviewDecisions(request));
}
async function handleGetFileContent(
_event: IpcMainInvokeEvent,
teamName: string,
memberName: string,
filePath: string,
snippets: SnippetDiff[] = []
): Promise<IpcResult<FileChangeWithContent>> {
return wrapReviewHandler('getFileContent', () =>
getContentResolver().getFileContent(teamName, memberName, filePath, snippets)
);
}
// --- Editable diff Handlers ---
async function handleSaveEditedFile(
_event: IpcMainInvokeEvent,
filePath: string,
content: string
): Promise<IpcResult<{ success: boolean }>> {
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
return wrapReviewHandler('saveEditedFile', async () => {
const result = await getApplier().saveEditedFile(filePath, content);
// Invalidate cached content so next fetch reads the saved version from disk
getContentResolver().invalidateFile(filePath);
return result;
});
}
// --- Phase 4 Handlers ---
async function handleGetGitFileLog(
_event: IpcMainInvokeEvent,
projectPath: string,
filePath: string
): Promise<IpcResult<{ hash: string; timestamp: string; message: string }[]>> {
return wrapReviewHandler('getGitFileLog', async () => {
if (!gitDiffFallback) {
return [];
}
return gitDiffFallback.getFileLog(projectPath, filePath);
});
}
// --- Decision Persistence Handlers ---
async function handleLoadDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string
): Promise<
IpcResult<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
} | null>
> {
return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey));
}
async function handleSaveDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>
): Promise<IpcResult<void>> {
return wrapReviewHandler('saveDecisions', () =>
reviewDecisionStore.save(teamName, scopeKey, { hunkDecisions, fileDecisions })
);
}
async function handleClearDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string
): Promise<IpcResult<void>> {
return wrapReviewHandler('clearDecisions', () => reviewDecisionStore.clear(teamName, scopeKey));
}

View file

@ -12,12 +12,16 @@ import {
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
@ -25,7 +29,12 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
TEAM_STOP,
TEAM_UPDATE_CONFIG,
@ -39,10 +48,11 @@ import {
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { ConfigManager } from '../services/infrastructure/ConfigManager';
import { NotificationManager } from '../services/infrastructure/NotificationManager';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
@ -80,6 +90,7 @@ import type {
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -176,6 +187,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
ipcMain.handle(TEAM_RESTORE, handleRestoreTeam);
ipcMain.handle(TEAM_PERMANENTLY_DELETE, handlePermanentlyDeleteTeam);
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
@ -193,6 +206,13 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
logger.info('Team handlers registered');
}
@ -212,6 +232,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_RESTORE);
ipcMain.removeHandler(TEAM_PERMANENTLY_DELETE);
ipcMain.removeHandler(TEAM_PROCESS_SEND);
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
ipcMain.removeHandler(TEAM_ALIVE_LIST);
@ -229,6 +251,13 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
ipcMain.removeHandler(TEAM_KILL_PROCESS);
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
}
function getTeamDataService(): TeamDataService {
@ -370,6 +399,30 @@ async function handleDeleteTeam(
return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!));
}
async function handleRestoreTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<void>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('restoreTeam', () => getTeamDataService().restoreTeam(validated.value!));
}
async function handlePermanentlyDeleteTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<void>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('permanentlyDeleteTeam', () =>
getTeamDataService().permanentlyDeleteTeam(validated.value!)
);
}
async function handleUpdateConfig(
_event: IpcMainInvokeEvent,
teamName: unknown,
@ -1063,6 +1116,97 @@ async function handleUpdateTaskStatus(
);
}
async function handleSoftDeleteTask(
_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('softDeleteTask', () =>
getTeamDataService().softDeleteTask(validatedTeamName.value!, validatedTaskId.value!)
);
}
async function handleRestoreTask(
_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('restoreTask', () =>
getTeamDataService().restoreTask(validatedTeamName.value!, validatedTaskId.value!)
);
}
async function handleGetDeletedTasks(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<TeamTask[]>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('getDeletedTasks', () =>
getTeamDataService().getDeletedTasks(validatedTeamName.value!)
);
}
const VALID_CLARIFICATION_VALUES = ['lead', 'user'] as const;
async function handleSetTaskClarification(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
value: 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 (
value !== null &&
(typeof value !== 'string' || !VALID_CLARIFICATION_VALUES.includes(value as 'lead' | 'user'))
) {
return {
success: false,
error: `value must be "lead", "user", or null`,
};
}
return wrapTeamHandler('setTaskClarification', () =>
getTeamDataService().setTaskNeedsClarification(
validatedTeamName.value!,
validatedTaskId.value!,
value as 'lead' | 'user' | null
)
);
}
async function handleUpdateTaskOwner(
_event: IpcMainInvokeEvent,
teamName: unknown,
@ -1263,6 +1407,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
}
async function handleLeadActivity(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<string>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('leadActivity', async () =>
getTeamProvisioningService().getLeadActivityState(validated.value!)
);
}
async function handleStopTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
@ -1399,6 +1556,110 @@ async function handleUpdateMemberRole(
});
}
async function handleKillProcess(
_event: IpcMainInvokeEvent,
teamName: unknown,
pid: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
return { success: false, error: 'pid must be a positive integer' };
}
return wrapTeamHandler('killProcess', async () => {
const tn = vTeam.value!;
const pidNum = pid;
// Read process label before killing (for notification message)
let processLabel = `PID ${pidNum}`;
try {
const data = await getTeamDataService().getTeamData(tn);
const proc = data.processes?.find((p) => p.pid === pidNum);
if (proc) {
processLabel = proc.label + (proc.port != null ? ` (:${proc.port})` : '');
}
} catch {
// best-effort label lookup
}
await getTeamDataService().killProcess(tn, pidNum);
// Notify the team lead about the killed process
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const message =
`Process "${processLabel}" (PID ${pidNum}) has been stopped by the user from the UI. ` +
`You may need to restart it if it was still needed.`;
try {
await provisioning.sendMessageToTeam(tn, message);
} catch {
logger.warn(`Failed to notify lead about killed process ${pidNum} in ${tn}`);
}
}
});
}
async function handleShowMessageNotification(
_event: IpcMainInvokeEvent,
data: unknown
): Promise<IpcResult<void>> {
if (!data || typeof data !== 'object') {
return { success: false, error: 'Invalid notification data' };
}
const d = data as TeamMessageNotificationData;
if (!d.teamDisplayName || !d.from || !d.body) {
return { success: false, error: 'Missing required fields (teamDisplayName, from, body)' };
}
showTeamNativeNotification({
title: d.teamDisplayName,
subtitle: d.summary ?? `${d.from}${d.to ?? 'team'}`,
body: d.body,
});
return { success: true, data: undefined };
}
/**
* Show a native OS notification for a team event.
* Respects user's notification settings (enabled, snoozed).
* Cross-platform: macOS, Linux, Windows via Electron Notification API.
*/
export function showTeamNativeNotification(opts: {
title: string;
subtitle?: string;
body: string;
}): void {
const config = ConfigManager.getInstance().getConfig();
if (!config.notifications.enabled) return;
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) return;
if (
typeof Notification === 'undefined' ||
typeof Notification.isSupported !== 'function' ||
!Notification.isSupported()
) {
return;
}
const notification = new Notification({
title: opts.title,
subtitle: opts.subtitle,
body: opts.body.slice(0, 300),
sound: config.notifications.soundEnabled ? 'default' : undefined,
});
notification.on('click', () => {
const windows = BrowserWindow.getAllWindows();
const mainWin = windows[0];
if (mainWin && !mainWin.isDestroyed()) {
mainWin.show();
mainWin.focus();
}
});
notification.show();
}
async function handleAddTaskComment(
_event: IpcMainInvokeEvent,
teamName: unknown,

101
src/main/ipc/terminal.ts Normal file
View file

@ -0,0 +1,101 @@
/**
* IPC Handlers for Embedded Terminal Operations.
*
* Handlers:
* - terminal:spawn: Spawn a new PTY process (returns pty ID)
* - terminal:write: Write data to PTY stdin (fire-and-forget)
* - terminal:resize: Resize PTY terminal (fire-and-forget)
* - terminal:kill: Kill PTY process (fire-and-forget)
* - terminal:data: PTY output events (main renderer, not a handler)
* - terminal:exit: PTY exit events (main renderer, not a handler)
*/
import {
TERMINAL_KILL,
TERMINAL_RESIZE,
TERMINAL_SPAWN,
TERMINAL_WRITE,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
} from '@preload/constants/ipcChannels';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import type { PtyTerminalService } from '../services';
import type { IpcResult } from '@shared/types';
import type { PtySpawnOptions } from '@shared/types/terminal';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:terminal');
let service: PtyTerminalService;
/**
* Initializes terminal handlers with the service instance.
*/
export function initializeTerminalHandlers(terminalService: PtyTerminalService): void {
service = terminalService;
}
/**
* Registers all terminal IPC handlers.
*/
export function registerTerminalHandlers(ipcMain: IpcMain): void {
// spawn uses handle (needs response with pty ID)
ipcMain.handle(TERMINAL_SPAWN, handleSpawn);
// write, resize, kill are fire-and-forget (hot path, latency-sensitive)
// Wrapped in try/catch: node-pty can throw if the PTY dies between Map.get() and .write()
ipcMain.on(TERMINAL_WRITE, (_event, ptyId: string, data: string) => {
try {
service.write(ptyId, data);
} catch (err) {
logger.warn('terminal:write error:', getErrorMessage(err));
}
});
ipcMain.on(TERMINAL_RESIZE, (_event, ptyId: string, cols: number, rows: number) => {
try {
service.resize(ptyId, cols, rows);
} catch (err) {
logger.warn('terminal:resize error:', getErrorMessage(err));
}
});
ipcMain.on(TERMINAL_KILL, (_event, ptyId: string) => {
try {
service.kill(ptyId);
} catch (err) {
logger.warn('terminal:kill error:', getErrorMessage(err));
}
});
logger.info('Terminal handlers registered');
}
/**
* Removes all terminal IPC handlers.
*/
export function removeTerminalHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TERMINAL_SPAWN);
ipcMain.removeAllListeners(TERMINAL_WRITE);
ipcMain.removeAllListeners(TERMINAL_RESIZE);
ipcMain.removeAllListeners(TERMINAL_KILL);
logger.info('Terminal handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
async function handleSpawn(
_event: IpcMainInvokeEvent,
options?: PtySpawnOptions
): Promise<IpcResult<string>> {
try {
const id = service.spawn(options);
return { success: true, data: id };
} catch (error) {
const msg = getErrorMessage(error);
logger.error('Error in terminal:spawn:', msg);
return { success: false, error: msg };
}
}

View file

@ -35,6 +35,7 @@ import { countTokens } from '../utils/tokenizer';
export function registerUtilityHandlers(ipcMain: IpcMain): void {
ipcMain.handle('get-app-version', handleGetAppVersion);
ipcMain.handle('shell:openPath', handleShellOpenPath);
ipcMain.handle('shell:showInFolder', handleShellShowInFolder);
ipcMain.handle('shell:openExternal', handleShellOpenExternal);
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
@ -50,6 +51,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void {
export function removeUtilityHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-app-version');
ipcMain.removeHandler('shell:openPath');
ipcMain.removeHandler('shell:showInFolder');
ipcMain.removeHandler('shell:openExternal');
ipcMain.removeHandler('read-claude-md-files');
ipcMain.removeHandler('read-directory-claude-md');
@ -71,6 +73,16 @@ function handleGetAppVersion(): string {
return app.getVersion();
}
/**
* Handler for 'shell:showInFolder' IPC call.
* Reveals a file in the system file manager (Finder/Explorer).
*/
function handleShellShowInFolder(_event: IpcMainInvokeEvent, filePath: string): void {
if (typeof filePath === 'string' && filePath.length > 0 && fs.existsSync(filePath)) {
shell.showItemInFolder(filePath);
}
}
/**
* Handler for 'shell:openExternal' IPC call.
* Opens a URL in the system's default browser.

View file

@ -0,0 +1,497 @@
/**
* CliInstallerService detects, downloads, verifies, and installs Claude Code CLI.
*
* Architecture mirrors UpdaterService: instance with setMainWindow(), progress events
* via webContents.send(). Downloads the native binary from GCS, verifies SHA256,
* then delegates `claude install` for shell integration (symlink, PATH setup).
*
* Edge cases handled:
* - HTTP redirects (GCS 302) manual redirect following
* - Missing Content-Length indeterminate progress
* - tmpfile cleanup on failure/abort (finally block)
* - SHA256 mismatch clear error, file deleted
* - spawn timeouts (10s for --version, 120s for install)
* - manifest.json / latest response validation
* - Concurrent install mutex
* - `latest` version string trimming / 'v' prefix stripping
* - Human-readable error messages per phase
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { execFile, spawn } from 'child_process';
import { createHash } from 'crypto';
import { createWriteStream, existsSync, promises as fsp } from 'fs';
import http from 'http';
import https from 'https';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types';
import type { BrowserWindow } from 'electron';
import type { IncomingMessage } from 'http';
const logger = createLogger('CliInstallerService');
// Note: execFile (not exec) is used intentionally — no shell injection risk.
// Arguments are passed as arrays, never interpolated into shell strings.
const execFileAsync = promisify(execFile);
// =============================================================================
// Constants
// =============================================================================
const GCS_BASE =
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases';
const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress';
/** Timeout for `claude --version` (ms) */
const VERSION_TIMEOUT_MS = 10_000;
/** Timeout for `claude install` (ms) — can take a while on slow disks */
const INSTALL_TIMEOUT_MS = 120_000;
/** Max redirects to follow when fetching from GCS */
const MAX_REDIRECTS = 5;
// =============================================================================
// Helpers
// =============================================================================
/**
* Follow redirects manually for https.get (Node https does NOT auto-follow).
*/
function httpsGetFollowRedirects(
url: string,
redirectsLeft = MAX_REDIRECTS
): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
transport
.get(url, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
reject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
res.destroy();
httpsGetFollowRedirects(redirectUrl, redirectsLeft - 1).then(resolve, reject);
return;
}
if (status !== 200) {
res.destroy();
reject(new Error(`HTTP ${status} fetching ${url}`));
return;
}
resolve(res);
})
.on('error', reject);
});
}
/**
* Fetch text content from a URL with redirect support.
*/
async function fetchText(url: string): Promise<string> {
const res = await httpsGetFollowRedirects(url);
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
res.on('error', reject);
});
}
/**
* Fetch JSON from a URL with redirect support and basic validation.
*/
async function fetchJson<T>(url: string): Promise<T> {
const text = await fetchText(url);
try {
return JSON.parse(text) as T;
} catch {
throw new Error(`Invalid JSON response from ${url}`);
}
}
/**
* Extract semver from a version string like "2.1.34 (Claude Code)" or "v2.1.34".
* Returns just the "X.Y.Z" portion, or the trimmed string if no match.
*/
export function normalizeVersion(raw: string): string {
const match = /\d{1,10}\.\d{1,10}\.\d{1,10}/.exec(raw);
return match ? match[0] : raw.trim();
}
/**
* Compare two semver strings numerically.
* Returns true if `installed` is strictly older than `latest`.
* Handles "2.10.0" > "2.9.0" correctly (numeric, not lexicographic).
*/
export function isVersionOlder(installed: string, latest: string): boolean {
const iParts = installed.split('.').map(Number);
const lParts = latest.split('.').map(Number);
for (let i = 0; i < Math.max(iParts.length, lParts.length); i++) {
const a = iParts[i] ?? 0;
const b = lParts[i] ?? 0;
if (a < b) return true;
if (a > b) return false;
}
return false;
}
// =============================================================================
// Manifest types (internal)
// =============================================================================
interface GcsPlatformEntry {
binary?: string;
checksum?: string;
size?: number;
}
interface GcsManifest {
version?: string;
platforms?: Record<string, GcsPlatformEntry>;
}
// =============================================================================
// Service
// =============================================================================
export class CliInstallerService {
private mainWindow: BrowserWindow | null = null;
private installing = false;
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
// ---------------------------------------------------------------------------
// Public: getStatus
// ---------------------------------------------------------------------------
async getStatus(): Promise<CliInstallationStatus> {
const result: CliInstallationStatus = {
installed: false,
installedVersion: null,
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
};
const binaryPath = await ClaudeBinaryResolver.resolve();
if (binaryPath) {
result.installed = true;
result.binaryPath = binaryPath;
try {
const { stdout } = await execFileAsync(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
});
result.installedVersion = normalizeVersion(stdout);
logger.info(
`Installed CLI version: "${stdout.trim()}" → normalized: "${result.installedVersion}"`
);
} catch (err) {
logger.warn('Failed to get CLI version:', getErrorMessage(err));
}
// Check auth status
try {
const { stdout: authStdout } = await execFileAsync(binaryPath, ['auth', 'status'], {
timeout: VERSION_TIMEOUT_MS,
});
const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string };
result.authLoggedIn = auth.loggedIn === true;
result.authMethod = auth.authMethod ?? null;
logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}`
);
} catch (err) {
logger.warn('Failed to check auth status:', getErrorMessage(err));
result.authLoggedIn = false;
}
}
try {
const latestRaw = await fetchText(`${GCS_BASE}/latest`);
result.latestVersion = normalizeVersion(latestRaw);
logger.info(
`Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"`
);
if (result.installedVersion && result.latestVersion) {
result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion);
logger.info(
`Update available: ${result.updateAvailable} (${result.installedVersion}${result.latestVersion})`
);
}
} catch (err) {
logger.warn('Failed to fetch latest CLI version:', getErrorMessage(err));
}
return result;
}
// ---------------------------------------------------------------------------
// Public: install
// ---------------------------------------------------------------------------
async install(): Promise<void> {
if (this.installing) {
this.sendProgress({ type: 'error', error: 'Installation already in progress' });
return;
}
this.installing = true;
let tmpFilePath: string | null = null;
try {
// --- Phase 1: Check ---
this.sendProgress({ type: 'checking', detail: 'Detecting platform...' });
const platform = this.detectPlatform();
logger.info(`Detected platform: ${platform}`);
this.sendProgress({ type: 'checking', detail: 'Fetching latest version...' });
let version: string;
try {
const latestRaw = await fetchText(`${GCS_BASE}/latest`);
version = normalizeVersion(latestRaw);
if (!version) throw new Error('Server returned empty version');
} catch (err) {
throw new Error(`Failed to check latest version: ${getErrorMessage(err)}`);
}
logger.info(`Latest CLI version: ${version}`);
this.sendProgress({ type: 'checking', detail: `Fetching manifest for v${version}...` });
let manifest: GcsManifest;
try {
manifest = await fetchJson<GcsManifest>(`${GCS_BASE}/${version}/manifest.json`);
} catch (err) {
throw new Error(`Failed to fetch release manifest: ${getErrorMessage(err)}`);
}
const platformEntry = manifest.platforms?.[platform];
if (!platformEntry?.checksum) {
const available = Object.keys(manifest.platforms ?? {}).join(', ');
throw new Error(
`Platform "${platform}" not found in release manifest.\nAvailable: ${available || 'none'}`
);
}
const expectedSha256 = platformEntry.checksum;
const expectedSize = platformEntry.size;
const binaryName = platformEntry.binary ?? 'claude';
// --- Phase 2: Download ---
const downloadUrl = `${GCS_BASE}/${version}/${platform}/${binaryName}`;
tmpFilePath = join(tmpdir(), `claude-cli-${version}-${Date.now()}`);
logger.info(`Downloading ${downloadUrl}${tmpFilePath}`);
this.sendProgress({ type: 'downloading', percent: 0, transferred: 0, total: expectedSize });
let actualSha256: string;
try {
actualSha256 = await this.downloadWithProgress(downloadUrl, tmpFilePath, expectedSize);
} catch (err) {
throw new Error(`Download failed: ${getErrorMessage(err)}`);
}
// --- Phase 3: Verify ---
this.sendProgress({ type: 'verifying', detail: 'Comparing SHA256 checksums...' });
logger.info(`Expected SHA256: ${expectedSha256}`);
logger.info(`Actual SHA256: ${actualSha256}`);
if (actualSha256 !== expectedSha256) {
throw new Error(
`Checksum verification failed — the downloaded file is corrupted.\n` +
`Expected: ${expectedSha256}\n` +
`Got: ${actualSha256}`
);
}
// --- Phase 4: Make executable + install ---
if (process.platform !== 'win32') {
// eslint-disable-next-line sonarjs/file-permissions -- 0o755 is standard for executables (rwxr-xr-x)
await fsp.chmod(tmpFilePath, 0o755);
}
this.sendProgress({ type: 'installing', detail: 'Starting shell integration...' });
logger.info('Running claude install...');
try {
await this.runInstallWithStreaming(tmpFilePath);
} catch (err) {
throw new Error(`Shell integration failed: ${getErrorMessage(err)}`);
}
// --- Phase 5: Done ---
ClaudeBinaryResolver.clearCache();
logger.info(`CLI v${version} installed successfully`);
this.sendProgress({ type: 'completed', version });
await this.removeTmpFile(tmpFilePath);
tmpFilePath = null;
} catch (err) {
const error = getErrorMessage(err);
logger.error('CLI install failed:', error);
this.sendProgress({ type: 'error', error });
} finally {
this.installing = false;
if (tmpFilePath) {
await this.removeTmpFile(tmpFilePath);
}
}
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private sendProgress(progress: CliInstallerProgress): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(CLI_INSTALLER_PROGRESS_CHANNEL, progress);
}
}
private detectPlatform(): CliPlatform {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
if (process.platform === 'darwin') return `darwin-${arch}` as CliPlatform;
if (process.platform === 'win32') return `win32-${arch}` as CliPlatform;
const isMusl =
existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
return (isMusl ? `linux-${arch}-musl` : `linux-${arch}`) as CliPlatform;
}
private async downloadWithProgress(
url: string,
destPath: string,
expectedSize?: number
): Promise<string> {
const res = await httpsGetFollowRedirects(url);
const contentLength = res.headers['content-length']
? parseInt(res.headers['content-length'], 10)
: expectedSize;
const hash = createHash('sha256');
const fileStream = createWriteStream(destPath);
let transferred = 0;
return new Promise<string>((resolve, reject) => {
res.on('data', (chunk: Buffer) => {
transferred += chunk.length;
hash.update(chunk);
fileStream.write(chunk);
const percent = contentLength ? Math.round((transferred / contentLength) * 100) : undefined;
this.sendProgress({ type: 'downloading', percent, transferred, total: contentLength });
});
res.on('end', () => {
fileStream.end(() => resolve(hash.digest('hex')));
});
res.on('error', (err) => {
fileStream.destroy();
reject(err);
});
fileStream.on('error', (err) => {
res.destroy();
reject(err);
});
});
}
/**
* Run `claude install` via spawn with streaming output.
* Collects all output for error context. Non-zero exit tolerated if binary resolves.
*/
private async runInstallWithStreaming(binaryPath: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(binaryPath, ['install'], {
env: { ...process.env, CLAUDE_SKIP_ANALYTICS: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeout = setTimeout(() => {
child.kill();
reject(
new Error(
`Timed out after ${INSTALL_TIMEOUT_MS / 1000}s. ` +
`The install process may still be running in the background.`
)
);
}, INSTALL_TIMEOUT_MS);
const outputLines: string[] = [];
const handleOutput = (chunk: Buffer): void => {
const text = chunk.toString('utf-8').trim();
if (!text) return;
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
outputLines.push(trimmed);
logger.info(`[claude install] ${trimmed}`);
this.sendProgress({ type: 'installing', detail: trimmed });
}
}
};
child.stdout?.on('data', handleOutput);
child.stderr?.on('data', handleOutput);
child.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) {
resolve();
return;
}
logger.warn(`claude install exited with code ${code ?? 'unknown'}`);
ClaudeBinaryResolver.clearCache();
ClaudeBinaryResolver.resolve().then((check) => {
if (check) {
resolve();
} else {
const context =
outputLines.length > 0 ? `\n\nOutput:\n${outputLines.slice(-10).join('\n')}` : '';
reject(new Error(`Exit code ${code ?? 'unknown'}${context}`));
}
}, reject);
});
child.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
private async removeTmpFile(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch {
// Ignore — file may already be cleaned up
}
}
}

View file

@ -920,6 +920,12 @@ export class FileWatcher extends EventEmitter {
return;
}
if (relative === 'processes.json') {
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
this.emit('team-change', event);
return;
}
// Classify only the paths we care about in iteration 02.
if (normalized.includes('inboxes') || relative === 'sentMessages.json') {
const event: TeamChangeEvent = {

View file

@ -0,0 +1,112 @@
/**
* PtyTerminalService manages node-pty terminal instances.
*
* Provides PTY spawning, IO, and lifecycle management for the embedded terminal.
* Events (data, exit) are forwarded to the renderer via mainWindow.webContents.send().
*/
import crypto from 'node:crypto';
import os from 'node:os';
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import type { PtySpawnOptions } from '@shared/types/terminal';
import type { BrowserWindow } from 'electron';
const logger = createLogger('PtyTerminalService');
// Graceful import: node-pty is a native addon that may not be available
// if electron-rebuild was not run or native build tools are missing.
import type { IPty } from 'node-pty';
import type * as NodePty from 'node-pty';
type NodePtyModule = typeof NodePty;
let nodePty: NodePtyModule | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon
nodePty = require('node-pty') as NodePtyModule;
} catch {
logger.warn('node-pty not available — terminal features disabled');
}
export class PtyTerminalService {
private ptys = new Map<string, IPty>();
private mainWindow: BrowserWindow | null = null;
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
/**
* Spawn a new PTY process.
* @returns Unique PTY ID for subsequent write/resize/kill calls.
* @throws If node-pty native module is not available.
*/
spawn(options?: PtySpawnOptions): string {
if (!nodePty) {
throw new Error(
'Terminal not available: node-pty native module not found. Run: pnpm install'
);
}
const id = crypto.randomUUID();
const shell =
options?.command ??
(process.platform === 'win32'
? (process.env.COMSPEC ?? 'powershell.exe')
: (process.env.SHELL ?? '/bin/bash'));
const pty = nodePty.spawn(shell, options?.args ?? [], {
name: 'xterm-256color',
cols: options?.cols ?? 80,
rows: options?.rows ?? 24,
cwd: options?.cwd ?? os.homedir(),
env: { ...process.env, ...options?.env } as Record<string, string>,
});
pty.onData((data) => this.send(TERMINAL_DATA, id, data));
pty.onExit(({ exitCode }) => {
this.send(TERMINAL_EXIT, id, exitCode);
this.ptys.delete(id);
});
this.ptys.set(id, pty);
logger.info(`PTY spawned: ${id} (${shell})`);
return id;
}
write(id: string, data: string): void {
this.ptys.get(id)?.write(data);
}
resize(id: string, cols: number, rows: number): void {
this.ptys.get(id)?.resize(cols, rows);
}
kill(id: string): void {
const pty = this.ptys.get(id);
if (pty) {
pty.kill();
this.ptys.delete(id);
logger.info(`PTY killed: ${id}`);
}
}
/** Kill all PTY processes. Called on app shutdown. */
killAll(): void {
const count = this.ptys.size;
if (count > 0) {
logger.info(`Killing ${count} PTY processes on shutdown`);
}
this.ptys.forEach((pty) => pty.kill());
this.ptys.clear();
}
private send(channel: string, ...args: unknown[]): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, ...args);
}
}
}

View file

@ -16,6 +16,7 @@
* - HttpServer: Fastify-based HTTP server for API and static file serving
*/
export * from './CliInstallerService';
export * from './ConfigManager';
export * from './DataCache';
export type * from './FileSystemProvider';
@ -23,6 +24,7 @@ export * from './FileWatcher';
export * from './HttpServer';
export * from './LocalFileSystemProvider';
export * from './NotificationManager';
export * from './PtyTerminalService';
export * from './ServiceContext';
export * from './ServiceContextRegistry';
export * from './SshConfigParser';

View file

@ -0,0 +1,619 @@
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import * as readline from 'readline';
import { TeamConfigReader } from './TeamConfigReader';
import { countLineChanges } from './UnifiedLineCounter';
import type { TaskBoundaryParser } from './TaskBoundaryParser';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type {
AgentChangeSet,
ChangeStats,
FileChangeSummary,
FileEditEvent,
FileEditTimeline,
MemberLogSummary,
SnippetDiff,
TaskChangeScope,
TaskChangeSetV2,
} from '@shared/types';
const logger = createLogger('Service:ChangeExtractorService');
/** Кеш-запись: данные + mtime файла + время протухания */
interface CacheEntry {
data: AgentChangeSet;
mtime: number;
expiresAt: number;
}
/** Ссылка на JSONL файл с привязкой к memberName */
interface LogFileRef {
filePath: string;
memberName: string;
}
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly boundaryParser: TaskBoundaryParser,
private readonly configReader: TeamConfigReader = new TeamConfigReader()
) {}
/** Получить все изменения агента */
async getAgentChanges(teamName: string, memberName: string): Promise<AgentChangeSet> {
const cacheKey = `${teamName}:${memberName}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
const projectPath = await this.resolveProjectPath(teamName);
// Собираем все snippets из всех JSONL файлов
const allSnippets: SnippetDiff[] = [];
let latestMtime = 0;
for (const filePath of paths) {
try {
const fileStat = await stat(filePath);
if (fileStat.mtimeMs > latestMtime) {
latestMtime = fileStat.mtimeMs;
}
} catch {
// Файл может быть удалён между обнаружением и чтением
}
const snippets = await this.parseJSONLFile(filePath);
allSnippets.push(...snippets);
}
const files = this.aggregateByFile(allSnippets, projectPath);
let totalLinesAdded = 0;
let totalLinesRemoved = 0;
for (const file of files) {
totalLinesAdded += file.linesAdded;
totalLinesRemoved += file.linesRemoved;
}
const result: AgentChangeSet = {
teamName,
memberName,
files,
totalLinesAdded,
totalLinesRemoved,
totalFiles: files.length,
computedAt: new Date().toISOString(),
};
this.cache.set(cacheKey, {
data: result,
mtime: latestMtime,
expiresAt: Date.now() + this.cacheTtl,
});
return result;
}
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
const logs = await this.logsFinder.findLogsForTask(teamName, taskId);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
if (logRefs.length === 0) {
return this.emptyTaskChangeSet(teamName, taskId);
}
const projectPath = await this.resolveProjectPath(teamName);
// Парсим boundaries для каждого лог-файла и ищем scope данной задачи
const allScopes: TaskChangeScope[] = [];
for (const ref of logRefs) {
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
const scope = boundaries.scopes.find((s) => s.taskId === taskId);
if (scope) {
allScopes.push({ ...scope, memberName: ref.memberName });
}
}
// Если scope не найден — fallback на весь файл
if (allScopes.length === 0) {
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath);
}
// Фильтруем snippets по tool_use IDs из scope
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds, projectPath);
const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
const warnings: string[] = [];
if (worstTier >= 3) {
warnings.push('Some task boundaries could not be precisely determined.');
}
return {
teamName,
taskId,
files,
totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0),
totalFiles: files.length,
confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low',
computedAt: new Date().toISOString(),
scope: allScopes[0],
warnings,
};
}
/** Получить краткую статистику */
async getChangeStats(teamName: string, memberName: string): Promise<ChangeStats> {
const changes = await this.getAgentChanges(teamName, memberName);
return {
linesAdded: changes.totalLinesAdded,
linesRemoved: changes.totalLinesRemoved,
filesChanged: changes.totalFiles,
};
}
// ---- Private methods ----
/** Получить projectPath из конфига команды */
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
try {
const config = await this.configReader.getConfig(teamName);
return config?.projectPath?.trim() || undefined;
} catch {
return undefined;
}
}
/**
* Compute a context hash from old/newString for reliable hunksnippet matching.
* Uses first+last 3 lines of both strings as a fingerprint.
*/
private computeContextHash(oldString: string, newString: string): string {
const take3 = (s: string): string => {
const lines = s.split('\n');
const head = lines.slice(0, 3).join('\n');
const tail = lines.length > 3 ? lines.slice(-3).join('\n') : '';
return `${head}|${tail}`;
};
const raw = `${take3(oldString)}::${take3(newString)}`;
// Simple hash: DJB2 variant (fast, no crypto needed)
let hash = 5381;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) + hash + raw.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
// Сначала считываем все записи в память для двух проходов
const entries: Record<string, unknown>[] = [];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
entries.push(JSON.parse(trimmed) as Record<string, unknown>);
} catch {
// Пропускаем невалидный JSON
}
}
rl.close();
stream.destroy();
} catch (err) {
logger.debug(`Не удалось прочитать файл ${filePath}: ${String(err)}`);
return [];
}
// Проход 1: собираем tool_use_id с ошибками
const erroredIds = this.collectErroredToolUseIds(entries);
// Проход 2: извлекаем snippets из tool_use блоков
const snippets: SnippetDiff[] = [];
// Множество уже встречавшихся файлов (для определения write-new vs write-update)
const seenFiles = new Set<string>();
for (const entry of entries) {
const role = this.extractRole(entry);
if (role !== 'assistant') continue;
const content = this.extractContent(entry);
if (!content) continue;
const timestamp =
typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString();
for (const block of content) {
if (
!block ||
typeof block !== 'object' ||
(block as Record<string, unknown>).type !== 'tool_use'
) {
continue;
}
const toolBlock = block as Record<string, unknown>;
const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : '';
// Убираем proxy_ префикс
const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName;
const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : '';
const input = toolBlock.input as Record<string, unknown> | undefined;
if (!input) continue;
const isError = erroredIds.has(toolUseId);
if (toolName === 'Edit') {
const path = typeof input.file_path === 'string' ? input.file_path : '';
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
const newString = typeof input.new_string === 'string' ? input.new_string : '';
const replaceAll = input.replace_all === true;
if (path) {
seenFiles.add(path);
snippets.push({
toolUseId,
filePath: path,
toolName: 'Edit',
type: 'edit',
oldString,
newString,
replaceAll,
timestamp,
isError,
contextHash: this.computeContextHash(oldString, newString),
});
}
} else if (toolName === 'Write') {
const path = typeof input.file_path === 'string' ? input.file_path : '';
const writeContent = typeof input.content === 'string' ? input.content : '';
if (path) {
const isNew = !seenFiles.has(path);
seenFiles.add(path);
snippets.push({
toolUseId,
filePath: path,
toolName: 'Write',
type: isNew ? 'write-new' : 'write-update',
oldString: '',
newString: writeContent,
replaceAll: false,
timestamp,
isError,
contextHash: this.computeContextHash('', writeContent),
});
}
} else if (toolName === 'MultiEdit') {
const path = typeof input.file_path === 'string' ? input.file_path : '';
const edits = Array.isArray(input.edits) ? input.edits : [];
if (path) {
seenFiles.add(path);
for (const edit of edits) {
if (!edit || typeof edit !== 'object') continue;
const editObj = edit as Record<string, unknown>;
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
snippets.push({
toolUseId,
filePath: path,
toolName: 'MultiEdit',
type: 'multi-edit',
oldString,
newString,
replaceAll: false,
timestamp,
isError,
contextHash: this.computeContextHash(oldString, newString),
});
}
}
}
// Остальные инструменты (NotebookEdit и пр.) пропускаем
}
}
return snippets;
}
/** Извлечь content array из JSONL entry (оба формата: subagent и main) */
private extractContent(entry: Record<string, unknown>): unknown[] | null {
const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) return message.content as unknown[];
if (Array.isArray(entry.content)) return entry.content as unknown[];
return null;
}
/** Извлечь роль из JSONL entry */
private extractRole(entry: Record<string, unknown>): string | null {
if (typeof entry.role === 'string') return entry.role;
const message = entry.message as Record<string, unknown> | undefined;
if (message && typeof message.role === 'string') return message.role;
return null;
}
/** Собрать errored tool_use_ids из tool_result блоков */
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
const erroredIds = new Set<string>();
for (const entry of entries) {
// tool_result может находиться в entry.content (когда это массив)
if (Array.isArray(entry.content)) {
for (const block of entry.content) {
if (this.isErroredToolResult(block)) {
const toolUseId = (block as Record<string, unknown>).tool_use_id;
if (typeof toolUseId === 'string') {
erroredIds.add(toolUseId);
}
}
}
}
// Также проверяем entry.message.content
const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) {
for (const block of message.content) {
if (this.isErroredToolResult(block)) {
const toolUseId = (block as Record<string, unknown>).tool_use_id;
if (typeof toolUseId === 'string') {
erroredIds.add(toolUseId);
}
}
}
}
}
return erroredIds;
}
/** Проверить, является ли блок tool_result с ошибкой */
private isErroredToolResult(block: unknown): boolean {
if (!block || typeof block !== 'object') return false;
const obj = block as Record<string, unknown>;
return obj.type === 'tool_result' && obj.is_error === true;
}
/** Агрегировать snippets в FileChangeSummary[] */
private aggregateByFile(snippets: SnippetDiff[], projectPath?: string): FileChangeSummary[] {
const fileMap = new Map<string, { snippets: SnippetDiff[]; isNewFile: boolean }>();
for (const snippet of snippets) {
// Пропускаем snippets с ошибками при агрегации
if (snippet.isError) continue;
const existing = fileMap.get(snippet.filePath);
if (existing) {
existing.snippets.push(snippet);
} else {
fileMap.set(snippet.filePath, {
snippets: [snippet],
isNewFile: snippet.type === 'write-new',
});
}
}
return [...fileMap.entries()].map(([fp, data]) => {
let totalAdded = 0;
let totalRemoved = 0;
for (const s of data.snippets) {
if (s.isError) continue;
const { added, removed } = countLineChanges(s.oldString, s.newString);
totalAdded += added;
totalRemoved += removed;
}
// Normalize separators for cross-platform path stripping
const normalizedFp = fp.replace(/\\/g, '/');
const normalizedProject = projectPath?.replace(/\\/g, '/');
const relative = normalizedProject
? normalizedFp.startsWith(normalizedProject + '/')
? normalizedFp.slice(normalizedProject.length + 1)
: normalizedFp.startsWith(normalizedProject)
? normalizedFp.slice(normalizedProject.length)
: normalizedFp.split('/').slice(-3).join('/')
: normalizedFp.split('/').slice(-3).join('/');
return {
filePath: fp,
relativePath: relative,
snippets: data.snippets,
linesAdded: totalAdded,
linesRemoved: totalRemoved,
isNewFile: data.isNewFile,
timeline: this.buildTimeline(fp, data.snippets),
};
});
}
/** Build edit timeline from snippets */
private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline {
const events: FileEditEvent[] = snippets
.filter((s) => !s.isError)
.map((s, idx) => {
const { added, removed } = countLineChanges(s.oldString, s.newString);
return {
toolUseId: s.toolUseId,
toolName: s.toolName as FileEditEvent['toolName'],
timestamp: s.timestamp,
summary: this.generateEditSummary(s),
linesAdded: added,
linesRemoved: removed,
snippetIndex: idx,
};
});
const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t));
const durationMs =
timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0;
return { filePath, events, durationMs };
}
private generateEditSummary(snippet: SnippetDiff): string {
switch (snippet.type) {
case 'write-new':
return 'Created new file';
case 'write-update':
return 'Wrote full file content';
case 'multi-edit': {
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
const total = added + removed;
return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`;
}
case 'edit': {
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`;
if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
return `Changed ${removed}${added} lines`;
}
default:
return 'File modified';
}
}
/** Проверить, содержит ли путь к файлу один из sessionId */
private pathMatchesAnySession(filePath: string, sessionIds: Set<string>): boolean {
for (const sessionId of sessionIds) {
if (filePath.includes(sessionId)) return true;
}
return false;
}
/** Конвертировать MemberLogSummary[] в LogFileRef[] через findMemberLogPaths */
private async resolveLogFileRefs(
teamName: string,
logs: MemberLogSummary[]
): Promise<LogFileRef[]> {
const refs: LogFileRef[] = [];
const byMember = new Map<string, MemberLogSummary[]>();
for (const log of logs) {
const name = log.memberName ?? 'unknown';
if (!byMember.has(name)) byMember.set(name, []);
byMember.get(name)!.push(log);
}
for (const [memberName, memberLogs] of byMember) {
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
for (const log of memberLogs) {
const matchedPath = paths.find((p) =>
log.kind === 'subagent'
? p.includes(log.sessionId) && p.includes(log.subagentId)
: p.includes(log.sessionId) && p.endsWith('.jsonl')
);
if (matchedPath) {
refs.push({ filePath: matchedPath, memberName });
}
}
}
return refs;
}
/** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */
private async extractFilteredChanges(
logRefs: LogFileRef[],
allowedToolUseIds: Set<string>,
projectPath?: string
): Promise<FileChangeSummary[]> {
const allSnippets: SnippetDiff[] = [];
for (const ref of logRefs) {
const snippets = await this.parseJSONLFile(ref.filePath);
if (allowedToolUseIds.size > 0) {
// Фильтруем только по разрешённым tool_use IDs
for (const s of snippets) {
if (allowedToolUseIds.has(s.toolUseId)) {
allSnippets.push(s);
}
}
} else {
allSnippets.push(...snippets);
}
}
return this.aggregateByFile(allSnippets, projectPath);
}
/** Извлечь все изменения из одного файла */
private async extractAllChanges(
filePath: string,
_memberName: string,
projectPath?: string
): Promise<FileChangeSummary[]> {
const snippets = await this.parseJSONLFile(filePath);
return this.aggregateByFile(snippets, projectPath);
}
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
logRefs: LogFileRef[],
projectPath?: string
): Promise<TaskChangeSetV2> {
const allFiles: FileChangeSummary[] = [];
for (const ref of logRefs) {
const files = await this.extractAllChanges(ref.filePath, ref.memberName, projectPath);
allFiles.push(...files);
}
const fallbackScope: TaskChangeScope = {
taskId,
memberName: logRefs[0]?.memberName ?? 'unknown',
startLine: 1,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: allFiles.map((f) => f.filePath),
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
};
return {
teamName,
taskId,
files: allFiles,
totalLinesAdded: allFiles.reduce((sum, f) => sum + f.linesAdded, 0),
totalLinesRemoved: allFiles.reduce((sum, f) => sum + f.linesRemoved, 0),
totalFiles: allFiles.length,
confidence: 'fallback',
computedAt: new Date().toISOString(),
scope: fallbackScope,
warnings: ['No task boundaries found — showing all changes from related sessions.'],
};
}
/** Пустой TaskChangeSetV2 */
private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 {
return {
teamName,
taskId,
files: [],
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: 0,
confidence: 'fallback',
computedAt: new Date().toISOString(),
scope: {
taskId,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: ['No log files found for this task.'],
};
}
}

View file

@ -135,6 +135,14 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
let cachedPath: string | null | undefined;
export class ClaudeBinaryResolver {
/**
* Clear the cached binary path.
* Call after CLI install/update so the next resolve() picks up the new location.
*/
static clearCache(): void {
cachedPath = undefined;
}
static async resolve(): Promise<string | null> {
if (cachedPath !== undefined) return cachedPath;
@ -163,6 +171,8 @@ export class ClaudeBinaryResolver {
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
const candidateDirs: string[] = [
// Native binary installation path (claude install)
path.join(os.homedir(), '.local', 'bin'),
path.join(os.homedir(), '.npm-global', 'bin'),
path.join(os.homedir(), '.npm', 'bin'),
process.platform === 'win32'

View file

@ -0,0 +1,506 @@
import { createLogger } from '@shared/utils/logger';
import { diffLines } from 'diff';
import { createReadStream } from 'fs';
import { access, readFile } from 'fs/promises';
import * as path from 'path';
import * as readline from 'readline';
import type { GitDiffFallback } from './GitDiffFallback';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { FileChangeWithContent, SnippetDiff } from '@shared/types';
const logger = createLogger('Service:FileContentResolver');
/** Кеш-запись для resolved content */
interface ContentCacheEntry {
original: string | null;
modified: string | null;
source: FileChangeWithContent['contentSource'];
expiresAt: number;
}
/**
* Resolves full file contents (original + modified) for CodeMirror diff view.
*
* Uses three-level resolution strategy:
* 1. File-history backup (most accurate)
* 2. Snippet reconstruction (reverse-apply edits from current disk state)
* 3. Fallback to current file on disk
*/
export class FileContentResolver {
private cache = new Map<string, ContentCacheEntry>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly gitFallback?: GitDiffFallback
) {}
/** Invalidate cached content for a file (e.g. after user saves edits) */
invalidateFile(filePath: string): void {
for (const key of this.cache.keys()) {
if (key.endsWith(`:${filePath}`)) {
this.cache.delete(key);
}
}
}
/**
* Resolve full file contents for a single file.
* Returns original (before changes) and modified (after changes) content.
*/
async resolveFileContent(
teamName: string,
memberName: string,
filePath: string,
snippets: SnippetDiff[]
): Promise<{
original: string | null;
modified: string | null;
source: FileChangeWithContent['contentSource'];
}> {
const cacheKey = `${teamName}:${memberName}:${filePath}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return { original: cached.original, modified: cached.modified, source: cached.source };
}
// Read current file from disk (= modified state after agent's changes)
let currentContent: string | null = null;
try {
currentContent = await readFile(filePath, 'utf8');
} catch {
logger.debug(`Файл недоступен на диске: ${filePath}`);
}
// Strategy 1: Try file-history backup
const historyResult = await this.tryFileHistoryBackup(teamName, memberName, filePath);
if (historyResult) {
const result = {
original: historyResult,
modified: currentContent,
source: 'file-history' as const,
};
this.cacheResult(cacheKey, result);
return result;
}
// Strategy 2: Try snippet reconstruction
const reconstructed = this.trySnippetReconstruction(currentContent, snippets);
if (reconstructed !== null) {
const result = {
original: reconstructed,
modified: currentContent,
source: 'snippet-reconstruction' as const,
};
this.cacheResult(cacheKey, result);
return result;
}
// Strategy 3 (Phase 4): Git fallback
if (this.gitFallback) {
const gitResult = await this.tryGitFallback(filePath, currentContent, snippets);
if (gitResult) {
const result = {
original: gitResult,
modified: currentContent,
source: 'git-fallback' as const,
};
this.cacheResult(cacheKey, result);
return result;
}
}
// Strategy 4: Fallback — only current file on disk
if (currentContent !== null) {
const result = {
original: null,
modified: currentContent,
source: 'disk-current' as const,
};
this.cacheResult(cacheKey, result);
return result;
}
// Nothing available
return { original: null, modified: null, source: 'unavailable' };
}
/**
* Get full file content for a single file (IPC-facing method).
* Returns a FileChangeWithContent object ready for the renderer.
*/
async getFileContent(
teamName: string,
memberName: string,
filePath: string,
snippets: SnippetDiff[] = []
): Promise<FileChangeWithContent> {
const resolved = await this.resolveFileContent(teamName, memberName, filePath, snippets);
// Compute accurate stats from full content diff
let linesAdded = 0;
let linesRemoved = 0;
if (resolved.original !== null && resolved.modified !== null) {
const changes = diffLines(resolved.original, resolved.modified);
for (const c of changes) {
if (c.added) linesAdded += c.count ?? 0;
if (c.removed) linesRemoved += c.count ?? 0;
}
} else if (resolved.original === null && resolved.modified !== null) {
// Use diffLines for consistency with ChangeExtractorService.countLines()
const changes = diffLines('', resolved.modified);
for (const c of changes) {
if (c.added) linesAdded += c.count ?? 0;
}
}
const isNewFile = snippets.some((s) => s.type === 'write-new');
return {
filePath,
relativePath: filePath.split('/').slice(-3).join('/'),
snippets,
linesAdded,
linesRemoved,
isNewFile,
originalFullContent: resolved.original,
modifiedFullContent: resolved.modified,
contentSource: resolved.source,
};
}
/**
* Resolve full contents for multiple files at once.
* Returns a map of filePath -> FileChangeWithContent.
*/
async resolveAllFileContents(
teamName: string,
memberName: string,
files: {
filePath: string;
relativePath: string;
snippets: SnippetDiff[];
linesAdded: number;
linesRemoved: number;
isNewFile: boolean;
}[]
): Promise<Map<string, FileChangeWithContent>> {
const results = new Map<string, FileChangeWithContent>();
// Resolve all files in parallel
const promises = files.map(async (file) => {
const resolved = await this.resolveFileContent(
teamName,
memberName,
file.filePath,
file.snippets
);
// Compute accurate stats from full content diff
let linesAdded = file.linesAdded;
let linesRemoved = file.linesRemoved;
if (resolved.original !== null && resolved.modified !== null) {
linesAdded = 0;
linesRemoved = 0;
const changes = diffLines(resolved.original, resolved.modified);
for (const c of changes) {
if (c.added) linesAdded += c.count ?? 0;
if (c.removed) linesRemoved += c.count ?? 0;
}
}
const entry: FileChangeWithContent = {
filePath: file.filePath,
relativePath: file.relativePath,
snippets: file.snippets,
linesAdded,
linesRemoved,
isNewFile: file.isNewFile,
originalFullContent: resolved.original,
modifiedFullContent: resolved.modified,
contentSource: resolved.source,
};
results.set(file.filePath, entry);
});
await Promise.all(promises);
return results;
}
// ── Private: Resolution strategies ──
/**
* Strategy 1: Read original content from Claude's file-history backup.
*
* Claude saves file snapshots at `~/.claude/file-history/{sessionId}/{backupFileName}`.
* The mapping is stored as `type: "file-history-snapshot"` entries in JSONL.
*/
private async tryFileHistoryBackup(
teamName: string,
memberName: string,
filePath: string
): Promise<string | null> {
let logPaths: string[];
try {
logPaths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
} catch {
return null;
}
if (logPaths.length === 0) return null;
for (const logPath of logPaths) {
const sessionId = this.extractSessionId(logPath);
if (!sessionId) continue;
const backupFileName = await this.findFileHistoryBackup(logPath, filePath);
if (!backupFileName) continue;
// Construct the file-history path
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const historyPath = path.join(homeDir, '.claude', 'file-history', sessionId, backupFileName);
try {
await access(historyPath);
const content = await readFile(historyPath, 'utf8');
logger.debug(`File-history backup найден: ${historyPath}`);
return content;
} catch {
// Backup file doesn't exist, try next log
continue;
}
}
return null;
}
/**
* Extract sessionId from a JSONL log path.
*
* Paths can be:
* - `~/.claude/projects/{encodedPath}/{sessionId}.jsonl` (lead session)
* - `~/.claude/projects/{encodedPath}/{sessionId}/subagents/agent-{id}.jsonl` (subagent)
*
* For lead sessions, sessionId = filename without extension.
* For subagents, sessionId = the parent directory's parent name.
*/
private extractSessionId(logPath: string): string | null {
const parts = logPath.split(path.sep);
// Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl
const subagentsIdx = parts.indexOf('subagents');
if (subagentsIdx > 0) {
return parts[subagentsIdx - 1] || null;
}
// Lead session: .../{sessionId}.jsonl
const fileName = parts[parts.length - 1];
if (fileName?.endsWith('.jsonl')) {
return fileName.replace('.jsonl', '');
}
return null;
}
/**
* Stream a JSONL file looking for file-history-snapshot entries that reference the target file.
* Returns the backup file name if found.
*/
private async findFileHistoryBackup(
logPath: string,
targetFilePath: string
): Promise<string | null> {
try {
const stream = createReadStream(logPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
// Quick check before JSON parse
if (!trimmed.includes('file-history-snapshot')) continue;
try {
const entry = JSON.parse(trimmed) as Record<string, unknown>;
if (entry.type !== 'file-history-snapshot') continue;
const snapshot = entry.snapshot as Record<string, unknown> | undefined;
if (!snapshot) continue;
const trackedFileBackups = snapshot.trackedFileBackups as
| Record<string, string>
| undefined;
if (!trackedFileBackups) continue;
const backupFileName = trackedFileBackups[targetFilePath];
if (backupFileName) {
rl.close();
stream.destroy();
return backupFileName;
}
} catch {
// Skip malformed JSON
}
}
rl.close();
stream.destroy();
} catch {
logger.debug(`Не удалось прочитать JSONL для file-history: ${logPath}`);
}
return null;
}
/**
* Strategy 2: Reconstruct original content by reverse-applying snippets.
*
* Algorithm:
* 1. Start with current file content from disk (= modified state)
* 2. Sort snippets by timestamp DESCENDING (newest first)
* 3. For each snippet, reverse the edit operation
* 4. Result = original content before any agent changes
*
* Returns null if reconstruction is not possible (chain broken).
*/
private trySnippetReconstruction(
currentContent: string | null,
snippets: SnippetDiff[]
): string | null {
if (!currentContent) return null;
if (snippets.length === 0) return null;
// Filter out errored snippets
const validSnippets = snippets.filter((s) => !s.isError);
if (validSnippets.length === 0) return null;
// Sort by timestamp descending (reverse order to undo newest first)
const sorted = [...validSnippets].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
let content = currentContent;
for (const snippet of sorted) {
switch (snippet.type) {
case 'write-new': {
// File was created by agent -> original was empty
return '';
}
case 'write-update': {
// Full file overwrite — can't reconstruct previous content from snippets alone
return null;
}
case 'edit':
case 'multi-edit': {
// Guard: empty newString means deletion — can't find position to reverse
if (!snippet.newString) return null;
if (snippet.replaceAll) {
// Reverse replaceAll: replace all occurrences of newString -> oldString
if (!content.includes(snippet.newString)) {
// Chain broken — newString not in current content
return null;
}
content = content.split(snippet.newString).join(snippet.oldString);
} else {
// Reverse single edit: replace first occurrence of newString -> oldString
const idx = content.indexOf(snippet.newString);
if (idx === -1) {
// Chain broken — can't find the new string to reverse
return null;
}
content =
content.substring(0, idx) +
snippet.oldString +
content.substring(idx + snippet.newString.length);
}
break;
}
}
}
return content;
}
// ── Private: Git fallback (Phase 4) ──
/**
* Strategy 3 (Phase 4): Git fallback find original content from git history.
* Uses the timestamp of the first snippet to locate a commit before changes.
*/
private async tryGitFallback(
filePath: string,
_currentContent: string | null,
snippets: SnippetDiff[]
): Promise<string | null> {
if (!this.gitFallback) return null;
// Determine project path from file path (heuristic: find .git parent)
const projectPath = this.guessProjectPath(filePath);
if (!projectPath) return null;
const isGit = await this.gitFallback.isGitRepo(projectPath);
if (!isGit) return null;
// Use earliest snippet timestamp to find the "before" state
const timestamps = snippets
.filter((s) => !s.isError && s.timestamp)
.map((s) => s.timestamp)
.sort((a, b) => a.localeCompare(b));
const firstTimestamp = timestamps[0];
if (!firstTimestamp) return null;
const commitHash = await this.gitFallback.findCommitNearTimestamp(
projectPath,
filePath,
firstTimestamp
);
if (!commitHash) return null;
const original = await this.gitFallback.getFileAtCommit(projectPath, filePath, commitHash);
return original;
}
/**
* Guess the project root path from a file path.
* Simple heuristic: look for common markers (package.json, .git directory).
*/
private guessProjectPath(filePath: string): string | null {
const parts = filePath.split('/');
// Walk up from file, looking for typical project root indicators
for (let i = parts.length - 1; i >= 1; i--) {
const candidate = parts.slice(0, i).join('/');
// Simple heuristic: paths with these patterns are likely project roots
if (candidate.endsWith('/src') || candidate.endsWith('/lib')) {
return parts.slice(0, i - 1).join('/') || null;
}
}
// Fallback: take the first 4-5 components as project path
if (parts.length > 4) {
return parts.slice(0, Math.min(parts.length - 2, 5)).join('/');
}
return null;
}
// ── Private: Cache helpers ──
private cacheResult(
key: string,
result: {
original: string | null;
modified: string | null;
source: FileChangeWithContent['contentSource'];
}
): void {
this.cache.set(key, {
original: result.original,
modified: result.modified,
source: result.source,
expiresAt: Date.now() + this.cacheTtl,
});
}
}

View file

@ -0,0 +1,134 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT = 10_000; // 10s timeout for all git operations
const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB
export class GitDiffFallback {
private gitRepoCache = new Map<string, boolean>();
/**
* Get file contents at a specific commit.
* Used when file-history-snapshot is unavailable.
*/
async getFileAtCommit(
projectPath: string,
filePath: string,
commitHash: string
): Promise<string | null> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${relativePath}`], {
cwd: projectPath,
maxBuffer: GIT_MAX_BUFFER,
timeout: GIT_TIMEOUT,
});
return stdout;
} catch {
return null;
}
}
/**
* Find the commit closest to (but before) a given timestamp for a file.
*/
async findCommitNearTimestamp(
projectPath: string,
filePath: string,
timestamp: string
): Promise<string | null> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync(
'git',
['log', '--format=%H', '--before', timestamp, '-1', '--', relativePath],
{ cwd: projectPath, timeout: GIT_TIMEOUT }
);
return stdout.trim() || null;
} catch {
return null;
}
}
/**
* Get git diff for a file between two refs.
*/
async getGitDiff(
projectPath: string,
filePath: string,
fromCommit: string,
toCommit: string = 'HEAD'
): Promise<string | null> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync(
'git',
['diff', fromCommit, toCommit, '--', relativePath],
{ cwd: projectPath, timeout: GIT_TIMEOUT }
);
return stdout || null;
} catch {
return null;
}
}
/**
* Get file change log (for timeline enrichment).
*/
async getFileLog(
projectPath: string,
filePath: string,
maxCount: number = 20
): Promise<{ hash: string; timestamp: string; message: string }[]> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync(
'git',
['log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath],
{ cwd: projectPath, timeout: GIT_TIMEOUT }
);
return stdout
.trim()
.split('\n')
.filter((line) => line.includes('|'))
.map((line) => {
const [hash, timestamp, ...msgParts] = line.split('|');
return { hash, timestamp, message: msgParts.join('|') };
});
} catch {
return [];
}
}
/**
* Check if a path is inside a git repository.
* Result is cached per projectPath for the session lifetime.
*/
async isGitRepo(projectPath: string): Promise<boolean> {
const cached = this.gitRepoCache.get(projectPath);
if (cached !== undefined) return cached;
try {
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
cwd: projectPath,
timeout: GIT_TIMEOUT,
});
this.gitRepoCache.set(projectPath, true);
return true;
} catch {
this.gitRepoCache.set(projectPath, false);
return false;
}
}
}

View file

@ -0,0 +1,150 @@
import { structuredPatch } from 'diff';
import type { SnippetDiff } from '@shared/types';
/**
* Reliable hunksnippet matcher using content overlap analysis.
*
* Uses bidirectional substring matching between hunk added/removed lines
* and snippet newString/oldString to determine which snippets correspond
* to which diff hunks.
*
* Replaces the previous 1:1 hunkIndexsnippetIndex assumption.
*/
export class HunkSnippetMatcher {
/**
* Match hunk indices to their corresponding snippets.
* Returns a Map where each hunk index maps to the set of matching snippet indices.
*
* @param snippets MUST be pre-filtered (no isError entries).
* Returned indices are relative to this array.
*/
matchHunksToSnippets(
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Map<number, Set<number>> {
if (snippets.length === 0) return new Map();
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return new Map();
const mapping = new Map<number, Set<number>>();
for (const hunkIdx of hunkIndices) {
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
const hunk = patch.hunks[hunkIdx];
const snippetSet = new Set<number>();
// Reconstruct old/new side of hunk INCLUDING context lines.
// Context lines (` ` prefix) are critical — without them, snippets whose
// oldString spans unchanged lines between changed lines can't be matched.
const oldSideContent = hunk.lines
.filter((l) => !l.startsWith('+'))
.map((l) => l.slice(1))
.join('\n');
const newSideContent = hunk.lines
.filter((l) => !l.startsWith('-'))
.map((l) => l.slice(1))
.join('\n');
for (let sIdx = 0; sIdx < snippets.length; sIdx++) {
const snippet = snippets[sIdx];
if (this.hasContentOverlap(snippet, oldSideContent, newSideContent)) {
snippetSet.add(sIdx);
}
}
mapping.set(hunkIdx, snippetSet);
}
return mapping;
}
/**
* Find the correct position of a snippet's newString in the content,
* disambiguating when multiple occurrences exist.
*/
findSnippetPosition(snippet: SnippetDiff, content: string): number {
const { newString, oldString } = snippet;
if (!newString) return -1; // Deletion — can't find empty string reliably
const firstPos = content.indexOf(newString);
if (firstPos === -1) return -1;
// Fast path: only one occurrence — no ambiguity
const lastPos = content.lastIndexOf(newString);
if (firstPos === lastPos) return firstPos;
// Multiple occurrences — collect all positions
const positions: number[] = [];
let searchStart = 0;
while (true) {
const pos = content.indexOf(newString, searchStart);
if (pos === -1) break;
positions.push(pos);
searchStart = pos + 1;
}
// Disambiguate using oldString context
if (oldString) {
const oldTokens = oldString
.split(/\s+/)
.filter((t) => t.length > 3)
.slice(0, 20); // Limit tokens to prevent excessive scanning
if (oldTokens.length > 0) {
let bestPos = firstPos;
let bestScore = 0;
for (const pos of positions) {
const nearbyStart = Math.max(0, pos - 500);
const nearbyEnd = Math.min(content.length, pos + newString.length + 500);
const nearby = content.substring(nearbyStart, nearbyEnd);
const matchScore = oldTokens.filter((t) => nearby.includes(t)).length;
if (matchScore > bestScore) {
bestScore = matchScore;
bestPos = pos;
}
}
return bestPos;
}
}
return firstPos;
}
// ── Private helpers ──
/**
* Check if a snippet's content overlaps with a hunk's reconstructed file ranges.
*
* @param hunkOldSide reconstructed original file text within hunk range (context + removed lines)
* @param hunkNewSide reconstructed modified file text within hunk range (context + added lines)
*/
private hasContentOverlap(
snippet: SnippetDiff,
hunkOldSide: string,
hunkNewSide: string
): boolean {
if (!snippet.newString && !snippet.oldString) return false;
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
// For Write: snippet.newString is the full file content — check if hunk's new side is within it
if (snippet.newString && hunkNewSide) {
return snippet.newString.includes(hunkNewSide);
}
return false;
}
// For Edit/MultiEdit: check if snippet falls within hunk's file range
const matchesOld = snippet.oldString ? hunkOldSide.includes(snippet.oldString) : false;
const matchesNew = snippet.newString ? hunkNewSide.includes(snippet.newString) : false;
return matchesOld || matchesNew;
}
}

View file

@ -3,11 +3,16 @@ import { createReadStream } from 'fs';
import * as readline from 'readline';
import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { countLineChanges } from './UnifiedLineCounter';
import type { MemberFullStats } from '@shared/types';
import type { FileLineStats, MemberFullStats } from '@shared/types';
const logger = createLogger('Service:MemberStatsComputer');
function isValidFilePath(value: string): boolean {
return value.length > 0 && value !== 'null' && value !== 'undefined' && value !== 'None';
}
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface CacheEntry {
@ -32,6 +37,7 @@ export class MemberStatsComputer {
let linesAdded = 0;
let linesRemoved = 0;
const filesTouchedSet = new Set<string>();
const perFileStats: Record<string, FileLineStats> = {};
const toolUsage: Record<string, number> = {};
let inputTokens = 0;
let outputTokens = 0;
@ -40,24 +46,38 @@ export class MemberStatsComputer {
let totalDurationMs = 0;
for (const filePath of paths) {
const fileStats = await this.parseFile(filePath);
linesAdded += fileStats.linesAdded;
linesRemoved += fileStats.linesRemoved;
for (const f of fileStats.filesTouched) filesTouchedSet.add(f);
for (const [tool, count] of Object.entries(fileStats.toolUsage)) {
const parsed = await this.parseFile(filePath);
linesAdded += parsed.linesAdded;
linesRemoved += parsed.linesRemoved;
for (const f of parsed.filesTouched) filesTouchedSet.add(f);
for (const [fp, fls] of Object.entries(parsed.perFileStats)) {
const existing = perFileStats[fp];
if (existing) {
existing.added += fls.added;
existing.removed += fls.removed;
} else {
perFileStats[fp] = { added: fls.added, removed: fls.removed };
}
}
for (const [tool, count] of Object.entries(parsed.toolUsage)) {
toolUsage[tool] = (toolUsage[tool] ?? 0) + count;
}
inputTokens += fileStats.inputTokens;
outputTokens += fileStats.outputTokens;
cacheReadTokens += fileStats.cacheReadTokens;
messageCount += fileStats.messageCount;
totalDurationMs += fileStats.durationMs;
inputTokens += parsed.inputTokens;
outputTokens += parsed.outputTokens;
cacheReadTokens += parsed.cacheReadTokens;
messageCount += parsed.messageCount;
totalDurationMs += parsed.durationMs;
}
const validFiles = [...filesTouchedSet]
.filter(isValidFilePath)
.sort((a, b) => a.localeCompare(b));
const stats: MemberFullStats = {
linesAdded,
linesRemoved,
filesTouched: [...filesTouchedSet].sort((a, b) => a.localeCompare(b)),
filesTouched: validFiles,
fileStats: perFileStats,
toolUsage,
inputTokens,
outputTokens,
@ -78,6 +98,7 @@ export class MemberStatsComputer {
linesAdded: number;
linesRemoved: number;
filesTouched: string[];
perFileStats: Record<string, FileLineStats>;
toolUsage: Record<string, number>;
inputTokens: number;
outputTokens: number;
@ -88,6 +109,7 @@ export class MemberStatsComputer {
let linesAdded = 0;
let linesRemoved = 0;
const filesTouchedSet = new Set<string>();
const perFileStats: Record<string, FileLineStats> = {};
const toolUsage: Record<string, number> = {};
let inputTokens = 0;
let outputTokens = 0;
@ -96,6 +118,24 @@ export class MemberStatsComputer {
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
// Track last known content per file for accurate Write/NotebookEdit diffs
const fileLastContent = new Map<string, string>();
const trackFile = (fp: string): void => {
if (typeof fp === 'string' && isValidFilePath(fp)) filesTouchedSet.add(fp);
};
const addFileLines = (fp: string, added: number, removed: number): void => {
if (!isValidFilePath(fp)) return;
const existing = perFileStats[fp];
if (existing) {
existing.added += added;
existing.removed += removed;
} else {
perFileStats[fp] = { added, removed };
}
};
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
@ -144,38 +184,83 @@ export class MemberStatsComputer {
// Track files
if (typeof input.file_path === 'string') {
filesTouchedSet.add(input.file_path);
trackFile(input.file_path);
}
if (typeof input.path === 'string' && toolName === 'Read') {
filesTouchedSet.add(input.path);
trackFile(input.path);
}
// Count lines for Edit
// Count lines for Edit (using semantic diff for accuracy)
if (toolName === 'Edit') {
const editPath = typeof input.file_path === 'string' ? input.file_path : '';
const oldStr = typeof input.old_string === 'string' ? input.old_string : '';
const newStr = typeof input.new_string === 'string' ? input.new_string : '';
const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0;
if (newLines > oldLines) linesAdded += newLines - oldLines;
if (oldLines > newLines) linesRemoved += oldLines - newLines;
}
// Count lines for Write
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
if (writeContent) {
linesAdded += writeContent.split('\n').length;
const replaceAll = input.replace_all === true;
const { added: fileAdded, removed: fileRemoved } = countLineChanges(
oldStr,
newStr
);
linesAdded += fileAdded;
linesRemoved += fileRemoved;
if (editPath) {
addFileLines(editPath, fileAdded, fileRemoved);
// Update fileLastContent so subsequent Writes diff against correct state
const prev = fileLastContent.get(editPath);
if (prev !== undefined && oldStr) {
if (replaceAll) {
fileLastContent.set(editPath, prev.split(oldStr).join(newStr));
} else {
const idx = prev.indexOf(oldStr);
if (idx !== -1) {
fileLastContent.set(
editPath,
prev.substring(0, idx) + newStr + prev.substring(idx + oldStr.length)
);
}
}
}
}
}
// Count lines for NotebookEdit
// Count lines for Write (track previous content for accurate diff)
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
const writePath = typeof input.file_path === 'string' ? input.file_path : '';
if (writeContent) {
const prevContent = fileLastContent.get(writePath) ?? '';
const { added: fileAdded, removed: fileRemoved } = countLineChanges(
prevContent,
writeContent
);
if (writePath) fileLastContent.set(writePath, writeContent);
linesAdded += fileAdded;
linesRemoved += fileRemoved;
if (writePath) {
addFileLines(writePath, fileAdded, fileRemoved);
}
}
}
// Count lines for NotebookEdit (semantic diff)
if (toolName === 'NotebookEdit') {
const src = typeof input.new_source === 'string' ? input.new_source : '';
if (src) {
linesAdded += src.split('\n').length;
const nbPath =
typeof input.notebook_path === 'string' ? input.notebook_path : '';
const prevContent = fileLastContent.get(nbPath) ?? '';
const { added: fileAdded, removed: fileRemoved } = countLineChanges(
prevContent,
src
);
if (nbPath) fileLastContent.set(nbPath, src);
linesAdded += fileAdded;
linesRemoved += fileRemoved;
if (nbPath) {
addFileLines(nbPath, fileAdded, fileRemoved);
}
}
if (typeof input.notebook_path === 'string') {
filesTouchedSet.add(input.notebook_path);
trackFile(input.notebook_path);
}
}
@ -186,7 +271,15 @@ export class MemberStatsComputer {
const bashLines = estimateBashLinesChanged(cmd);
linesAdded += bashLines.added;
linesRemoved += bashLines.removed;
for (const f of bashLines.files) filesTouchedSet.add(f);
const touchedFiles = [...new Set(bashLines.files)];
for (const f of touchedFiles) {
trackFile(f);
}
// Only attribute per-file lines when a single file is touched;
// with multiple files we can't determine per-file distribution
if (touchedFiles.length === 1) {
addFileLines(touchedFiles[0], bashLines.added, bashLines.removed);
}
}
}
}
@ -216,6 +309,7 @@ export class MemberStatsComputer {
linesAdded,
linesRemoved,
filesTouched: [...filesTouchedSet],
perFileStats,
toolUsage,
inputTokens,
outputTokens,
@ -308,8 +402,9 @@ export function estimateBashLinesChanged(command: string): BashLinesResult {
}
// 2. Echo / printf with redirect: echo "..." > /path OR printf "..." > /path
const echoPattern =
/(?:echo|printf)\s+(?:-[a-zA-Z]+\s+)?(?:"([^"]*)"|'([^']*)')\s*>{1,2}\s*(\S+)/g;
/(?:echo|printf)\s+(?:-[a-zA-Z]+\s+)?(?:"([^"]*)"|'([^']*)')\s*>{1,2}\s*(\S+)/g; // eslint-disable-line security/detect-unsafe-regex -- Fixed alternation, short command strings only
let echoMatch: RegExpExecArray | null;
while ((echoMatch = echoPattern.exec(command)) !== null) {
const content = echoMatch[1] ?? echoMatch[2] ?? '';
@ -317,7 +412,7 @@ export function estimateBashLinesChanged(command: string): BashLinesResult {
added += content.split('\\n').length;
}
const filePath = echoMatch[3];
if (filePath?.startsWith('/')) {
if (filePath?.trim()) {
files.push(filePath);
}
}
@ -349,7 +444,7 @@ export function estimateBashLinesChanged(command: string): BashLinesResult {
}
// 5. tee: ... | tee /path/to/file
const teePattern = /\btee\s+(?:-a\s+)?(\/\S+)/g;
const teePattern = /\btee\s+(?:-a\s+)?(\/\S+)/g; // eslint-disable-line security/detect-unsafe-regex -- Simple pattern on short command strings
let teeMatch: RegExpExecArray | null;
while ((teeMatch = teePattern.exec(command)) !== null) {
const filePath = teeMatch[1];

View file

@ -0,0 +1,509 @@
import { createLogger } from '@shared/utils/logger';
import { applyPatch, structuredPatch } from 'diff';
import { readFile, writeFile } from 'fs/promises';
import { diff3Merge } from 'node-diff3';
import { HunkSnippetMatcher } from './HunkSnippetMatcher';
import type {
ApplyReviewRequest,
ApplyReviewResult,
ConflictCheckResult,
FileChangeWithContent,
RejectResult,
SnippetDiff,
} from '@shared/types';
import type { StructuredPatchHunk } from 'diff';
const logger = createLogger('Service:ReviewApplierService');
/**
* Service for applying reject decisions from code review.
*
* Supports:
* - Conflict detection (file changed since review was computed)
* - Hunk-level rejection (reverse specific hunks)
* - File-level rejection (restore entire file to original)
* - Preview mode (show what would change without writing)
* - Batch review application
*/
export class ReviewApplierService {
private readonly matcher = new HunkSnippetMatcher();
/**
* Check if the file on disk has been modified since the review was computed.
* Compares current disk content against the expected modified content.
*/
async checkConflict(filePath: string, expectedModified: string): Promise<ConflictCheckResult> {
let currentContent: string;
try {
currentContent = await readFile(filePath, 'utf8');
} catch {
return {
hasConflict: true,
conflictContent: null,
currentContent: '',
originalContent: expectedModified,
};
}
const hasConflict = currentContent !== expectedModified;
return {
hasConflict,
conflictContent: hasConflict ? currentContent : null,
currentContent,
originalContent: expectedModified,
};
}
/**
* Reject specific hunks from a file's changes.
*
* PRIMARY approach: snippet-level replacement with positional reverse.
* FALLBACK: hunk-level inverse patch when snippet replacement fails.
*/
async rejectHunks(
_teamName: string,
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Promise<RejectResult> {
// Try snippet-level reverse first (most accurate)
const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
if (snippetResult) {
try {
await writeFile(filePath, snippetResult.newContent, 'utf8');
return snippetResult;
} catch (err) {
return {
success: false,
newContent: modified,
hadConflicts: false,
conflictDescription: `Не удалось записать файл: ${String(err)}`,
};
}
}
// Fallback: hunk-level inverse patch
const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices);
if (patchResult) {
try {
await writeFile(filePath, patchResult.newContent, 'utf8');
return patchResult;
} catch (err) {
return {
success: false,
newContent: modified,
hadConflicts: false,
conflictDescription: `Не удалось записать файл: ${String(err)}`,
};
}
}
// Both approaches failed — try three-way merge as last resort
const mergeResult = threeWayMerge(original, modified, original);
if (!mergeResult.hasConflicts) {
try {
await writeFile(filePath, mergeResult.content, 'utf8');
return {
success: true,
newContent: mergeResult.content,
hadConflicts: false,
};
} catch (err) {
return {
success: false,
newContent: modified,
hadConflicts: false,
conflictDescription: `Не удалось записать файл: ${String(err)}`,
};
}
}
return {
success: false,
newContent: modified,
hadConflicts: true,
conflictDescription: 'Не удалось применить reject: все стратегии завершились неудачно',
};
}
/**
* Reject the entire file restore to original content.
*/
async rejectFile(
_teamName: string,
filePath: string,
original: string,
modified: string
): Promise<RejectResult> {
// Check for conflicts first
const conflict = await this.checkConflict(filePath, modified);
if (conflict.hasConflict) {
// File was modified since review — try three-way merge
const currentContent = conflict.currentContent;
const mergeResult = threeWayMerge(modified, currentContent, original);
if (mergeResult.hasConflicts) {
return {
success: false,
newContent: currentContent,
hadConflicts: true,
conflictDescription:
'Файл был изменён после вычисления review, и три-сторонний merge обнаружил конфликты',
};
}
try {
await writeFile(filePath, mergeResult.content, 'utf8');
return {
success: true,
newContent: mergeResult.content,
hadConflicts: false,
};
} catch (err) {
return {
success: false,
newContent: currentContent,
hadConflicts: false,
conflictDescription: `Не удалось записать файл: ${String(err)}`,
};
}
}
// No conflict — simply write original content
try {
await writeFile(filePath, original, 'utf8');
return {
success: true,
newContent: original,
hadConflicts: false,
};
} catch (err) {
return {
success: false,
newContent: modified,
hadConflicts: false,
conflictDescription: `Не удалось записать файл: ${String(err)}`,
};
}
}
/**
* Preview what a reject operation would produce WITHOUT writing to disk.
*/
async previewReject(
_filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Promise<{ preview: string; hasConflicts: boolean }> {
// Try snippet-level reverse
const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
if (snippetResult) {
return { preview: snippetResult.newContent, hasConflicts: false };
}
// Fallback: hunk-level inverse patch
const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices);
if (patchResult) {
return { preview: patchResult.newContent, hasConflicts: patchResult.hadConflicts };
}
// Final fallback — three-way merge
const mergeResult = threeWayMerge(original, modified, original);
return { preview: mergeResult.content, hasConflicts: mergeResult.hasConflicts };
}
/**
* Apply all review decisions in batch.
*/
async applyReviewDecisions(
request: ApplyReviewRequest,
fileContents = new Map<string, FileChangeWithContent>()
): Promise<ApplyReviewResult> {
let applied = 0;
let skipped = 0;
let conflicts = 0;
const errors: ApplyReviewResult['errors'] = [];
for (const decision of request.decisions) {
const fileContent = fileContents.get(decision.filePath);
if (!fileContent) {
skipped++;
continue;
}
// Skip files where all hunks are accepted (nothing to reject)
if (decision.fileDecision === 'accepted') {
skipped++;
continue;
}
const original = fileContent.originalFullContent;
const modified = fileContent.modifiedFullContent;
if (original === null || modified === null) {
errors.push({
filePath: decision.filePath,
error: 'Содержимое файла недоступно для применения review',
});
continue;
}
try {
if (decision.fileDecision === 'rejected') {
// Reject entire file
const result = await this.rejectFile(
request.teamName,
decision.filePath,
original,
modified
);
if (result.success) {
applied++;
} else {
if (result.hadConflicts) conflicts++;
errors.push({
filePath: decision.filePath,
error: result.conflictDescription || 'Не удалось применить reject',
});
}
} else {
// Partial reject — only specific hunks
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
.filter(([, d]) => d === 'rejected')
.map(([idx]) => parseInt(idx, 10));
if (rejectedHunkIndices.length === 0) {
skipped++;
continue;
}
const result = await this.rejectHunks(
request.teamName,
decision.filePath,
original,
modified,
rejectedHunkIndices,
fileContent.snippets
);
if (result.success) {
applied++;
} else {
if (result.hadConflicts) conflicts++;
errors.push({
filePath: decision.filePath,
error: result.conflictDescription || 'Не удалось применить reject',
});
}
}
} catch (err) {
errors.push({
filePath: decision.filePath,
error: `Неожиданная ошибка: ${String(err)}`,
});
}
}
return { applied, skipped, conflicts, errors };
}
/**
* Save edited file content directly to disk.
*/
async saveEditedFile(filePath: string, content: string): Promise<{ success: boolean }> {
await writeFile(filePath, content, 'utf8');
return { success: true };
}
// ── Private: Rejection strategies ──
/**
* Snippet-level rejection: reverse specific snippets by position (most accurate).
*
* Uses HunkSnippetMatcher with content overlap analysis to map
* hunk indices snippet indices, then reverses matched snippets.
*/
private trySnippetLevelReject(
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): RejectResult | null {
const validSnippets = snippets.filter((s) => !s.isError);
if (validSnippets.length === 0) return null;
// Pass pre-filtered snippets — matcher returns indices relative to this array
const hunkToSnippets = this.matcher.matchHunksToSnippets(
original,
modified,
hunkIndices,
validSnippets
);
// Collect all unique snippet indices to reject
const snippetIndices = new Set<number>();
for (const indices of hunkToSnippets.values()) {
indices.forEach((idx) => snippetIndices.add(idx));
}
const snippetsToReject = Array.from(snippetIndices)
.map((idx) => validSnippets[idx])
.filter(Boolean);
if (snippetsToReject.length === 0) return null;
let content = modified;
// Find positions using disambiguation and sort descending for safe replacement
const positioned = snippetsToReject
.map((snippet) => {
const pos = this.matcher.findSnippetPosition(snippet, content);
return { snippet, pos };
})
.filter((item) => item.pos !== -1)
.sort((a, b) => b.pos - a.pos);
if (positioned.length !== snippetsToReject.length) {
// Some snippets' newStrings not found — can't do snippet-level
return null;
}
for (const { snippet, pos } of positioned) {
if (snippet.type === 'write-new') {
// Can't partially reject a file creation at snippet level
continue;
}
if (snippet.replaceAll) {
content = content.split(snippet.newString).join(snippet.oldString);
} else {
content =
content.substring(0, pos) +
snippet.oldString +
content.substring(pos + snippet.newString.length);
}
}
return {
success: true,
newContent: content,
hadConflicts: false,
};
}
/**
* Hunk-level rejection: create inverse patch for rejected hunks and apply it.
*/
private tryHunkLevelReject(
original: string,
modified: string,
hunkIndices: number[]
): RejectResult | null {
// Create structured patch
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return null;
// Validate hunk indices
const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
if (validIndices.length === 0) return null;
// Build a partial inverse patch: only reverse the rejected hunks
const inversedHunks: StructuredPatchHunk[] = [];
for (const idx of validIndices) {
const hunk = patch.hunks[idx];
if (!hunk) continue;
inversedHunks.push(invertHunk(hunk));
}
if (inversedHunks.length === 0) return null;
// Create a partial inverse patch with the inverted hunks
const inversePatch = {
oldFileName: 'file',
newFileName: 'file',
oldHeader: undefined,
newHeader: undefined,
hunks: inversedHunks,
};
// Apply the inverse patch to the modified content
const result = applyPatch(modified, inversePatch, { fuzzFactor: 2 });
if (result === false) {
logger.debug('Hunk-level inverse patch не удался');
return null;
}
return {
success: true,
newContent: result,
hadConflicts: false,
};
}
}
// ── Module-level helpers ──
/**
* Invert a single hunk: swap added/removed lines, swap old/new start/lines.
*/
function invertHunk(hunk: StructuredPatchHunk): StructuredPatchHunk {
const invertedLines = hunk.lines.map((line) => {
if (line.startsWith('+')) return '-' + line.substring(1);
if (line.startsWith('-')) return '+' + line.substring(1);
return line; // context lines remain unchanged
});
return {
oldStart: hunk.newStart,
oldLines: hunk.newLines,
newStart: hunk.oldStart,
newLines: hunk.oldLines,
lines: invertedLines,
};
}
/**
* Three-way merge using node-diff3.
*
* @param base base version (common ancestor)
* @param ours "our" version (current state)
* @param theirs "their" version (desired state)
* @returns merged content and conflict indicator
*/
function threeWayMerge(
base: string,
ours: string,
theirs: string
): { content: string; hasConflicts: boolean } {
const regions = diff3Merge(ours, base, theirs);
let hasConflicts = false;
const parts: string[] = [];
for (const region of regions) {
if (region.ok) {
parts.push(region.ok.join('\n'));
} else if (region.conflict) {
hasConflicts = true;
// Include conflict markers for visibility
parts.push('<<<<<<< current');
parts.push(region.conflict.a.join('\n'));
parts.push('=======');
parts.push(region.conflict.b.join('\n'));
parts.push('>>>>>>> original');
}
}
return {
content: parts.join('\n'),
hasConflicts,
};
}

View file

@ -0,0 +1,103 @@
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 { HunkDecision } from '@shared/types';
const logger = createLogger('ReviewDecisionStore');
export interface ReviewDecisionsData {
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
updatedAt: string;
}
export class ReviewDecisionStore {
private getDirPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'review-decisions');
}
private getFilePath(teamName: string, scopeKey: string): string {
return path.join(this.getDirPath(teamName), `${scopeKey}.json`);
}
async load(
teamName: string,
scopeKey: string
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
} | null> {
const filePath = this.getFilePath(teamName, scopeKey);
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read review decisions for ${teamName}/${scopeKey}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
logger.error(`Corrupted review decisions file for ${teamName}/${scopeKey}`);
return null;
}
if (!parsed || typeof parsed !== 'object') {
return null;
}
const data = parsed as Partial<ReviewDecisionsData>;
const hunkDecisions: Record<string, HunkDecision> =
data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {};
const fileDecisions: Record<string, HunkDecision> =
data.fileDecisions && typeof data.fileDecisions === 'object' ? data.fileDecisions : {};
return { hunkDecisions, fileDecisions };
}
async save(
teamName: string,
scopeKey: string,
data: {
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
}
): Promise<void> {
try {
const payload: ReviewDecisionsData = {
hunkDecisions: data.hunkDecisions,
fileDecisions: data.fileDecisions,
updatedAt: new Date().toISOString(),
};
await atomicWriteAsync(
this.getFilePath(teamName, scopeKey),
JSON.stringify(payload, null, 2)
);
} catch (error) {
logger.error(`Failed to save review decisions for ${teamName}/${scopeKey}: ${String(error)}`);
}
}
async clear(teamName: string, scopeKey: string): Promise<void> {
try {
await fs.promises.unlink(this.getFilePath(teamName, scopeKey));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(
`Failed to clear review decisions for ${teamName}/${scopeKey}: ${String(error)}`
);
}
}
}
}

View file

@ -0,0 +1,378 @@
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import * as readline from 'readline';
import type {
TaskBoundariesResult,
TaskBoundary,
TaskChangeScope,
TaskScopeConfidence,
} from '@shared/types';
const logger = createLogger('Service:TaskBoundaryParser');
/** Файл-модифицирующие инструменты, которые включаем в scope.toolUseIds */
const FILE_MODIFYING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
/** Кеш-запись: данные + mtime файла + время протухания */
interface BoundaryCacheEntry {
data: TaskBoundariesResult;
mtime: number;
expiresAt: number;
}
/** Информация о tool_use блоке собранная при парсинге */
interface ToolUseInfo {
toolUseId: string;
toolName: string;
filePath?: string;
}
/** Regex для teamctl task команд */
const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/;
export class TaskBoundaryParser {
private cache = new Map<string, BoundaryCacheEntry>();
private readonly CACHE_TTL = 60 * 1000; // 60s
/** Парсинг JSONL файла для обнаружения границ задач */
async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
// 1. Проверяем кеш (TTL + mtime)
let fileStat;
try {
fileStat = await stat(filePath);
} catch (err) {
logger.debug(`Cannot stat file ${filePath}: ${String(err)}`);
return { boundaries: [], scopes: [], isSingleTaskSession: true, detectedMechanism: 'none' };
}
const cached = this.cache.get(filePath);
if (cached?.mtime === fileStat.mtimeMs && cached.expiresAt > Date.now()) {
return cached.data;
}
// 2. Стриминг JSONL
const boundaries: TaskBoundary[] = [];
const allToolUsesByLine = new Map<number, ToolUseInfo[]>();
let lineNumber = 0;
let detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none' = 'none';
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
lineNumber++;
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as Record<string, unknown>;
const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : '';
const content = this.extractContent(entry);
if (!Array.isArray(content)) continue;
// Собираем ВСЕ tool_use блоки для scope tracking
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as Record<string, unknown>;
if (b.type !== 'tool_use') continue;
const rawName = typeof b.name === 'string' ? b.name : '';
const toolName = rawName.replace(/^proxy_/, '');
const toolUseId = typeof b.id === 'string' ? b.id : '';
const input = b.input as Record<string, unknown> | undefined;
const fp = typeof input?.file_path === 'string' ? input.file_path : undefined;
if (!allToolUsesByLine.has(lineNumber)) allToolUsesByLine.set(lineNumber, []);
allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp });
}
// Пробуем TaskUpdate
const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp);
if (taskUpdateBounds.length > 0) {
detectedMechanism = 'TaskUpdate';
boundaries.push(...taskUpdateBounds);
continue;
}
// Пробуем teamctl
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
if (teamctlBounds.length > 0) {
detectedMechanism = 'teamctl';
boundaries.push(...teamctlBounds);
}
} catch {
// Пропускаем невалидные строки
}
}
rl.close();
stream.destroy();
} catch (err) {
logger.debug(`Error reading file ${filePath}: ${String(err)}`);
}
// 3. Вычисляем scopes
const scopes = this.computeScopes(boundaries, allToolUsesByLine, lineNumber);
const uniqueTaskIds = new Set(boundaries.map((b) => b.taskId));
const isSingleTaskSession = uniqueTaskIds.size <= 1;
const result: TaskBoundariesResult = {
boundaries,
scopes,
isSingleTaskSession,
detectedMechanism,
};
this.cache.set(filePath, {
data: result,
mtime: fileStat.mtimeMs,
expiresAt: Date.now() + this.CACHE_TTL,
});
return result;
}
/** Получить scope для конкретной задачи */
async getTaskScope(filePath: string, taskId: string): Promise<TaskChangeScope | null> {
const result = await this.parseBoundaries(filePath);
return result.scopes.find((s) => s.taskId === taskId) ?? null;
}
/** Очистить кеш (для тестов) */
clearCache(): void {
this.cache.clear();
}
// ── Приватные методы ──
/** Извлечь content array из JSONL entry (оба формата: subagent и main) */
private extractContent(entry: Record<string, unknown>): unknown[] | null {
const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) return message.content as unknown[];
if (Array.isArray(entry.content)) return entry.content as unknown[];
return null;
}
/**
* Найти TaskUpdate/proxy_TaskUpdate tool_use блоки.
* status: in_progress start, completed complete
*/
private extractTaskUpdateBoundaries(
content: unknown[],
lineNumber: number,
timestamp: string
): TaskBoundary[] {
const results: TaskBoundary[] = [];
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as Record<string, unknown>;
if (b.type !== 'tool_use') continue;
const rawName = typeof b.name === 'string' ? b.name : '';
const toolName = rawName.replace(/^proxy_/, '');
if (toolName !== 'TaskUpdate') continue;
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;
const rawTaskId = input.taskId;
const taskId =
typeof rawTaskId === 'string'
? rawTaskId
: typeof rawTaskId === 'number'
? String(rawTaskId)
: '';
if (!taskId) continue;
const status = typeof input.status === 'string' ? input.status : '';
let event: 'start' | 'complete' | null = null;
if (status === 'in_progress') event = 'start';
else if (status === 'completed') event = 'complete';
if (event) {
const toolUseId = typeof b.id === 'string' ? b.id : undefined;
results.push({
taskId,
event,
lineNumber,
timestamp,
mechanism: 'TaskUpdate',
toolUseId,
});
}
}
return results;
}
/**
* Найти teamctl task start/complete/set-status команды в Bash tool_use блоках.
* Regex: /task\s+(start|complete|set-status)\s+(\d+)/
*/
private extractTeamctlBoundaries(
content: unknown[],
lineNumber: number,
timestamp: string
): TaskBoundary[] {
const results: TaskBoundary[] = [];
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as Record<string, unknown>;
if (b.type !== 'tool_use') continue;
const rawName = typeof b.name === 'string' ? b.name : '';
const toolName = rawName.replace(/^proxy_/, '');
if (toolName !== 'Bash') continue;
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;
const command = typeof input.command === 'string' ? input.command : '';
if (!command.includes('teamctl')) continue;
const match = TEAMCTL_TASK_REGEX.exec(command);
if (!match) continue;
const action = match[1]; // start | complete | set-status
const taskId = match[2];
let event: 'start' | 'complete' | null = null;
if (action === 'start') event = 'start';
else if (action === 'complete') event = 'complete';
else if (action === 'set-status') {
// set-status может быть start или complete — определяем по аргументам
if (command.includes('in_progress') || command.includes('in-progress')) event = 'start';
else if (command.includes('completed') || command.includes('done')) event = 'complete';
}
if (event) {
const toolUseId = typeof b.id === 'string' ? b.id : undefined;
results.push({
taskId,
event,
lineNumber,
timestamp,
mechanism: 'teamctl',
toolUseId,
});
}
}
return results;
}
/**
* Вычислить scopes для каждой задачи на основе границ.
*
* Tier 1 (high): обе границы (start + complete)
* Tier 2 (medium): только start (end = конец файла)
* Tier 3 (low): только complete (start = начало файла)
* Tier 4 (fallback): нет границ (весь файл)
*/
private computeScopes(
boundaries: TaskBoundary[],
allToolUsesByLine: Map<number, ToolUseInfo[]>,
totalLines: number
): TaskChangeScope[] {
// Группируем по taskId
const byTask = new Map<string, TaskBoundary[]>();
for (const b of boundaries) {
if (!byTask.has(b.taskId)) byTask.set(b.taskId, []);
byTask.get(b.taskId)!.push(b);
}
const scopes: TaskChangeScope[] = [];
for (const [taskId, taskBoundaries] of byTask) {
const starts = taskBoundaries.filter((b) => b.event === 'start');
const completes = taskBoundaries.filter((b) => b.event === 'complete');
const hasStart = starts.length > 0;
const hasComplete = completes.length > 0;
// Определяем границы строк
let startLine: number;
let endLine: number;
let startTimestamp: string;
let endTimestamp: string;
let confidence: TaskScopeConfidence;
if (hasStart && hasComplete) {
// Tier 1: обе границы
const firstStart = starts.reduce(
(a, b) => (a.lineNumber < b.lineNumber ? a : b),
starts[0]
);
const lastComplete = completes.reduce(
(a, b) => (a.lineNumber > b.lineNumber ? a : b),
completes[0]
);
startLine = firstStart.lineNumber;
endLine = lastComplete.lineNumber;
startTimestamp = firstStart.timestamp;
endTimestamp = lastComplete.timestamp;
confidence = { tier: 1, label: 'high', reason: 'Both start and complete markers found' };
} else if (hasStart) {
// Tier 2: только start
const firstStart = starts.reduce(
(a, b) => (a.lineNumber < b.lineNumber ? a : b),
starts[0]
);
startLine = firstStart.lineNumber;
endLine = totalLines;
startTimestamp = firstStart.timestamp;
endTimestamp = '';
confidence = {
tier: 2,
label: 'medium',
reason: 'Only start marker found, end assumed at file end',
};
} else {
// Tier 3: только complete
const lastComplete = completes.reduce(
(a, b) => (a.lineNumber > b.lineNumber ? a : b),
completes[0]
);
startLine = 1;
endLine = lastComplete.lineNumber;
startTimestamp = '';
endTimestamp = lastComplete.timestamp;
confidence = {
tier: 3,
label: 'low',
reason: 'Only complete marker found, start assumed at file beginning',
};
}
// Собираем tool_use IDs в диапазоне [startLine, endLine], только файл-модифицирующие
const toolUseIds: string[] = [];
const filePaths = new Set<string>();
for (const [line, tools] of allToolUsesByLine) {
if (line < startLine || line > endLine) continue;
for (const tool of tools) {
if (FILE_MODIFYING_TOOLS.has(tool.toolName) && tool.toolUseId) {
toolUseIds.push(tool.toolUseId);
if (tool.filePath) filePaths.add(tool.filePath);
}
}
}
scopes.push({
taskId,
memberName: '', // будет заполнен вызывающим кодом
startLine,
endLine,
startTimestamp,
endTimestamp,
toolUseIds,
filePaths: [...filePaths],
confidence,
});
}
return scopes;
}
}

View file

@ -6,7 +6,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 5;
const TOOL_VERSION = 10;
function buildTeamCtlScript(): string {
const script = String.raw`#!/usr/bin/env node
@ -166,7 +166,17 @@ function getPaths(flags, teamName) {
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 };
const processesPath = path.join(teamDir, 'processes.json');
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
}
function inferLeadName(paths) {
const config = readJson(path.join(paths.teamDir, 'config.json'), null);
if (!config || !Array.isArray(config.members)) return 'team-lead';
const lead = config.members.find(function (m) {
return m.role && String(m.role).toLowerCase().includes('lead');
});
return lead ? String(lead.name) : (config.members[0] ? String(config.members[0].name) : 'team-lead');
}
function readTask(paths, taskId) {
@ -191,29 +201,71 @@ function setTaskStatus(paths, taskId, status) {
writeTask(taskPath, task);
}
function setTaskOwner(paths, taskId, owner) {
const { taskPath, task } = readTask(paths, taskId);
if (owner) {
task.owner = owner;
} else {
delete task.owner;
}
writeTask(taskPath, task);
return task;
}
function addTaskComment(paths, taskId, flags) {
var text = typeof flags.text === 'string' ? flags.text.trim() : '';
if (!text) die('Missing --text');
var from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'agent';
var ref;
var task;
var taskPath;
var commentId;
var comment;
var existing;
var lastErr;
for (var attempt = 0; attempt < 8; attempt++) {
try {
ref = readTask(paths, taskId);
task = ref.task;
taskPath = ref.taskPath;
if (task.needsClarification === 'lead' && from !== task.owner) {
delete task.needsClarification;
}
existing = Array.isArray(task.comments) ? task.comments : [];
commentId = crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
comment = {
id: commentId,
author: from,
text: text,
createdAt: nowIso(),
};
task.comments = existing.concat([comment]);
writeTask(taskPath, task);
return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner };
} catch (e) {
lastErr = e;
if (attempt === 7) throw e;
}
}
throw lastErr;
}
function setNeedsClarification(paths, taskId, value) {
var allowed = { lead: true, user: true, clear: true };
if (!allowed[value]) die('Invalid value: ' + value + '. Use: lead, user, clear');
var ref = readTask(paths, taskId);
var task = ref.task;
var taskPath = ref.taskPath;
var existing = Array.isArray(task.comments) ? task.comments : [];
var commentId = crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
var comment = {
id: commentId,
author: from,
text: text,
createdAt: nowIso(),
};
task.comments = existing.concat([comment]);
writeTask(taskPath, task);
return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner };
if (value === 'clear') {
delete ref.task.needsClarification;
} else {
ref.task.needsClarification = value;
}
writeTask(ref.taskPath, ref.task);
}
function listTaskIds(tasksDir) {
@ -271,25 +323,36 @@ function createTask(paths, flags) {
: 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 from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
const task = {
id: nextId,
subject,
description: String(description || subject),
activeForm: activeForm ? String(activeForm) : undefined,
owner,
createdBy: from,
status,
blocks: [],
blockedBy: [],
};
writeTask(taskPath, task);
let nextId;
let task;
let taskPath;
while (true) {
nextId = getNextTaskId(paths);
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
task = {
id: nextId,
subject,
description: String(description || subject),
activeForm: activeForm ? String(activeForm) : undefined,
owner,
createdBy: from,
status,
blocks: [],
blockedBy: [],
};
try {
const fd = fs.openSync(taskPath, 'wx');
fs.closeSync(fd);
atomicWrite(taskPath, JSON.stringify(task, null, 2));
const verify = readJson(taskPath, null);
if (!verify) die('Task write verification failed');
break;
} catch (e) {
if (e && e.code === 'EEXIST') continue;
throw e;
}
}
updateHighwatermark(paths, nextId);
return task;
}
@ -340,7 +403,7 @@ function sendInboxMessage(paths, teamName, flags) {
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 from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
ensureDir(path.dirname(inboxPath));
@ -374,7 +437,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
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 from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
const text = note
? 'Task #' + String(taskId) + ' approved.\n\n' + note
@ -396,7 +459,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
task.status = 'in_progress';
writeTask(taskPath, task);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const text =
'Task #' +
String(taskId) +
@ -412,6 +475,210 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
});
}
function readProcessesSafe(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (err && err.code === 'EPERM') return true;
return false;
}
}
function processRegister(paths, flags) {
const pid = Number(flags.pid);
if (!Number.isInteger(pid) || pid <= 0) die('Invalid --pid (must be > 0)');
const label = typeof flags.label === 'string' ? flags.label.trim() : '';
if (!label) die('Missing --label');
const rawPort = flags.port != null ? Number(flags.port) : undefined;
const port = rawPort != null && Number.isInteger(rawPort) && rawPort >= 1 && rawPort <= 65535 ? rawPort : undefined;
const url = typeof flags.url === 'string' && flags.url.trim() ? flags.url.trim() : undefined;
const claudeProcessId = typeof flags['claude-process-id'] === 'string' ? flags['claude-process-id'].trim() : undefined;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
const command = typeof flags.command === 'string' ? flags.command.trim() : undefined;
const list = readProcessesSafe(paths.processesPath);
const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
const entry = {
id: existingIdx >= 0 ? list[existingIdx].id : (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random())),
port: port,
url: url,
label: label,
pid: pid,
claudeProcessId: claudeProcessId,
registeredBy: from,
command: command,
registeredAt: existingIdx >= 0 ? list[existingIdx].registeredAt : nowIso(),
};
if (existingIdx >= 0) {
list[existingIdx] = entry;
} else {
list.push(entry);
}
atomicWrite(paths.processesPath, JSON.stringify(list, null, 2));
var portStr = port ? ' port=' + String(port) : '';
process.stdout.write('OK process registered pid=' + String(pid) + portStr + '\n');
}
function processUnregister(paths, flags) {
const list = readProcessesSafe(paths.processesPath);
const pid = flags.pid ? Number(flags.pid) : undefined;
const id = typeof flags.id === 'string' ? flags.id.trim() : undefined;
if (!pid && !id) die('Missing --pid or --id');
const idx = list.findIndex(function (p) {
if (pid) return p.pid === pid;
return p.id === id;
});
if (idx < 0) die('Process not found');
const removed = list.splice(idx, 1)[0];
atomicWrite(paths.processesPath, JSON.stringify(list, null, 2));
process.stdout.write('OK process unregistered pid=' + String(removed.pid) + '\n');
}
function processList(paths) {
const list = readProcessesSafe(paths.processesPath);
const result = list.map(function (p) {
return Object.assign({}, p, { alive: isProcessAlive(p.pid) });
});
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
function taskBriefing(paths, teamName, flags) {
var forMember = typeof flags['for'] === 'string' ? flags['for'].trim() : '';
if (!forMember) die('Missing --for <member-name>');
var kanban = readKanbanState(paths, teamName);
var ids = listTaskIds(paths.tasksDir);
var allTasks = [];
for (var i = 0; i < ids.length; i++) {
try {
var taskPath = path.join(paths.tasksDir, ids[i] + '.json');
var t = readJson(taskPath, null);
if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) {
try { t._mtime = fs.statSync(taskPath).mtime.toISOString(); } catch (_e) { t._mtime = ''; }
allTasks.push(t);
}
} catch (e) { /* skip unreadable */ }
}
function getEffectiveColumn(task) {
var ks = kanban.tasks[String(task.id)];
if (ks) return ks.column;
if (task.status === 'pending') return 'todo';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'completed') return 'done';
return task.status;
}
var relevant = allTasks.filter(function (t) {
var col = getEffectiveColumn(t);
return col !== 'approved' && t.status !== 'deleted';
});
var myTasks = { todo: [], in_progress: [], done: [], review: [] };
var otherTasks = { todo: [], in_progress: [], done: [], review: [] };
for (var j = 0; j < relevant.length; j++) {
var task = relevant[j];
var col = getEffectiveColumn(task);
var bucket = (task.owner === forMember) ? myTasks : otherTasks;
if (col === 'todo') bucket.todo.push(task);
else if (col === 'in_progress') bucket.in_progress.push(task);
else if (col === 'done') bucket.done.push(task);
else if (col === 'review') bucket.review.push(task);
}
function sortByMtime(arr) {
return arr.sort(function (a, b) {
var da = a._mtime || '';
var db = b._mtime || '';
return da < db ? 1 : da > db ? -1 : 0;
});
}
myTasks.done = sortByMtime(myTasks.done).slice(0, 15);
otherTasks.done = sortByMtime(otherTasks.done).slice(0, 15);
var lines = [];
lines.push('=== Task Briefing for ' + forMember + ' ===');
lines.push('');
function formatTask(t) {
var parts = [];
parts.push('#' + t.id + ' [' + getEffectiveColumn(t).toUpperCase() + '] ' + t.subject);
if (t.owner) parts.push(' Owner: ' + t.owner);
if (t.description && t.description !== t.subject) {
parts.push(' Description: ' + t.description.slice(0, 500));
}
if (t.blockedBy && t.blockedBy.length > 0) {
parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', '));
}
if (t.related && t.related.length > 0) {
parts.push(' Related: ' + t.related.map(function(id) { return '#' + id; }).join(', '));
}
if (t.needsClarification) {
parts.push(' *** NEEDS CLARIFICATION: from ' + t.needsClarification.toUpperCase() + ' ***');
}
if (Array.isArray(t.comments) && t.comments.length > 0) {
parts.push(' Comments (' + t.comments.length + '):');
for (var c = 0; c < t.comments.length; c++) {
var cm = t.comments[c];
var ts = cm.createdAt ? ' (' + cm.createdAt + ')' : '';
parts.push(' [' + (cm.author || '?') + ts + '] ' + (cm.text || '').slice(0, 300));
}
}
return parts.join('\n');
}
function renderSection(label, tasks) {
if (tasks.length === 0) return;
lines.push('--- ' + label + ' (' + tasks.length + ') ---');
for (var k = 0; k < tasks.length; k++) {
lines.push(formatTask(tasks[k]));
lines.push('');
}
}
lines.push('== YOUR TASKS ==');
renderSection('IN PROGRESS', myTasks.in_progress);
renderSection('TODO', myTasks.todo);
renderSection('REVIEW', myTasks.review);
renderSection('DONE (recent)', myTasks.done);
if (myTasks.in_progress.length + myTasks.todo.length + myTasks.review.length + myTasks.done.length === 0) {
lines.push('(no tasks assigned to you)');
lines.push('');
}
lines.push('== TEAM BOARD (others) ==');
renderSection('IN PROGRESS', otherTasks.in_progress);
renderSection('TODO', otherTasks.todo);
renderSection('REVIEW', otherTasks.review);
renderSection('DONE (recent)', otherTasks.done);
if (otherTasks.in_progress.length + otherTasks.todo.length + otherTasks.review.length + otherTasks.done.length === 0) {
lines.push('(no other tasks on the board)');
lines.push('');
}
process.stdout.write(lines.join('\n') + '\n');
}
function printHelp() {
const inferred = inferTeamNameFromScriptPath();
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
@ -424,12 +691,19 @@ function printHelp() {
' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
' node teamctl.js task briefing --for <member-name> [--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>]',
' node teamctl.js process register --pid <pid> --label <label> [--port <port>] [--url <url>] [--claude-process-id <id>] [--from <member>] [--command <cmd>] [--team <team>]',
' node teamctl.js process unregister --pid <pid> [--team <team>]',
' node teamctl.js process unregister --id <uuid> [--team <team>]',
' node teamctl.js process list [--team <team>]',
'',
'Options:',
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
@ -481,7 +755,7 @@ async function main() {
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';
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
@ -546,6 +820,49 @@ async function main() {
process.stdout.write('OK comment added to task #' + String(id) + '\n');
return;
}
if (action === 'set-clarification') {
const id = rest[0] || args.flags.id;
const val = rest[1] || args.flags.value;
if (!id || !val) die('Usage: task set-clarification <id> <lead|user|clear>');
setNeedsClarification(paths, String(id), String(val));
process.stdout.write('OK task #' + String(id) + ' needsClarification=' + (val === 'clear' ? 'cleared' : String(val)) + '\n');
return;
}
if (action === 'set-owner' || action === 'assign') {
const id = rest[0] || args.flags.id;
const owner = rest[1] || args.flags.owner;
if (!id) die('Usage: task set-owner <id> <member|clear>');
if (!owner) die('Usage: task set-owner <id> <member|clear>');
const effectiveOwner = owner === 'clear' || owner === 'none' ? null : String(owner);
const task = setTaskOwner(paths, String(id), effectiveOwner);
process.stdout.write('OK task #' + String(id) + ' owner=' + (effectiveOwner || 'cleared') + '\n');
const notify = args.flags.notify === true;
if (notify && effectiveOwner) {
const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
const parts = ['Task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
if (task.description && task.description !== task.subject) {
parts.push('\nDescription:\n' + String(task.description).slice(0, 500));
}
parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:',
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)}
);
sendInboxMessage(paths, teamName, {
to: effectiveOwner,
text: parts.join('\n'),
summary: 'Task #' + String(task.id) + ' assigned',
from,
});
}
return;
}
if (action === 'briefing') {
taskBriefing(paths, teamName, args.flags);
return;
}
die('Unknown task action: ' + String(action));
}
@ -618,6 +935,22 @@ async function main() {
die('Unknown message action: ' + String(action));
}
if (domain === 'process') {
if (action === 'register') {
processRegister(paths, args.flags);
return;
}
if (action === 'unregister' || action === 'remove') {
processUnregister(paths, args.flags);
return;
}
if (action === 'list') {
processList(paths);
return;
}
die('Unknown process action: ' + String(action));
}
die('Unknown domain: ' + String(domain));
}

View file

@ -121,6 +121,7 @@ export class TeamConfigReader {
? config.projectPathHistory
: undefined,
sessionHistory: Array.isArray(config.sessionHistory) ? config.sessionHistory : undefined,
deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined,
});
} catch {
logger.debug(`Skipping team dir without valid config: ${entry.name}`);

View file

@ -5,6 +5,7 @@ import {
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { isProcessAlive } from '@main/utils/processHealth';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
@ -43,6 +44,7 @@ import type {
TeamCreateConfigRequest,
TeamData,
TeamMember,
TeamProcess,
TeamSummary,
TeamTask,
TeamTaskStatus,
@ -54,8 +56,12 @@ const logger = createLogger('Service:TeamDataService');
const MIN_TEXT_LENGTH = 30;
const MAX_LEAD_TEXTS = 50;
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
export class TeamDataService {
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
private processHealthTeams = new Set<string>();
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
@ -79,14 +85,20 @@ export class TeamDataService {
this.configReader.listTeams(),
]);
const teamInfoMap = new Map<string, { displayName: string; projectPath?: string }>();
const teamInfoMap = new Map<
string,
{ displayName: string; projectPath?: string; deletedAt?: string }
>();
for (const team of teams) {
teamInfoMap.set(team.teamName, {
displayName: team.displayName,
projectPath: team.projectPath,
deletedAt: team.deletedAt,
});
}
const deletedTeams = new Set(teams.filter((t) => t.deletedAt).map((t) => t.teamName));
const teamNames = [
...new Set(rawTasks.map((t) => t.teamName).filter((n) => teamInfoMap.has(n))),
];
@ -117,6 +129,7 @@ export class TeamDataService {
teamDisplayName: info.displayName,
projectPath: task.projectPath ?? info.projectPath,
kanbanColumn,
teamDeleted: deletedTeams.has(task.teamName) || undefined,
};
});
}
@ -129,6 +142,26 @@ export class TeamDataService {
}
async deleteTeam(teamName: string): Promise<void> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
config.deletedAt = new Date().toISOString();
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
}
async restoreTeam(teamName: string): Promise<void> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
delete config.deletedAt;
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
}
async permanentlyDeleteTeam(teamName: string): Promise<void> {
const teamsDir = path.join(getTeamsBasePath(), teamName);
await fs.promises.rm(teamsDir, { recursive: true, force: true });
@ -252,6 +285,21 @@ export class TeamDataService {
return { ...task, kanbanColumn };
});
let processes: TeamProcess[] = [];
try {
processes = await this.readProcesses(teamName);
} catch {
warnings.push('Processes failed to load');
}
// Auto-track teams with alive processes for periodic health checks
const hasAlive = processes.some((p) => !p.stoppedAt);
if (hasAlive) {
this.processHealthTeams.add(teamName);
} else {
this.processHealthTeams.delete(teamName);
}
return {
teamName,
config,
@ -259,10 +307,163 @@ export class TeamDataService {
members,
messages,
kanbanState,
processes,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
startProcessHealthPolling(): void {
if (this.processHealthTimer) return;
this.processHealthTimer = setInterval(() => {
void this.processHealthTick();
}, PROCESS_HEALTH_INTERVAL_MS);
}
stopProcessHealthPolling(): void {
if (this.processHealthTimer) {
clearInterval(this.processHealthTimer);
this.processHealthTimer = null;
}
this.processHealthTeams.clear();
}
trackProcessHealthForTeam(teamName: string): void {
this.processHealthTeams.add(teamName);
}
untrackProcessHealthForTeam(teamName: string): void {
this.processHealthTeams.delete(teamName);
}
private async processHealthTick(): Promise<void> {
for (const teamName of this.processHealthTeams) {
try {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
let raw: unknown[];
try {
const content = await fs.promises.readFile(processesPath, 'utf8');
const parsed: unknown = JSON.parse(content);
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
} catch {
continue;
}
const processes = raw.filter(
(p): p is TeamProcess =>
!!p &&
typeof p === 'object' &&
'pid' in p &&
typeof (p as TeamProcess).pid === 'number' &&
(p as TeamProcess).pid > 0
);
let dirty = false;
for (const proc of processes) {
if (!proc.stoppedAt && !isProcessAlive(proc.pid)) {
proc.stoppedAt = new Date().toISOString();
dirty = true;
}
}
if (dirty) {
await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2));
// atomicWrite triggers FileWatcher → team-change 'process' → UI refresh
// No need to emit manually — FileWatcher handles it.
}
} catch {
// best-effort per team
}
}
}
private async readProcesses(teamName: string): Promise<TeamProcess[]> {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
let raw: unknown[];
try {
const content = await fs.promises.readFile(processesPath, 'utf8');
const parsed: unknown = JSON.parse(content);
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
} catch {
return [];
}
const processes = raw.filter(
(p): p is TeamProcess =>
!!p &&
typeof p === 'object' &&
'pid' in p &&
typeof (p as TeamProcess).pid === 'number' &&
(p as TeamProcess).pid > 0
);
let dirty = false;
for (const proc of processes) {
if (!proc.stoppedAt && !isProcessAlive(proc.pid)) {
proc.stoppedAt = new Date().toISOString();
dirty = true;
}
}
if (dirty) {
try {
await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2));
} catch {
// best-effort write-back
}
}
return processes;
}
/**
* Kill a registered CLI process by PID (SIGTERM) and mark it as stopped in processes.json.
*/
async killProcess(teamName: string, pid: number): Promise<void> {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
// Try to kill the process
try {
process.kill(pid, 'SIGTERM');
} catch (err: unknown) {
// ESRCH = process not found — still mark as stopped below
if (
err instanceof Error &&
'code' in err &&
(err as NodeJS.ErrnoException).code !== 'ESRCH'
) {
throw new Error(`Failed to kill process ${pid}: ${(err as Error).message}`);
}
}
// Update processes.json to set stoppedAt
let raw: unknown[];
try {
const content = await fs.promises.readFile(processesPath, 'utf8');
const parsed: unknown = JSON.parse(content);
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
} catch {
return; // No processes file — nothing to update
}
let dirty = false;
for (const entry of raw) {
if (
entry &&
typeof entry === 'object' &&
'pid' in entry &&
(entry as TeamProcess).pid === pid &&
!(entry as TeamProcess).stoppedAt
) {
(entry as TeamProcess).stoppedAt = new Date().toISOString();
dirty = true;
}
}
if (dirty) {
await atomicWriteAsync(processesPath, JSON.stringify(raw, null, 2));
}
}
/**
* Enriches members with gitBranch when their cwd differs from the lead's.
* Mutates members in-place for efficiency (called right after resolveMembers).
@ -431,8 +632,10 @@ export class TeamDataService {
AGENT_BLOCK_CLOSE
);
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: request.owner,
from: leadName,
text: parts.join('\n'),
summary: `New task #${task.id} assigned`,
});
@ -469,8 +672,10 @@ export class TeamDataService {
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE
);
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: task.owner,
from: leadName,
text: parts.join('\n'),
summary: `Task #${task.id} started`,
});
@ -486,10 +691,30 @@ export class TeamDataService {
await this.taskWriter.updateStatus(teamName, taskId, status);
}
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
await this.taskWriter.softDelete(teamName, taskId);
}
async restoreTask(teamName: string, taskId: string): Promise<void> {
await this.taskWriter.restoreTask(teamName, taskId);
}
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
return this.taskReader.getDeletedTasks(teamName);
}
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
await this.taskWriter.updateOwner(teamName, taskId, owner);
}
async setTaskNeedsClarification(
teamName: string,
taskId: string,
value: 'lead' | 'user' | null
): Promise<void> {
await this.taskWriter.setNeedsClarification(teamName, taskId, value);
}
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
const comment = await this.taskWriter.addComment(teamName, taskId, text);
@ -499,6 +724,13 @@ export class TeamDataService {
this.toolsInstaller.ensureInstalled(),
]);
const task = tasks.find((t) => t.id === taskId);
// Auto-clear needsClarification: "user" on UI comment
// UI comments always have author "user" (TeamTaskWriter default)
if (task?.needsClarification === 'user') {
await this.taskWriter.setNeedsClarification(teamName, taskId, null);
}
if (task?.owner) {
const parts = [
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
@ -507,8 +739,10 @@ export class TeamDataService {
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "<your-name>"`,
AGENT_BLOCK_CLOSE,
];
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: task.owner,
from: leadName,
text: parts.join('\n'),
summary: `Comment on #${taskId}`,
});
@ -524,6 +758,17 @@ export class TeamDataService {
return this.inboxWriter.sendMessage(teamName, request);
}
private async resolveLeadName(teamName: string): Promise<string> {
try {
const config = await this.configReader.getConfig(teamName);
if (!config) return 'team-lead';
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
} catch {
return 'team-lead';
}
}
async sendDirectToLead(
teamName: string,
leadName: string,
@ -582,9 +827,13 @@ export class TeamDataService {
}
try {
const toolPath = await this.toolsInstaller.ensureInstalled();
const [toolPath, leadName] = await Promise.all([
this.toolsInstaller.ensureInstalled(),
this.resolveLeadName(teamName),
]);
await this.sendMessage(teamName, {
member: reviewer,
from: leadName,
text:
`Please review task #${taskId}.\n\n` +
`${AGENT_BLOCK_OPEN}\n` +
@ -786,8 +1035,10 @@ export class TeamDataService {
try {
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: task.owner,
from: leadName,
text:
`Task #${taskId} needs fixes.\n\n` +
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +

View file

@ -243,6 +243,36 @@ export class TeamMemberLogsFinder {
return paths;
}
/** Быстрая проверка: содержит ли файл TaskUpdate/teamctl маркер для данного taskId */
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`"taskId"\\s*:\\s*"${escapedTaskId}"`);
try {
for await (const line of rl) {
if (line.includes('TaskUpdate') && pattern.test(line)) {
rl.close();
stream.destroy();
return true;
}
if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) {
rl.close();
stream.destroy();
return true;
}
}
} catch {
// ignore read errors
}
rl.close();
stream.destroy();
return false;
}
private async discoverProjectSessions(teamName: string): Promise<{
projectDir: string;
projectId: string;

View file

@ -9,7 +9,12 @@ import {
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import {
AGENT_BLOCK_CLOSE,
AGENT_BLOCK_OPEN,
stripAgentBlocks,
} from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { createLogger } from '@shared/utils/logger';
import { execFile, spawn } from 'child_process';
@ -26,6 +31,7 @@ import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import type {
InboxMessage,
@ -37,6 +43,7 @@ import type {
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
TeamTask,
} from '@shared/types';
const logger = createLogger('Service:TeamProvisioning');
@ -131,8 +138,14 @@ interface ProvisioningRun {
directReplyParts: string[];
/** Accumulates assistant text during provisioning phase for live UI preview. */
provisioningOutputParts: string[];
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
detectedSessionId: string | null;
/** Lead process activity: 'active' during turn processing, 'idle' waiting for input, 'offline' after exit. */
leadActivityState: LeadActivityState;
}
type LeadActivityState = 'active' | 'idle' | 'offline';
type ProvisioningAuthSource =
| 'anthropic_api_key'
| 'anthropic_auth_token'
@ -293,9 +306,32 @@ function buildTaskStatusProtocol(teamName: string): string {
- Typical flow:
a) Owner finishes work on #X task complete #X
b) Reviewer accepts review approve #X
10. CLARIFICATION PROTOCOL (CRITICAL MANDATORY):
When you are blocked and need information to continue a task, you MUST do BOTH steps below skipping the Bash command breaks the task board:
a) STEP 1 FIRST, set the clarification flag via Bash (this updates the task board):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> lead --from "<your-name>"
b) STEP 2 THEN, send a message to your team lead via SendMessage explaining what you need.
IMPORTANT: Always run the Bash command BEFORE sending the message. The flag is what makes the task board show "needs clarification" without it, your request is invisible on the board.
c) The flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
d) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
Failure to follow this protocol means the task board will show incorrect status.`);
}
function buildProcessRegistrationProtocol(teamName: string): string {
return wrapInAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
1. Launch with & to get PID:
pnpm dev &
2. Register immediately (--port and --url are optional, use when the process listens on a port):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process register --pid $! --label "<description>" --from "<your-name>" [--port <PORT> --url "http://localhost:<PORT>"]
3. VERIFY registration succeeded (MANDATORY never skip this step):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process list
4. When stopping a process:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process unregister --pid <PID>
If verification in step 3 fails or the process is missing from the list, re-register it.`);
}
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
return wrapInAgentBlock(
[
@ -304,10 +340,23 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
``,
`Task board operations — use teamctl.js via Bash:`,
`- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"`,
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
``,
`Notification policy:`,
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
``,
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag) or SendMessage.`,
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "${leadName}"`,
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
` 1) FIRST, set the flag to "user" via Bash (this updates the task board):`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> user --from "${leadName}"`,
` 2) THEN, send a message to "user" explaining the question.`,
` 3) THEN, reply to the teammate telling them to wait.`,
` IMPORTANT: Always run the Bash command BEFORE sending messages. Without the flag, the task board won't show that the task is blocked waiting for user input.`,
].join('\n')
);
}
@ -329,7 +378,9 @@ function buildAgentBlockUsagePolicy(): string {
${AGENT_BLOCK_OPEN}
(internal instructions: commands, script usage, paths, etc.)
${AGENT_BLOCK_CLOSE}
- Put ONLY the internal instructions inside the agent-only block.`;
- Put ONLY the internal instructions inside the agent-only block.
- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty.
- CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`;
}
function getSystemLocale(): string {
@ -348,11 +399,44 @@ function getAgentLanguageInstruction(): string {
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
}
/** Build a concise task snapshot for a specific member (pending/in_progress tasks only). */
function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string {
const activeTasks = tasks.filter(
(t) =>
t.owner === memberName &&
(t.status === 'pending' || t.status === 'in_progress') &&
!t.id.startsWith('_internal')
);
if (activeTasks.length === 0) return '';
const lines = activeTasks.map((t) => {
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
return ` - #${t.id} [${t.status}] ${t.subject}${desc}`;
});
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
}
/** Build a full task board snapshot for the lead. */
function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
const active = tasks.filter(
(t) => (t.status === 'pending' || t.status === 'in_progress') && !t.id.startsWith('_internal')
);
if (active.length === 0) return '\nNo pending tasks on the board.\n';
const lines = active.map((t) => {
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${desc}`;
});
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
}
function buildProvisioningPrompt(request: TeamCreateRequest): string {
const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description';
const members = buildMembersPrompt(request.members);
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const userPromptBlock = request.prompt?.trim()
@ -362,8 +446,11 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
const leadName =
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
Goal: Provision a Claude Code agent team with live teammates.
@ -409,6 +496,8 @@ Steps (execute in this exact order):
${taskProtocol}
${processRegistration}
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked create tasks on the team board.
- Prefer fewer, broader tasks over many micro-tasks.
- Avoid duplicate notifications for the same assignment.
@ -422,26 +511,59 @@ ${members}
function buildLaunchPrompt(
request: TeamLaunchRequest,
members: TeamCreateRequest['members']
members: TeamCreateRequest['members'],
tasks: TeamTask[]
): string {
const membersBlock = buildMembersPrompt(members);
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
// Build per-member task snapshots to include in each teammate's spawn prompt
const memberTaskBlocks = new Map<string, string>();
for (const m of members) {
const snapshot = buildMemberTaskSnapshot(m.name, tasks);
if (snapshot) memberTaskBlocks.set(m.name, snapshot);
}
// Build the teammate spawn prompt template with member-specific task injection
const memberSpawnInstructions = members
.map((m) => {
const taskBlock = memberTaskBlocks.get(m.name) || '';
const hasTasks = Boolean(taskBlock);
return ` For "${m.name}":
- prompt:
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".
${languageInstruction}
The team has been reconnected after a restart.
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
Your FIRST action: run this command to get your full task briefing with descriptions and comments:
node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task briefing --for "${m.name}"
Then resume in_progress tasks first, then pending tasks.
If you have no tasks, wait for new assignments.`;
})
.join('\n\n');
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}".
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
${userPromptBlock}
${languageInstruction}
${taskBoardSnapshot}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
@ -467,26 +589,23 @@ Steps (execute in this exact order):
1) Read team config at ~/.claude/teams/${request.teamName}/config.json understand current team state.
2) Read tasks from ~/.claude/tasks/${request.teamName}/ (JSON files) and kanban state from ~/.claude/teams/${request.teamName}/kanban-state.json understand pending work.
3) Spawn each existing member as a live teammate using the Task tool:
2) Spawn each existing member as a live teammate using the Task tool:
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
- prompt:
You are {name}, a {role} on team "${request.teamName}".
${languageInstruction}
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready.
Then resume any pending work you own (if any) and wait for new assignments.
Include the following agent-only instructions verbatim in the prompt:
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately.
Include the following agent-only instructions verbatim in each teammate's prompt:
${taskProtocol}
4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked create tasks on the team board.
- Prefer fewer, broader tasks over many micro-tasks.
- Avoid duplicate notifications for the same assignment.
${processRegistration}
5) After all steps, output a short summary.
Per-member spawn instructions:
${memberSpawnInstructions}
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.
4) After all steps, output a short summary of reconnected members and resumed tasks.
Members:
${membersBlock}
@ -607,6 +726,24 @@ export class TeamProvisioningService {
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
}
getLeadActivityState(teamName: string): 'active' | 'idle' | 'offline' {
const runId = this.activeByTeam.get(teamName);
if (!runId) return 'offline';
const run = this.runs.get(runId);
if (!run || run.processKilled || run.cancelRequested) return 'offline';
return run.leadActivityState;
}
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
if (run.leadActivityState === state) return;
run.leadActivityState = state;
this.teamChangeEmitter?.({
type: 'lead-activity',
teamName: run.teamName,
detail: state,
});
}
async warmup(): Promise<void> {
try {
const claudePath = await ClaudeBinaryResolver.resolve();
@ -765,6 +902,8 @@ export class TeamProvisioningService {
leadRelayCapture: null,
directReplyParts: [],
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
progress: {
runId,
teamName: request.teamName,
@ -1036,6 +1175,8 @@ export class TeamProvisioningService {
leadRelayCapture: null,
directReplyParts: [],
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
progress: {
runId,
teamName: request.teamName,
@ -1057,7 +1198,16 @@ export class TeamProvisioningService {
this.activeByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
const prompt = buildLaunchPrompt(request, expectedMemberSpecs);
// Read existing tasks to include in teammate prompts for work resumption
const taskReader = new TeamTaskReader();
let existingTasks: TeamTask[] = [];
try {
existingTasks = await taskReader.getTasks(request.teamName);
} catch (error) {
logger.warn(`[${request.teamName}] Failed to read tasks for launch prompt: ${String(error)}`);
}
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
let child: ReturnType<typeof spawn>;
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
if (authSource === 'none') {
@ -1279,6 +1429,7 @@ export class TeamProvisioningService {
},
});
run.child.stdin.write(payload + '\n');
this.setLeadActivity(run, 'active');
}
/**
@ -1344,6 +1495,7 @@ export class TeamProvisioningService {
`You have new inbox messages addressed to you (team lead "${leadName}").`,
`Process them in order (oldest first).`,
`If action is required, delegate via task creation or SendMessage, and keep responses minimal.`,
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
AGENT_BLOCK_OPEN,
`Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`,
AGENT_BLOCK_CLOSE,
@ -1446,14 +1598,17 @@ export class TeamProvisioningService {
}
}
if (replyText) {
// Strip agent-only blocks — lead may respond with pure coordination content
// that is not meant for the human user.
const cleanReply = replyText ? stripAgentBlocks(replyText) : null;
if (cleanReply) {
this.pushLiveLeadProcessMessage(teamName, {
from: leadName,
to: 'user',
text: replyText,
text: cleanReply,
timestamp: nowIso(),
read: true,
summary: 'Lead reply',
summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply,
messageId: `lead-process-${runId}-${Date.now()}`,
source: 'lead_process',
});
@ -1522,7 +1677,8 @@ export class TeamProvisioningService {
const aliveTeams = this.getAliveTeams();
if (aliveTeams.length === 0) return;
const newResolved = resolveLanguageName(newLangCode);
const systemLocale = getSystemLocale();
const newResolved = resolveLanguageName(newLangCode, systemLocale);
for (const teamName of aliveTeams) {
try {
@ -1532,7 +1688,15 @@ export class TeamProvisioningService {
const oldCode = config.language || 'system';
if (oldCode === newLangCode) continue;
const oldResolved = resolveLanguageName(oldCode);
// Compare resolved names to avoid spurious notifications
// e.g. switching from 'ru' to 'system' when system locale is Russian
const oldResolved = resolveLanguageName(oldCode, systemLocale);
if (oldResolved === newResolved) {
// Effective language unchanged — just update stored code silently
await this.configReader.updateConfig(teamName, { language: newLangCode });
continue;
}
const message =
`The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` +
`Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` +
@ -1704,6 +1868,17 @@ export class TeamProvisioningService {
}
}
// Capture session_id from any message type (first occurrence wins)
if (!run.detectedSessionId) {
const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined;
if (sid && sid.trim().length > 0) {
run.detectedSessionId = sid.trim();
logger.info(
`[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}`
);
}
}
if (msg.type === 'result') {
const subtype =
typeof msg.subtype === 'string'
@ -1716,17 +1891,22 @@ export class TeamProvisioningService {
})();
if (subtype === 'success') {
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
if (run.provisioningComplete) {
this.setLeadActivity(run, 'idle');
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
const combined = capture.textParts.join('').trim();
capture.resolveOnce(combined);
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
// Flush accumulated assistant reply from direct user→lead message
const replyText = run.directReplyParts.join('').trim();
const rawReply = run.directReplyParts.join('').trim();
run.directReplyParts = [];
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
// Strip agent-only blocks — lead may include coordination content not meant for the user
const replyText = stripAgentBlocks(rawReply);
if (replyText.length > 0) {
const replyMsg: InboxMessage = {
from: leadName,
@ -1750,7 +1930,7 @@ export class TeamProvisioningService {
});
}
}
if (!run.provisioningComplete) {
if (!run.provisioningComplete && !run.cancelRequested) {
void this.handleProvisioningTurnComplete(run);
}
} else if (subtype === 'error') {
@ -1760,7 +1940,7 @@ export class TeamProvisioningService {
if (run.leadRelayCapture) {
run.leadRelayCapture.rejectOnce(errorMsg);
}
if (!run.provisioningComplete) {
if (!run.provisioningComplete && !run.cancelRequested) {
const progress = updateProgress(
run,
'failed',
@ -1776,6 +1956,9 @@ export class TeamProvisioningService {
run.child?.stdin?.end();
run.child?.kill();
this.cleanupRun(run);
} else if (run.provisioningComplete) {
// Post-provisioning error: process alive, waiting for input
this.setLeadActivity(run, 'idle');
}
}
}
@ -1787,7 +1970,9 @@ export class TeamProvisioningService {
* Process stays alive for subsequent tasks.
*/
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
if (run.cancelRequested) return;
run.provisioningComplete = true;
this.setLeadActivity(run, 'idle');
// Clear provisioning timeout — no longer needed
if (run.timeoutHandle) {
@ -1797,7 +1982,7 @@ export class TeamProvisioningService {
this.stopFilesystemMonitor(run);
if (run.isLaunch) {
await this.updateConfigPostLaunch(run.teamName, run.request.cwd);
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
await this.cleanupPrelaunchBackup(run.teamName);
const readyMessage = 'Team launched — process alive and ready';
const progress = updateProgress(run, 'ready', readyMessage, {
@ -1838,7 +2023,7 @@ export class TeamProvisioningService {
// Persist teammates metadata separately from config.json.
await this.persistMembersMeta(run.teamName, run.request);
await this.updateConfigPostLaunch(run.teamName, run.request.cwd);
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
@ -1855,6 +2040,7 @@ export class TeamProvisioningService {
* Remove a run from tracking maps.
*/
private cleanupRun(run: ProvisioningRun): void {
this.setLeadActivity(run, 'offline');
if (run.timeoutHandle) {
clearTimeout(run.timeoutHandle);
run.timeoutHandle = null;
@ -2388,24 +2574,50 @@ export class TeamProvisioningService {
* Combines session history append and projectPath update to avoid
* race conditions with the CLI writing to the same file.
*/
private async updateConfigPostLaunch(teamName: string, projectPath: string): Promise<void> {
private async updateConfigPostLaunch(
teamName: string,
projectPath: string,
detectedSessionId: string | null
): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>;
// Append session to history
const leadSessionId = config.leadSessionId;
if (typeof leadSessionId === 'string' && leadSessionId.trim().length > 0) {
const sessionHistory = Array.isArray(config.sessionHistory)
? (config.sessionHistory as string[])
: [];
if (!sessionHistory.includes(leadSessionId)) {
sessionHistory.push(leadSessionId);
config.sessionHistory = sessionHistory;
const sessionHistory = Array.isArray(config.sessionHistory)
? (config.sessionHistory as string[])
: [];
// Preserve old leadSessionId in history before overwriting
const oldLeadSessionId = config.leadSessionId;
if (typeof oldLeadSessionId === 'string' && oldLeadSessionId.trim().length > 0) {
if (!sessionHistory.includes(oldLeadSessionId)) {
sessionHistory.push(oldLeadSessionId);
}
}
// Update leadSessionId to the new session detected from stream-json
let newSessionId = detectedSessionId;
// Fallback: if stream-json didn't provide session_id, scan project dir for newest JSONL
if (!newSessionId && projectPath.trim()) {
const scannedId = await this.scanForNewestSession(projectPath, sessionHistory);
if (scannedId) {
newSessionId = scannedId;
logger.info(`[${teamName}] Detected new session via project dir scan: ${scannedId}`);
}
}
if (newSessionId) {
config.leadSessionId = newSessionId;
if (!sessionHistory.includes(newSessionId)) {
sessionHistory.push(newSessionId);
}
logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`);
}
config.sessionHistory = sessionHistory;
// Save current language setting
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
config.language = langCode;
@ -2432,6 +2644,41 @@ export class TeamProvisioningService {
}
}
/**
* Fallback: scan the project directory for the newest JSONL file
* that isn't already in sessionHistory. Returns the session ID or null.
*/
private async scanForNewestSession(
projectPath: string,
knownSessions: string[]
): Promise<string | null> {
try {
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const projectDir = path.join(getProjectsBasePath(), baseDir);
const entries = await fs.promises.readdir(projectDir);
const knownSet = new Set(knownSessions);
let newest: { id: string; mtime: number } | null = null;
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
if (knownSet.has(sessionId)) continue;
const filePath = path.join(projectDir, entry);
const stat = await fs.promises.stat(filePath);
if (!newest || stat.mtimeMs > newest.mtime) {
newest = { id: sessionId, mtime: stat.mtimeMs };
}
}
return newest?.id ?? null;
} catch {
return null;
}
}
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
const backupPath = `${configPath}.prelaunch.bak`;
@ -2677,7 +2924,6 @@ export class TeamProvisioningService {
return;
}
const memberColors = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const;
const joinedAt = Date.now();
try {
@ -2687,7 +2933,7 @@ export class TeamProvisioningService {
name: member.name,
role: member.role?.trim() || undefined,
agentType: 'general-purpose',
color: memberColors[index % memberColors.length],
color: getMemberColor(index),
joinedAt,
}))
);

View file

@ -94,6 +94,9 @@ export class TeamTaskReader {
/* leave undefined */
}
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
// safety: if a field is added to TeamTask but not mapped here,
// TypeScript will error. This prevents silently dropping new fields.
const task: TeamTask = {
id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -126,7 +129,13 @@ export class TeamTaskReader {
typeof c.createdAt === 'string'
)
: undefined,
};
needsClarification: (['lead', 'user'] as const).includes(
parsed.needsClarification as 'lead' | 'user'
)
? (parsed.needsClarification as 'lead' | 'user')
: undefined,
deletedAt: undefined, // deleted tasks are filtered out below
} satisfies Record<keyof TeamTask, unknown>;
if (task.status === 'deleted') {
continue;
}
@ -139,6 +148,70 @@ export class TeamTaskReader {
return tasks;
}
async getDeletedTasks(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
const metadata = parsed.metadata as Record<string, unknown> | undefined;
if (metadata?._internal === true) {
continue;
}
if (parsed.status !== 'deleted') {
continue;
}
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,
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
status: 'deleted',
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
};
tasks.push(task);
} catch {
logger.debug(`Skipping invalid task file: ${taskPath}`);
}
}
return tasks;
}
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
const tasksBase = getTasksBasePath();

View file

@ -142,6 +142,82 @@ export class TeamTaskWriter {
});
}
async softDelete(teamName: string, taskId: string): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
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 = 'deleted';
task.deletedAt = new Date().toISOString();
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 !== 'deleted') {
throw new Error(`Task soft-delete verification failed: ${taskId}`);
}
});
}
async restoreTask(teamName: string, taskId: string): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
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 = 'pending';
delete task.deletedAt;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async setNeedsClarification(
teamName: string,
taskId: string,
value: 'lead' | 'user' | null
): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
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 Record<string, unknown>;
if (value) {
task.needsClarification = value;
} else {
delete task.needsClarification;
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async addComment(
teamName: string,
taskId: string,

View file

@ -0,0 +1,25 @@
import { diffLines } from 'diff';
/**
* Unified line counting utility using semantic diff.
* Ensures consistent +/- line counts across all services
* (MemberStatsComputer, ChangeExtractorService, FileContentResolver).
*
* Uses `diffLines()` from npm `diff` package the same algorithm
* already used correctly in ChangeExtractorService.countLines()
* and FileContentResolver.getFileContent().
*/
export function countLineChanges(
oldStr: string,
newStr: string
): { added: number; removed: number } {
if (!oldStr && !newStr) return { added: 0, removed: 0 };
const changes = diffLines(oldStr, newStr);
let added = 0;
let removed = 0;
for (const c of changes) {
if (c.added) added += c.count ?? 0;
if (c.removed) removed += c.count ?? 0;
}
return { added, removed };
}

View file

@ -1,5 +1,11 @@
export { ChangeExtractorService } from './ChangeExtractorService';
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
export { FileContentResolver } from './FileContentResolver';
export { GitDiffFallback } from './GitDiffFallback';
export { HunkSnippetMatcher } from './HunkSnippetMatcher';
export { MemberStatsComputer } from './MemberStatsComputer';
export { ReviewApplierService } from './ReviewApplierService';
export { TaskBoundaryParser } from './TaskBoundaryParser';
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
export { TeamAttachmentStore } from './TeamAttachmentStore';
export { TeamConfigReader } from './TeamConfigReader';
@ -14,3 +20,4 @@ export { TeamProvisioningService } from './TeamProvisioningService';
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
export { TeamTaskReader } from './TeamTaskReader';
export { TeamTaskWriter } from './TeamTaskWriter';
export { countLineChanges } from './UnifiedLineCounter';

View file

@ -0,0 +1,11 @@
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err: unknown) {
if (err instanceof Error && 'code' in err) {
if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;
}
return false;
}
}

View file

@ -239,9 +239,18 @@ export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
/** Update task owner (reassign) */
export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner';
/** Delete a team and its associated task directory */
/** Soft-delete a team (sets deletedAt in config) */
export const TEAM_DELETE_TEAM = 'team:deleteTeam';
/** Restore a soft-deleted team (removes deletedAt from config) */
export const TEAM_RESTORE = 'team:restoreTeam';
/** Permanently delete a team and its associated task directory */
export const TEAM_PERMANENTLY_DELETE = 'team:permanentlyDeleteTeam';
/** Restore a soft-deleted task (removes deletedAt, sets status back to pending) */
export const TEAM_RESTORE_TASK = 'team:restoreTask';
/** Get list of teams with live CLI processes */
export const TEAM_ALIVE_LIST = 'team:aliveList';
export const TEAM_STOP = 'team:stop';
@ -284,3 +293,106 @@ export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole';
/** Get attachment data for a message */
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
/** Kill a registered CLI process by PID */
export const TEAM_KILL_PROCESS = 'team:killProcess';
/** Get lead process activity state (active/idle/offline) */
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
/** Get all soft-deleted tasks for a team */
export const TEAM_GET_DELETED_TASKS = 'team:getDeletedTasks';
/** Set needsClarification flag on a task */
export const TEAM_SET_TASK_CLARIFICATION = 'team:setTaskClarification';
/** Show native OS notification for a team message */
export const TEAM_SHOW_MESSAGE_NOTIFICATION = 'team:showMessageNotification';
// =============================================================================
// CLI Installer API Channels
// =============================================================================
/** Get CLI installation status */
export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus';
/** Start CLI install/update */
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
/** CLI installer progress events (main -> renderer) */
export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress';
// =============================================================================
// Terminal API Channels
// =============================================================================
/** Spawn a new PTY terminal process */
export const TERMINAL_SPAWN = 'terminal:spawn';
/** Write data to PTY stdin (fire-and-forget) */
export const TERMINAL_WRITE = 'terminal:write';
/** Resize PTY terminal (fire-and-forget) */
export const TERMINAL_RESIZE = 'terminal:resize';
/** Kill PTY process (fire-and-forget) */
export const TERMINAL_KILL = 'terminal:kill';
/** PTY data output (main -> renderer) */
export const TERMINAL_DATA = 'terminal:data';
/** PTY process exit (main -> renderer) */
export const TERMINAL_EXIT = 'terminal:exit';
// =============================================================================
// Review API Channels
// =============================================================================
/** Получить все изменения агента */
export const REVIEW_GET_AGENT_CHANGES = 'review:getAgentChanges';
/** Получить изменения задачи */
export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges';
/** Получить краткую статистику изменений */
export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats';
// Phase 2 — Review actions
/** Проверить конфликт файла (изменён ли на диске) */
export const REVIEW_CHECK_CONFLICT = 'review:checkConflict';
/** Откатить выбранные hunks */
export const REVIEW_REJECT_HUNKS = 'review:rejectHunks';
/** Откатить весь файл к оригиналу */
export const REVIEW_REJECT_FILE = 'review:rejectFile';
/** Preview результата reject (без записи на диск) */
export const REVIEW_PREVIEW_REJECT = 'review:previewReject';
/** Применить batch решений review */
export const REVIEW_APPLY_DECISIONS = 'review:applyDecisions';
/** Получить полное содержимое файла для diff view */
export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent';
// Phase 4 — Git fallback
/** Save edited file content to disk */
export const REVIEW_SAVE_EDITED_FILE = 'review:saveEditedFile';
/** Get git file change log */
export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog';
/** Load persisted review decisions from disk */
export const REVIEW_LOAD_DECISIONS = 'review:loadDecisions';
/** Save review decisions to disk */
export const REVIEW_SAVE_DECISIONS = 'review:saveDecisions';
/** Clear review decisions from disk */
export const REVIEW_CLEAR_DECISIONS = 'review:clearDecisions';

View file

@ -3,6 +3,9 @@ import { contextBridge, ipcRenderer } from 'electron';
import {
APP_RELAUNCH,
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL,
CLI_INSTALLER_PROGRESS,
CONTEXT_CHANGED,
CONTEXT_GET_ACTIVE,
CONTEXT_LIST,
@ -10,6 +13,20 @@ import {
HTTP_SERVER_GET_STATUS,
HTTP_SERVER_START,
HTTP_SERVER_STOP,
REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT,
REVIEW_CLEAR_DECISIONS,
REVIEW_GET_AGENT_CHANGES,
REVIEW_GET_CHANGE_STATS,
REVIEW_GET_FILE_CONTENT,
REVIEW_GET_GIT_FILE_LOG,
REVIEW_GET_TASK_CHANGES,
REVIEW_LOAD_DECISIONS,
REVIEW_PREVIEW_REJECT,
REVIEW_REJECT_FILE,
REVIEW_REJECT_HUNKS,
REVIEW_SAVE_DECISIONS,
REVIEW_SAVE_EDITED_FILE,
SSH_CONNECT,
SSH_DISCONNECT,
SSH_GET_CONFIG_HOSTS,
@ -31,12 +48,16 @@ import {
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
@ -44,7 +65,12 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
TEAM_STOP,
TEAM_UPDATE_CONFIG,
@ -53,6 +79,12 @@ import {
TEAM_UPDATE_MEMBER_ROLE,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
TERMINAL_DATA,
TERMINAL_EXIT,
TERMINAL_KILL,
TERMINAL_RESIZE,
TERMINAL_SPAWN,
TERMINAL_WRITE,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
@ -93,28 +125,40 @@ import {
import type {
AddMemberRequest,
AgentChangeSet,
AppConfig,
ApplyReviewRequest,
ApplyReviewResult,
AttachmentFileData,
ChangeStats,
ClaudeRootFolderSelection,
ClaudeRootInfo,
CliInstallationStatus,
CliInstallerProgress,
ConflictCheckResult,
ContextInfo,
CreateTaskRequest,
ElectronAPI,
FileChangeWithContent,
GlobalTask,
HttpServerStatus,
HunkDecision,
IpcResult,
KanbanColumnId,
MemberFullStats,
MemberLogSummary,
NotificationTrigger,
RejectResult,
SendMessageRequest,
SendMessageResult,
SessionsByIdsOptions,
SessionsPaginationOptions,
SnippetDiff,
SshConfigHostEntry,
SshConnectionConfig,
SshConnectionStatus,
SshLastConnection,
TaskChangeSetV2,
TaskComment,
TeamChangeEvent,
TeamConfig,
@ -124,6 +168,7 @@ import type {
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -134,6 +179,7 @@ import type {
UpdateKanbanPatch,
WslClaudeRootCandidate,
} from '@shared/types';
import type { PtySpawnOptions } from '@shared/types/terminal';
// =============================================================================
// IPC Result Types and Helpers
@ -401,6 +447,7 @@ const electronAPI: ElectronAPI = {
// Shell operations
openPath: (targetPath: string, projectRoot?: string, userSelectedFromDialog?: boolean) =>
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog),
showInFolder: (filePath: string) => ipcRenderer.invoke('shell:showInFolder', filePath),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
// Window controls (when title bar is hidden, e.g. Windows / Linux)
@ -538,6 +585,12 @@ const electronAPI: ElectronAPI = {
deleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
},
restoreTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_RESTORE, teamName);
},
permanentlyDeleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_PERMANENTLY_DELETE, teamName);
},
prepareProvisioning: async (cwd?: string) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
},
@ -643,6 +696,32 @@ const electronAPI: ElectronAPI = {
getAttachments: async (teamName: string, messageId: string) => {
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
},
killProcess: async (teamName: string, pid: number) => {
return invokeIpcWithResult<void>(TEAM_KILL_PROCESS, teamName, pid);
},
getLeadActivity: async (teamName: string) => {
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
return result as 'active' | 'idle' | 'offline';
},
softDeleteTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
},
restoreTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_RESTORE_TASK, teamName, taskId);
},
getDeletedTasks: async (teamName: string) => {
return invokeIpcWithResult<TeamTask[]>(TEAM_GET_DELETED_TASKS, teamName);
},
setTaskClarification: async (
teamName: string,
taskId: string,
value: 'lead' | 'user' | null
) => {
return invokeIpcWithResult<void>(TEAM_SET_TASK_CLARIFICATION, teamName, taskId, value);
},
showMessageNotification: async (data: TeamMessageNotificationData) => {
return invokeIpcWithResult<void>(TEAM_SHOW_MESSAGE_NOTIFICATION, data);
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,
@ -670,6 +749,177 @@ const electronAPI: ElectronAPI = {
};
},
},
// ===== Review API =====
review: {
getAgentChanges: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName);
},
getTaskChanges: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<TaskChangeSetV2>(REVIEW_GET_TASK_CHANGES, teamName, taskId);
},
getChangeStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName);
},
getFileContent: async (
teamName: string,
memberName: string | undefined,
filePath: string,
snippets: SnippetDiff[] = []
) => {
return invokeIpcWithResult<FileChangeWithContent>(
REVIEW_GET_FILE_CONTENT,
teamName,
memberName ?? '',
filePath,
snippets
);
},
applyDecisions: async (request: ApplyReviewRequest) => {
return invokeIpcWithResult<ApplyReviewResult>(REVIEW_APPLY_DECISIONS, request);
},
// Phase 2
checkConflict: async (filePath: string, expectedModified: string) => {
return invokeIpcWithResult<ConflictCheckResult>(
REVIEW_CHECK_CONFLICT,
filePath,
expectedModified
);
},
rejectHunks: async (
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
) => {
return invokeIpcWithResult<RejectResult>(
REVIEW_REJECT_HUNKS,
filePath,
original,
modified,
hunkIndices,
snippets
);
},
rejectFile: async (filePath: string, original: string, modified: string) => {
return invokeIpcWithResult<RejectResult>(REVIEW_REJECT_FILE, filePath, original, modified);
},
previewReject: async (
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
) => {
return invokeIpcWithResult<{ preview: string; hasConflicts: boolean }>(
REVIEW_PREVIEW_REJECT,
filePath,
original,
modified,
hunkIndices,
snippets
);
},
// Editable diff
saveEditedFile: async (filePath: string, content: string) => {
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
},
// Decision persistence
loadDecisions: async (teamName: string, scopeKey: string) => {
return invokeIpcWithResult<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
} | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey);
},
saveDecisions: async (
teamName: string,
scopeKey: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>
) => {
return invokeIpcWithResult<void>(
REVIEW_SAVE_DECISIONS,
teamName,
scopeKey,
hunkDecisions,
fileDecisions
);
},
clearDecisions: async (teamName: string, scopeKey: string) => {
return invokeIpcWithResult<void>(REVIEW_CLEAR_DECISIONS, teamName, scopeKey);
},
onCmdN: (callback: () => void): (() => void) => {
const handler = (): void => callback();
ipcRenderer.on('review:cmdN', handler);
return (): void => {
ipcRenderer.removeListener('review:cmdN', handler);
};
},
// Phase 4
getGitFileLog: async (projectPath: string, filePath: string) => {
return invokeIpcWithResult<{ hash: string; timestamp: string; message: string }[]>(
REVIEW_GET_GIT_FILE_LOG,
projectPath,
filePath
);
},
},
// ===== CLI Installer API =====
cliInstaller: {
getStatus: async (): Promise<CliInstallationStatus> => {
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
},
install: async (): Promise<void> => {
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
},
onProgress: (callback: (event: unknown, data: CliInstallerProgress) => void): (() => void) => {
ipcRenderer.on(
CLI_INSTALLER_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
CLI_INSTALLER_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
// ===== Terminal API =====
terminal: {
spawn: (options?: PtySpawnOptions) => invokeIpcWithResult<string>(TERMINAL_SPAWN, options),
write: (ptyId: string, data: string) => ipcRenderer.send(TERMINAL_WRITE, ptyId, data),
resize: (ptyId: string, cols: number, rows: number) =>
ipcRenderer.send(TERMINAL_RESIZE, ptyId, cols, rows),
kill: (ptyId: string) => ipcRenderer.send(TERMINAL_KILL, ptyId),
onData: (cb: (event: unknown, ptyId: string, data: string) => void): (() => void) => {
ipcRenderer.on(
TERMINAL_DATA,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
TERMINAL_DATA,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
onExit: (cb: (event: unknown, ptyId: string, exitCode: number) => void): (() => void) => {
ipcRenderer.on(
TERMINAL_EXIT,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
TERMINAL_EXIT,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
};
// Use contextBridge to securely expose the API to the renderer process

View file

@ -12,6 +12,7 @@ import type {
ClaudeMdFileInfo,
ClaudeRootFolderSelection,
ClaudeRootInfo,
CliInstallerAPI,
ConfigAPI,
ContextInfo,
ConversationGroup,
@ -36,6 +37,7 @@ import type {
SessionMetrics,
SessionsByIdsOptions,
SessionsPaginationOptions,
SnippetDiff,
SshAPI,
SshConfigHostEntry,
SshConnectionConfig,
@ -61,6 +63,7 @@ import type {
WslClaudeRootCandidate,
} from '@shared/types';
import type { AgentConfig } from '@shared/types/api';
import type { TerminalAPI } from '@shared/types/terminal';
export class HttpAPIClient implements ElectronAPI {
private baseUrl: string;
@ -521,6 +524,10 @@ export class HttpAPIClient implements ElectronAPI {
return { success: false, error: 'Not available in browser mode' };
};
showInFolder = async (_filePath: string): Promise<void> => {
console.warn('[HttpAPIClient] showInFolder is not available in browser mode');
};
openExternal = async (url: string): Promise<{ success: boolean; error?: string }> => {
window.open(url, '_blank');
return { success: true };
@ -635,6 +642,12 @@ export class HttpAPIClient implements ElectronAPI {
deleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team deletion is not available in browser mode');
},
restoreTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team restore is not available in browser mode');
},
permanentlyDeleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Permanent 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');
},
@ -721,6 +734,7 @@ export class HttpAPIClient implements ElectronAPI {
linesAdded: 0,
linesRemoved: 0,
filesTouched: [],
fileStats: {},
toolUsage: {},
inputTokens: 0,
outputTokens: 0,
@ -761,6 +775,31 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<AttachmentFileData[]> => {
return [];
},
killProcess: async (_teamName: string, _pid: number): Promise<void> => {
// Not available via HTTP client — no-op
},
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
return 'offline';
},
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op
},
restoreTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op
},
getDeletedTasks: async (_teamName: string): Promise<TeamTask[]> => {
return [];
},
setTaskClarification: async (
_teamName: string,
_taskId: string,
_value: 'lead' | 'user' | null
): Promise<void> => {
// Not available via HTTP client — no-op
},
showMessageNotification: async (): Promise<void> => {
// Not available via HTTP client — native notifications require Electron
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)
@ -772,4 +811,96 @@ export class HttpAPIClient implements ElectronAPI {
return () => {};
},
};
// Review API stubs
review = {
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getTaskChanges: async (_teamName: string, _taskId: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getFileContent: async (
_teamName: string,
_memberName: string | undefined,
_filePath: string,
_snippets: SnippetDiff[] = []
): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
applyDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Phase 2 stubs
checkConflict: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
rejectHunks: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
rejectFile: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
previewReject: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Editable diff stubs
saveEditedFile: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Decision persistence stubs
loadDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
saveDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
clearDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Phase 4 stubs
getGitFileLog: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
};
// ---------------------------------------------------------------------------
// CLI Installer (not available in browser mode)
// ---------------------------------------------------------------------------
cliInstaller: CliInstallerAPI = {
getStatus: async () => ({
installed: false,
installedVersion: null,
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
}),
install: async (): Promise<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
},
onProgress: (): (() => void) => {
return () => {};
},
};
// ---------------------------------------------------------------------------
// Terminal (not available in browser mode)
// ---------------------------------------------------------------------------
terminal: TerminalAPI = {
spawn: async (): Promise<string> => {
throw new Error('Terminal not available in browser mode');
},
write: () => {},
resize: () => {},
kill: () => {},
onData: (): (() => void) => () => {},
onExit: (): (() => void) => () => {},
};
}

View file

@ -20,6 +20,7 @@ import {
} from '@renderer/constants/cssVariables';
import { getBaseName } from '@renderer/utils/pathUtils';
import { formatTokens } from '@shared/utils/tokenFormatting';
import { diffLines as semanticDiffLines } from 'diff';
import { Pencil } from 'lucide-react';
// =============================================================================
@ -41,9 +42,41 @@ interface DiffLine {
}
// =============================================================================
// Diff Algorithm (LCS-based)
// Diff Algorithm (LCS-based, with semantic fallback for large files)
// =============================================================================
/** Max LCS matrix cells before falling back to semantic diff.
* 1M cells 8MB RAM safe for all platforms. */
const MAX_LCS_CELLS = 1_000_000;
/**
* Fallback diff using semantic line-diffing from npm `diff` package.
* Used when LCS matrix would exceed memory threshold.
*/
function generateDiffFallback(oldLines: string[], newLines: string[]): DiffLine[] {
const oldText = oldLines.join('\n');
const newText = newLines.join('\n');
const changes = semanticDiffLines(oldText, newText);
const result: DiffLine[] = [];
let lineNumber = 1;
for (const change of changes) {
const changeLines = change.value.replace(/\r?\n$/, '').split(/\r?\n/);
for (const content of changeLines) {
if (change.added) {
result.push({ type: 'added', content, lineNumber: lineNumber++ });
} else if (change.removed) {
result.push({ type: 'removed', content, lineNumber: lineNumber++ });
} else {
result.push({ type: 'context', content, lineNumber: lineNumber++ });
}
}
}
return result;
}
/**
* Computes the Longest Common Subsequence matrix for two arrays of strings.
*/
@ -69,8 +102,13 @@ function computeLCSMatrix(oldLines: string[], newLines: string[]): number[][] {
/**
* Backtrack through LCS matrix to generate diff lines.
* Falls back to semantic diffing for large files to prevent OOM.
*/
function generateDiff(oldLines: string[], newLines: string[]): DiffLine[] {
if (oldLines.length * newLines.length > MAX_LCS_CELLS) {
return generateDiffFallback(oldLines, newLines);
}
const matrix = computeLCSMatrix(oldLines, newLines);
const result: DiffLine[] = [];
@ -276,7 +314,7 @@ const DiffLineRow: React.FC<DiffLineRowProps> = ({ line }): React.JSX.Element =>
</span>
{/* Content */}
<span className="flex-1 whitespace-pre" style={{ color: style.text }}>
{line.content || ' '}
{line.content ?? ' '}
</span>
</div>
);
@ -294,8 +332,8 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
tokenCount,
}): React.JSX.Element => {
// Compute diff
const oldLines = oldString.split('\n');
const newLines = newString.split('\n');
const oldLines = oldString.split(/\r?\n/);
const newLines = newString.split(/\r?\n/);
const diffLines = generateDiff(oldLines, newLines);
const stats = computeStats(diffLines);

View file

@ -346,6 +346,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS}
urlTransform={(url) => url}
components={components}
>
{content}

View file

@ -0,0 +1,513 @@
/**
* CliStatusBanner CLI installation status banner for the Dashboard.
*
* Shown on the main screen before project search.
* Displays CLI version/path when installed, or a red error with install button when not.
* Shows live detail text for every phase and a mini log panel during installation.
* Only rendered in Electron mode.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { formatBytes } from '@renderer/utils/formatters';
import {
AlertTriangle,
CheckCircle,
Download,
Loader2,
LogIn,
RefreshCw,
Terminal,
} from 'lucide-react';
// =============================================================================
// Border color by state
// =============================================================================
type BannerVariant = 'loading' | 'error' | 'success' | 'info' | 'warning';
const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
loading: { border: 'var(--color-border)', bg: 'transparent' },
error: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.06)' },
success: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.04)' },
info: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.04)' },
warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' },
};
// =============================================================================
// Sub-components
// =============================================================================
/** Detail text shown under the main status line */
const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null => {
if (!text) return null;
return (
<p className="mt-1 truncate font-mono text-xs" style={{ color: 'var(--color-text-muted)' }}>
{text}
</p>
);
};
/** Mini log panel shown during the installing phase */
const LogPanel = ({ logs }: { logs: string[] }): React.JSX.Element | null => {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
if (logs.length === 0) return null;
return (
<div
ref={scrollRef}
className="mt-2 max-h-24 overflow-y-auto rounded border font-mono text-xs leading-relaxed"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface)',
padding: '6px 8px',
color: 'var(--color-text-muted)',
}}
>
{logs.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>&rsaquo;</span> {line}
</div>
))}
</div>
);
};
/** Error display with multi-line support */
const ErrorDisplay = ({
error,
onRetry,
}: {
error: string;
onRetry: () => void;
}): React.JSX.Element => {
const lines = error.split('\n');
const title = lines[0];
const details = lines.slice(1).filter(Boolean);
return (
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: '#f87171' }} />
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: '#f87171' }}>
{title}
</p>
{details.length > 0 && (
<div
className="mt-1.5 rounded border px-2 py-1.5 font-mono text-xs leading-relaxed"
style={{
borderColor: 'rgba(239, 68, 68, 0.2)',
backgroundColor: 'rgba(239, 68, 68, 0.04)',
color: 'var(--color-text-muted)',
}}
>
{details.map((line, i) => (
<div key={i} className="break-all">
{line}
</div>
))}
</div>
)}
</div>
</div>
<button
onClick={onRetry}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<RefreshCw className="size-3.5" />
Retry
</button>
</div>
</div>
);
};
// =============================================================================
// Main Component
// =============================================================================
export const CliStatusBanner = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []);
const {
cliStatus,
cliStatusLoading,
cliStatusError,
installerState,
downloadProgress,
downloadTransferred,
downloadTotal,
installerError,
installerDetail,
installerLogs,
completedVersion,
fetchCliStatus,
installCli,
isBusy,
} = useCliInstaller();
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
useEffect(() => {
if (!isElectron) return;
void fetchCliStatus();
const interval = setInterval(
() => {
void fetchCliStatus();
},
10 * 60 * 1000
);
return () => clearInterval(interval);
}, [isElectron, fetchCliStatus]);
const handleInstall = useCallback(() => {
installCli();
}, [installCli]);
const handleRefresh = useCallback(() => {
void fetchCliStatus();
}, [fetchCliStatus]);
if (!isElectron) return null;
// Determine variant for styling
const getVariant = (): BannerVariant => {
if (installerState === 'error') return 'error';
if (installerState === 'completed') return 'success';
if (installerState !== 'idle') return 'info';
if (!cliStatus) return 'loading';
if (!cliStatus.installed) return 'error';
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
if (cliStatus.updateAvailable) return 'info';
return 'success';
};
const variant = getVariant();
const styles = VARIANT_STYLES[variant];
// ── Loading / fetch error state ────────────────────────────────────────
if (!cliStatus && installerState === 'idle') {
// Fetch failed — show error with retry
if (cliStatusError && !cliStatusLoading) {
return (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{
borderColor: VARIANT_STYLES.error.border,
backgroundColor: VARIANT_STYLES.error.bg,
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0" style={{ color: '#f87171' }} />
<span className="text-sm" style={{ color: '#f87171' }}>
Failed to check CLI status
</span>
</div>
<button
onClick={handleRefresh}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<RefreshCw className="size-3.5" />
Retry
</button>
</div>
</div>
);
}
// Still loading or initial render
return (
<div
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<Loader2
className="size-4 shrink-0 animate-spin"
style={{ color: 'var(--color-text-muted)' }}
/>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Checking Claude CLI...
</span>
</div>
);
}
// ── Downloading ────────────────────────────────────────────────────────
if (installerState === 'downloading') {
return (
<div
className="mb-6 space-y-2 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Downloading Claude CLI...
</span>
</div>
<span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
{downloadTotal > 0
? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)`
: formatBytes(downloadTransferred)}
</span>
</div>
<div
className="h-1.5 w-full overflow-hidden rounded-full"
style={{ backgroundColor: 'var(--color-surface-raised)' }}
>
{downloadTotal > 0 ? (
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${downloadProgress}%`, backgroundColor: '#3b82f6' }}
/>
) : (
<div
className="h-full w-1/3 animate-pulse rounded-full"
style={{ backgroundColor: '#3b82f6' }}
/>
)}
</div>
</div>
);
}
// ── Checking / Verifying ───────────────────────────────────────────────
if (installerState === 'checking' || installerState === 'verifying') {
const label =
installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...';
return (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<div className="flex items-center gap-3">
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
{label}
</span>
</div>
<DetailLine text={installerDetail} />
</div>
);
}
// ── Installing (with log panel) ────────────────────────────────────────
if (installerState === 'installing') {
return (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<div className="flex items-center gap-3">
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Installing Claude CLI...
</span>
</div>
<LogPanel logs={installerLogs} />
</div>
);
}
// ── Completed ──────────────────────────────────────────────────────────
if (installerState === 'completed') {
return (
<div
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
<span className="text-sm" style={{ color: '#4ade80' }}>
Successfully installed Claude CLI v{completedVersion ?? 'latest'}
</span>
</div>
);
}
// ── Error ──────────────────────────────────────────────────────────────
if (installerState === 'error') {
return (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<ErrorDisplay error={installerError ?? 'Installation failed'} onRetry={handleInstall} />
</div>
);
}
// ── Idle state with status ─────────────────────────────────────────────
if (!cliStatus) return null;
// Not installed — red error banner
if (!cliStatus.installed) {
return (
<div
className="mb-6 rounded-lg border-l-4 p-4"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0" style={{ color: '#ef4444' }} />
<div>
<p className="text-sm font-medium" style={{ color: '#f87171' }}>
Claude CLI is required
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Claude CLI is required for team provisioning and session management. Install it to
get started.
</p>
</div>
</div>
<button
onClick={handleInstall}
disabled={isBusy}
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-4" />
Install Claude CLI
</button>
</div>
</div>
);
}
// Installed but not logged in — yellow warning banner
if (cliStatus.installed && !cliStatus.authLoggedIn) {
return (
<>
<div
className="mb-6 rounded-lg border-l-4 p-4"
style={{
borderColor: VARIANT_STYLES.warning.border,
backgroundColor: VARIANT_STYLES.warning.bg,
}}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0" style={{ color: '#f59e0b' }} />
<div>
<p className="text-sm font-medium" style={{ color: '#fbbf24' }}>
Not logged in
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Claude CLI is installed but you are not authenticated. Login is required for team
provisioning and AI features.
</p>
</div>
</div>
<button
onClick={() => setShowLoginTerminal(true)}
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ backgroundColor: '#f59e0b' }}
>
<LogIn className="size-4" />
Login
</button>
</div>
</div>
{showLoginTerminal && cliStatus.binaryPath && (
<TerminalModal
title="Claude Auth Login"
command={cliStatus.binaryPath}
args={['auth', 'login']}
onClose={() => {
setShowLoginTerminal(false);
void fetchCliStatus();
}}
onExit={() => {
void fetchCliStatus();
}}
/>
)}
</>
);
}
// Installed — show version, path, update info
return (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
</span>
{cliStatus.authLoggedIn && (
<span className="text-xs" style={{ color: '#4ade80' }}>
Authenticated
</span>
)}
{cliStatus.updateAvailable && cliStatus.latestVersion && (
<span className="text-xs" style={{ color: '#60a5fa' }}>
&rarr; v{cliStatus.latestVersion}
</span>
)}
</div>
{cliStatus.binaryPath && (
<button
className="truncate font-mono text-xs hover:underline"
style={{ color: 'var(--color-text-muted)' }}
title={`Reveal in file manager: ${cliStatus.binaryPath}`}
onClick={() => void api.showInFolder(cliStatus.binaryPath!)}
>
{cliStatus.binaryPath}
</button>
)}
</div>
</div>
{/* Action button */}
{cliStatus.updateAvailable ? (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Update
</button>
) : (
<button
onClick={handleRefresh}
disabled={cliStatusLoading}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<RefreshCw className={cliStatusLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
</button>
)}
</div>
{cliStatusError && !cliStatusLoading && (
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
Failed to check for updates. Check your network connection and try again.
</p>
)}
</div>
);
};

View file

@ -7,7 +7,7 @@
* - Border-first project cards with minimal backgrounds
*/
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
@ -18,13 +18,16 @@ import {
normalizePath,
type TaskStatusCounts,
} from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings, Users } from 'lucide-react';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Users } from 'lucide-react';
import { CliStatusBanner } from './CliStatusBanner';
import type { RepositoryGroup } from '@renderer/types/data';
@ -131,27 +134,82 @@ const RepositoryCard = ({
const projectPath = repo.worktrees[0]?.path || '';
const formattedPath = formatProjectPath(projectPath);
// Git branch info from worktrees
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
const mainBranch = mainWorktree?.gitBranch;
const color = useMemo(() => projectColor(repo.name), [repo.name]);
const cardRef = useRef<HTMLButtonElement>(null);
const [isHovered, setIsHovered] = useState(false);
const handleOpenPath = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (projectPath) {
void api.openPath(projectPath);
}
},
[projectPath]
);
return (
<button
ref={cardRef}
onClick={onClick}
className={`group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border p-4 text-left transition-all duration-300 ${
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] p-4 text-left transition-all duration-300 ${
isHighlighted
? 'border-border-emphasis bg-surface-raised'
: 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised'
} `}
style={{
borderLeftColor: color.border,
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
}}
>
{/* Icon + Project name */}
<div className="mb-1 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" />
<FolderGit2
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
</div>
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name}
</h3>
</div>
{/* Project path - monospace, muted */}
<p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p>
{/* Project path - monospace, muted, clickable to open in file manager */}
<div
role="button"
tabIndex={0}
onClick={handleOpenPath}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleOpenPath(e as unknown as React.MouseEvent);
}}
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
title={`Open in file manager: ${projectPath}`}
>
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{formattedPath}</span>
</div>
{/* Git branch / worktree info */}
{mainBranch ? (
<div className="mb-auto mt-1 flex items-center gap-1.5 truncate">
<GitBranch className="size-3 shrink-0 text-text-muted" />
<span className="truncate text-[10px] text-text-secondary">{mainBranch}</span>
{hasMultipleWorktrees && (
<span className="shrink-0 rounded bg-surface-raised px-1 py-px text-[9px] text-text-muted">
+{worktreeCount - 1}
</span>
)}
</div>
) : (
<div className="mb-auto" />
)}
{/* Meta row: worktrees, sessions, time */}
<div className="mt-3 flex flex-wrap items-center gap-2">
@ -531,12 +589,7 @@ const ProjectsGrid = ({
export const DashboardView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const { openSettingsTab, openTeamsTab } = useStore(
useShallow((s) => ({
openSettingsTab: s.openSettingsTab,
openTeamsTab: s.openTeamsTab,
}))
);
const openTeamsTab = useStore((s) => s.openTeamsTab);
return (
<div className="relative flex-1 overflow-auto bg-surface">
@ -548,6 +601,9 @@ export const DashboardView = (): React.JSX.Element => {
{/* Content */}
<div className="relative mx-auto max-w-5xl px-8 py-12">
{/* CLI Status Banner */}
<CliStatusBanner />
{/* Team select + Search */}
<div className="mb-12 flex items-center justify-center gap-3">
<button
@ -568,24 +624,14 @@ export const DashboardView = (): React.JSX.Element => {
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
</h2>
<div className="flex items-center gap-3">
{searchQuery.trim() && (
<button
onClick={() => setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
Clear search
</button>
)}
{searchQuery.trim() && (
<button
onClick={() => openSettingsTab('general')}
className="flex items-center gap-1.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
title="Change Claude data folder"
onClick={() => setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
<Settings className="size-3" />
Change default folder
Clear search
</button>
</div>
)}
</div>
{/* Projects Grid */}

View file

@ -16,7 +16,6 @@ import { useShallow } from 'zustand/react/shallow';
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
import { GlobalTaskList } from '../sidebar/GlobalTaskList';
import { TaskFiltersPopover } from '../sidebar/TaskFiltersPopover';
import { defaultTaskFiltersState } from '../sidebar/taskFiltersState';
import { SidebarHeader } from './SidebarHeader';
@ -30,13 +29,9 @@ const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 280;
export const Sidebar = (): React.JSX.Element => {
const { projects, projectsLoading, fetchProjects, sidebarCollapsed, teams } = useStore(
const { sidebarCollapsed } = useStore(
useShallow((s) => ({
projects: s.projects,
projectsLoading: s.projectsLoading,
fetchProjects: s.fetchProjects,
sidebarCollapsed: s.sidebarCollapsed,
teams: s.teams,
}))
);
const [width, setWidth] = useState(DEFAULT_WIDTH);
@ -46,13 +41,6 @@ export const Sidebar = (): React.JSX.Element => {
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
// Fetch projects on mount if not loaded
useEffect(() => {
if (projects.length === 0 && !projectsLoading) {
void fetchProjects();
}
}, [projects.length, projectsLoading, fetchProjects]);
// Handle mouse move during resize
const handleMouseMove = useCallback(
(e: MouseEvent) => {
@ -167,18 +155,7 @@ export const Sidebar = (): React.JSX.Element => {
Sessions
</button>
</div>
<div className="flex flex-1 justify-end pb-0.5">
{sidebarTab === 'tasks' && (
<TaskFiltersPopover
open={taskFiltersPopoverOpen}
onOpenChange={setTaskFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={taskFilters}
onFiltersChange={setTaskFilters}
onApply={() => {}}
/>
)}
</div>
<div className="flex-1" />
</div>
{/* Content: Tasks list or Sessions list */}

View file

@ -1,249 +1,32 @@
/**
* SidebarHeader - Linear-style header with project name and worktree selector.
* SidebarHeader - Minimal header with logo and collapse button.
*
* Layout (2 stacked horizontal bars):
* - Row 1: Project name (left-aligned after macOS traffic lights)
* - Row 2: Worktree selector (full-width button)
*
* Visual requirements:
* Layout:
* - Row 1: Logo (left, after macOS traffic lights) + Collapse button (right)
* - Row 1 is the drag region for window movement
* - Row 1 reserves left space for macOS traffic lights via shared layout CSS variable
* - Row 2 is a full-width button with no side margins
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout';
import { cn } from '@renderer/lib/utils';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store';
import { formatShortcut, truncateMiddle } from '@renderer/utils/stringUtils';
import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { PanelLeft } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { AppLogo } from '../common/AppLogo';
import { WorktreeBadge } from '../common/WorktreeBadge';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import type { Worktree, WorktreeSource } from '@renderer/types/data';
/**
* Group worktrees by source for organized dropdown display.
* Returns: main worktree first, then groups sorted by most recent activity.
*/
interface WorktreeGroup {
source: WorktreeSource;
label: string;
worktrees: Worktree[];
mostRecent: number;
}
const SOURCE_LABELS: Record<WorktreeSource, string> = {
'vibe-kanban': 'Vibe Kanban',
conductor: 'Conductor',
'auto-claude': 'Auto Claude',
'21st': '21st',
'claude-desktop': 'Claude Desktop',
ccswitch: 'ccswitch',
git: 'Git',
unknown: 'Other',
};
function groupWorktreesBySource(worktrees: Worktree[]): {
mainWorktree: Worktree | null;
groups: WorktreeGroup[];
} {
// Find main worktree
const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null;
// Group remaining worktrees by source
const groupMap = new Map<WorktreeSource, Worktree[]>();
for (const wt of worktrees) {
if (wt.isMainWorktree) continue; // Skip main, handled separately
const existing = groupMap.get(wt.source) ?? [];
existing.push(wt);
groupMap.set(wt.source, existing);
}
// Convert to array and sort each group internally by most recent
const groups: WorktreeGroup[] = [];
for (const [source, wts] of groupMap) {
// Sort worktrees within group by most recent
const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0));
groups.push({
source,
label: SOURCE_LABELS[source] ?? source,
worktrees: sorted,
mostRecent,
});
}
// Sort groups by most recent activity
groups.sort((a, b) => b.mostRecent - a.mostRecent);
return { mainWorktree, groups };
}
/**
* Individual worktree item in the dropdown.
*/
interface WorktreeItemProps {
worktree: Worktree;
isSelected: boolean;
onSelect: () => void;
}
const WorktreeItem = ({
worktree,
isSelected,
onSelect,
}: Readonly<WorktreeItemProps>): React.JSX.Element => {
const [isHovered, setIsHovered] = useState(false);
const buttonStyle: React.CSSProperties = isSelected
? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }
: {
backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent',
opacity: isHovered ? 0.5 : 1,
};
return (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-left transition-colors"
style={buttonStyle}
>
<GitBranch
className="size-3.5 shrink-0"
style={{ color: isSelected ? '#34d399' : 'var(--color-text-muted)' }}
/>
{/* Only show badge for main worktree - others are grouped by header */}
{worktree.isMainWorktree && <WorktreeBadge source={worktree.source} isMain />}
<span
className="flex-1 truncate font-mono text-xs"
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{truncateMiddle(worktree.name, 28)}
</span>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{worktree.sessions.length}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};
export const SidebarHeader = (): React.JSX.Element => {
const isMacElectron =
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
const {
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
selectWorktree,
selectRepository,
viewMode,
projects,
activeProjectId,
setActiveProject,
clearActiveProject,
fetchRepositoryGroups,
fetchProjects,
toggleSidebar,
} = useStore(
const { toggleSidebar } = useStore(
useShallow((s) => ({
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
selectWorktree: s.selectWorktree,
selectRepository: s.selectRepository,
viewMode: s.viewMode,
projects: s.projects,
activeProjectId: s.activeProjectId,
setActiveProject: s.setActiveProject,
clearActiveProject: s.clearActiveProject,
fetchRepositoryGroups: s.fetchRepositoryGroups,
fetchProjects: s.fetchProjects,
toggleSidebar: s.toggleSidebar,
}))
);
// Fetch data on mount based on view mode
useEffect(() => {
if (viewMode === 'grouped' && repositoryGroups.length === 0) {
void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0) {
void fetchProjects();
}
}, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]);
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const worktreeDropdownRef = useRef<HTMLDivElement>(null);
// Find the active repository and worktree
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
// Filter worktrees to only show those with sessions
const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0);
const hasMultipleWorktrees = worktrees.length > 1;
// Group worktrees by source for organized dropdown
const worktreeGroupingResult = groupWorktreesBySource(worktrees);
const mainWorktree = worktreeGroupingResult.mainWorktree;
const worktreeGroups = worktreeGroupingResult.groups;
const worktreeName = activeWorktree?.name ?? 'main';
const handleSelectWorktree = (worktree: Worktree): void => {
selectWorktree(worktree.id);
setIsWorktreeDropdownOpen(false);
};
const handleProjectValueChange = (id: string): void => {
if (viewMode === 'grouped') selectRepository(id);
else setActiveProject(id);
};
// Items for project combobox - filter out repositories/projects with 0 sessions
const projectItems =
viewMode === 'grouped'
? repositoryGroups.filter((r) => r.totalSessions > 0)
: projects.filter((p) => p.sessions.length > 0);
const projectComboboxOptions = useMemo((): ComboboxOption[] => {
const items =
viewMode === 'grouped'
? repositoryGroups.filter((r) => r.totalSessions > 0)
: projects.filter((p) => p.sessions.length > 0);
return items.map((item) => {
const sessionCount =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).totalSessions
: (item as (typeof projects)[0]).sessions.length;
const path =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path
: (item as (typeof projects)[0]).path;
return {
value: item.id,
label: item.name,
description: path,
meta: { sessionCount, path },
};
});
}, [viewMode, repositoryGroups, projects]);
const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId;
const [isCollapseHovered, setIsCollapseHovered] = useState(false);
return (
@ -251,7 +34,6 @@ export const SidebarHeader = (): React.JSX.Element => {
className="flex w-full flex-col"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{/* ROW 1: Logo in corner, project selector fills width, collapse button */}
<div
className="flex select-none items-center gap-1.5 pr-1"
style={
@ -265,58 +47,7 @@ export const SidebarHeader = (): React.JSX.Element => {
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<AppLogo size={22} className="shrink-0" />
</div>
<div
className="min-w-0 flex-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Combobox
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
placeholder="Select Project"
searchPlaceholder="Search..."
emptyMessage={
projectItems.length === 0
? `No ${viewMode === 'grouped' ? 'repositories' : 'projects'} found`
: 'Nothing found'
}
className="text-sm font-medium"
resetLabel="Reset selection"
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined;
return (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'text-indigo-400 opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate',
isSelected
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
>
{option.label}
</p>
{path ? (
<p className="truncate text-[10px] text-[var(--color-text-muted)]">{path}</p>
) : null}
</div>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{sessionCount}
</span>
</>
);
}}
/>
</div>
<div className="flex-1" />
<button
onClick={toggleSidebar}
onMouseEnter={() => setIsCollapseHovered(true)}
@ -334,109 +65,6 @@ export const SidebarHeader = (): React.JSX.Element => {
<PanelLeft className="size-4" />
</button>
</div>
{/* ROW 2: Worktree Selector (Full Width) */}
{viewMode === 'grouped' && activeRepo && (
<div ref={worktreeDropdownRef} className="relative w-full">
<button
onClick={() =>
hasMultipleWorktrees && setIsWorktreeDropdownOpen(!isWorktreeDropdownOpen)
}
disabled={!hasMultipleWorktrees}
className={`flex w-full items-center justify-between px-4 text-left transition-colors ${hasMultipleWorktrees ? 'cursor-pointer' : 'cursor-default'}`}
style={{
height: `${HEADER_ROW2_HEIGHT}px`,
backgroundColor: isWorktreeDropdownOpen
? 'var(--color-surface-raised)'
: 'var(--color-surface-sidebar)',
color: isWorktreeDropdownOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
}}
>
<div className="flex flex-1 items-center gap-1.5 overflow-hidden">
<GitBranch
className="size-4 shrink-0"
style={{ color: isWorktreeDropdownOpen ? '#34d399' : 'rgba(52, 211, 153, 0.7)' }}
/>
{activeWorktree?.isMainWorktree ? (
<WorktreeBadge source={activeWorktree.source} isMain />
) : (
activeWorktree?.source && <WorktreeBadge source={activeWorktree.source} />
)}
<span className="truncate font-mono text-xs">{truncateMiddle(worktreeName, 28)}</span>
</div>
{hasMultipleWorktrees && (
<ChevronDown
className={`size-4 shrink-0 transition-transform ${isWorktreeDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
)}
</button>
{/* Worktree Dropdown */}
{isWorktreeDropdownOpen && hasMultipleWorktrees && (
<>
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsWorktreeDropdownOpen(false)}
/>
<div
className="absolute inset-x-0 top-full z-20 mt-0 max-h-[400px] overflow-y-auto py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderTopWidth: '0',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
<div
className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch Worktree
</div>
{/* Main worktree first */}
{mainWorktree && (
<WorktreeItem
worktree={mainWorktree}
isSelected={mainWorktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(mainWorktree)}
/>
)}
{/* Grouped worktrees by source */}
{worktreeGroups.map((group) => (
<div key={group.source}>
{/* Group header */}
<div
className="mt-1 px-4 py-1.5 text-[9px] font-medium uppercase tracking-wider"
style={{
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{group.label}
</div>
{/* Worktrees in group */}
{group.worktrees.map((worktree) => (
<WorktreeItem
key={worktree.id}
worktree={worktree}
isSelected={worktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(worktree)}
/>
))}
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
};

View file

@ -21,6 +21,8 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { TeamTabSectionNav } from './TeamTabSectionNav';
import type { Tab } from '@renderer/types/tabs';
interface SortableTabProps {
@ -98,6 +100,8 @@ export const SortableTab = ({
[setNodeRef, setRef, tab.id]
);
const isTeamTab = tab.type === 'team' && tab.teamName;
return (
<div
ref={handleRef}
@ -108,7 +112,11 @@ export const SortableTab = ({
role="tab"
tabIndex={0}
aria-selected={isActive}
className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
className={
isTeamTab
? 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab flex-col rounded-md'
: 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5'
}
style={style}
onClick={(e) => onTabClick(tab.id, e)}
onMouseDown={(e) => onMouseDown(tab.id, e)}
@ -122,30 +130,45 @@ export const SortableTab = ({
}
}}
>
<Icon className="size-4 shrink-0" />
{tab.fromSearch && (
<span title="Opened from search">
<Search className="size-3 shrink-0 text-amber-400" />
</span>
<div className={isTeamTab ? 'flex min-w-0 items-center gap-2 px-3 pb-0.5 pt-1' : 'contents'}>
<Icon className="size-4 shrink-0" />
{tab.fromSearch && (
<span title="Opened from search">
<Search className="size-3 shrink-0 text-amber-400" />
</span>
)}
{isPinned && (
<span title="Pinned session">
<Pin className="size-3 shrink-0 text-blue-400" />
</span>
)}
<span className="truncate text-sm">{tab.label}</span>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
title="Close tab"
>
<X className="size-3" />
</button>
</div>
{isTeamTab && (
<TeamTabSectionNav
teamName={tab.teamName!}
onActivate={() => {
setIsHovered(false);
onTabClick(tab.id, {
metaKey: false,
ctrlKey: false,
shiftKey: false,
} as React.MouseEvent);
}}
/>
)}
{isPinned && (
<span title="Pinned session">
<Pin className="size-3 shrink-0 text-blue-400" />
</span>
)}
<span className="truncate text-sm">{tab.label}</span>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
title="Close tab"
>
<X className="size-3" />
</button>
</div>
);
};

View file

@ -16,6 +16,7 @@ import { UpdateBanner } from '../common/UpdateBanner';
import { UpdateDialog } from '../common/UpdateDialog';
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
import { CommandPalette } from '../search/CommandPalette';
import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog';
import { CustomTitleBar } from './CustomTitleBar';
import { PaneContainer } from './PaneContainer';
@ -50,6 +51,7 @@ export const TabbedLayout = (): React.JSX.Element => {
{/* Multi-pane content area */}
<PaneContainer />
</div>
<GlobalTaskDetailDialog />
<UpdateDialog />
<WorkspaceIndicator />
</div>

View file

@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Columns3, History, MessageSquare, Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface TeamTabSectionNavProps {
teamName: string;
onActivate?: () => void;
}
const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
{ id: 'team', label: 'Team', icon: Users },
{ id: 'sessions', label: 'Sessions', icon: History },
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
{ id: 'messages', label: 'Messages', icon: MessageSquare },
];
export const TeamTabSectionNav = ({
teamName,
onActivate,
}: TeamTabSectionNavProps): React.JSX.Element => {
const [open, setOpen] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
const handleNavigate = useCallback(
(sectionId: string) => {
onActivate?.();
const el = document.querySelector(
`[data-team-name="${CSS.escape(teamName)}"] [data-section-id="${sectionId}"]`
);
if (el) {
el.dispatchEvent(new CustomEvent('team-section-navigate'));
}
setOpen(false);
},
[teamName, onActivate]
);
useEffect(() => {
if (!open) return;
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setMenuPos({
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 120),
});
}
const handleDismiss = (e: MouseEvent): void => {
const target = e.target as Node;
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) {
return;
}
setOpen(false);
};
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleDismiss);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleDismiss);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
return (
<div className="w-full" onPointerDown={(e) => e.stopPropagation()}>
<button
ref={buttonRef}
type="button"
className="flex h-3.5 w-full items-center justify-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
title="Jump to section"
>
<ChevronDown size={10} />
</button>
{open &&
createPortal(
<div
ref={menuRef}
role="menu"
tabIndex={-1}
className="fixed z-50 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-0.5 shadow-lg"
style={{ top: menuPos.top, left: menuPos.left, minWidth: menuPos.width }}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false);
}}
>
{SECTIONS.map((section) => {
const SectionIcon = section.icon;
return (
<button
key={section.id}
type="button"
role="menuitem"
className="flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors"
style={{
color:
hoveredId === section.id
? 'var(--color-text)'
: 'var(--color-text-secondary)',
backgroundColor:
hoveredId === section.id ? 'var(--color-surface-raised)' : 'transparent',
}}
onMouseEnter={() => setHoveredId(section.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={(e) => {
e.stopPropagation();
handleNavigate(section.id);
}}
>
<SectionIcon size={12} className="shrink-0" />
{section.label}
</button>
);
})}
</div>,
document.body
)}
</div>
);
};

View file

@ -46,7 +46,7 @@ export const GeneralInfoSection = ({
{/* Scope/Tool Name */}
<div className="flex items-center justify-between border-b border-border-subtle py-2">
<label htmlFor="new-trigger-tool-name" className="text-sm text-text-secondary">
<label htmlFor="new-trigger-tool-name" className="label-optional text-sm">
Scope / Tool Name (optional)
</label>
<select

View file

@ -11,6 +11,8 @@ import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide
import { SettingsSectionHeader } from '../components';
import { CliStatusSection } from './CliStatusSection';
interface AdvancedSectionProps {
readonly saving: boolean;
readonly onResetToDefaults: () => void;
@ -144,6 +146,8 @@ export const AdvancedSection = ({
)}
</div>
<CliStatusSection />
<SettingsSectionHeader title="About" />
<div className="flex items-start gap-4 py-3">
<img src={appIcon} alt="App Icon" className="size-10 rounded-lg" />

View file

@ -0,0 +1,246 @@
/**
* CliStatusSection CLI installation status and install/update controls.
*
* Displayed in Settings Advanced, only in Electron mode.
* Shows detection status, version info, download progress, and error states.
*/
import { useCallback, useEffect, useMemo } from 'react';
import { isElectronMode } from '@renderer/api';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { formatBytes } from '@renderer/utils/formatters';
import { AlertTriangle, CheckCircle, Download, Loader2, RefreshCw, Terminal } from 'lucide-react';
import { SettingsSectionHeader } from '../components';
export const CliStatusSection = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []);
const {
cliStatus,
installerState,
downloadProgress,
downloadTransferred,
downloadTotal,
installerError,
completedVersion,
fetchCliStatus,
installCli,
isBusy,
} = useCliInstaller();
useEffect(() => {
if (isElectron) {
void fetchCliStatus();
}
}, [isElectron, fetchCliStatus]);
const handleInstall = useCallback(() => {
installCli();
}, [installCli]);
const handleRefresh = useCallback(() => {
void fetchCliStatus();
}, [fetchCliStatus]);
if (!isElectron) return null;
return (
<div className="mb-2">
<SettingsSectionHeader title="Claude CLI" />
<div className="space-y-3 py-2">
{/* Loading status */}
{!cliStatus && installerState === 'idle' && (
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-muted)' }}
>
<Loader2 className="size-4 animate-spin" />
Checking CLI...
</div>
)}
{/* Status display */}
{cliStatus && installerState === 'idle' && (
<div className="space-y-2">
{cliStatus.installed ? (
<div className="space-y-1">
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text)' }}
>
<Terminal
className="size-4 shrink-0"
style={{ color: 'var(--color-text-muted)' }}
/>
<span>Claude CLI v{cliStatus.installedVersion ?? 'unknown'}</span>
</div>
{cliStatus.binaryPath && (
<p
className="ml-6 truncate text-xs"
style={{ color: 'var(--color-text-muted)' }}
title={cliStatus.binaryPath}
>
{cliStatus.binaryPath}
</p>
)}
{cliStatus.updateAvailable && cliStatus.latestVersion && (
<div className="ml-6 flex items-center gap-2">
<span className="text-xs" style={{ color: '#60a5fa' }}>
v{cliStatus.installedVersion} &rarr; v{cliStatus.latestVersion}
</span>
</div>
)}
</div>
) : (
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
Claude CLI not installed
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
{!cliStatus.installed && (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Install Claude CLI
</button>
)}
{cliStatus.installed && cliStatus.updateAvailable && (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Update
</button>
)}
{cliStatus.installed && !cliStatus.updateAvailable && (
<button
onClick={handleRefresh}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<RefreshCw className="size-3.5" />
Check for Updates
</button>
)}
</div>
</div>
)}
{/* Downloading */}
{installerState === 'downloading' && (
<div className="space-y-2">
<div
className="flex items-center justify-between text-xs"
style={{ color: 'var(--color-text-secondary)' }}
>
<span>Downloading...</span>
<span>
{downloadTotal > 0
? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)`
: `${formatBytes(downloadTransferred)}`}
</span>
</div>
<div
className="h-1.5 w-full overflow-hidden rounded-full"
style={{ backgroundColor: 'var(--color-surface-raised)' }}
>
{downloadTotal > 0 ? (
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${downloadProgress}%`,
backgroundColor: '#3b82f6',
}}
/>
) : (
<div
className="h-full w-1/3 animate-pulse rounded-full"
style={{ backgroundColor: '#3b82f6' }}
/>
)}
</div>
</div>
)}
{/* Checking */}
{installerState === 'checking' && (
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<Loader2 className="size-4 animate-spin" />
Checking latest version...
</div>
)}
{/* Verifying */}
{installerState === 'verifying' && (
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<Loader2 className="size-4 animate-spin" />
Verifying checksum...
</div>
)}
{/* Installing */}
{installerState === 'installing' && (
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<Loader2 className="size-4 animate-spin" />
Installing...
</div>
)}
{/* Completed */}
{installerState === 'completed' && (
<div className="flex items-center gap-2 text-sm" style={{ color: '#4ade80' }}>
<CheckCircle className="size-4" />
Installed v{completedVersion ?? 'latest'}
</div>
)}
{/* Error */}
{installerState === 'error' && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm" style={{ color: '#f87171' }}>
<AlertTriangle className="size-4" />
{installerError ?? 'Installation failed'}
</div>
<button
onClick={handleInstall}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<RefreshCw className="size-3.5" />
Retry
</button>
</div>
)}
</div>
</div>
);
};

View file

@ -7,19 +7,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import {
getNonEmptyCategories,
groupSessionsByDate,
separatePinnedSessions,
} from '@renderer/utils/dateGrouping';
import { truncateMiddle } from '@renderer/utils/stringUtils';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
ArrowDownWideNarrow,
Calendar,
Check,
CheckSquare,
ChevronDown,
Eye,
EyeOff,
GitBranch,
Loader2,
MessageSquareOff,
Pin,
@ -27,11 +32,109 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { WorktreeBadge } from '../common/WorktreeBadge';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { SessionItem } from './SessionItem';
import type { Session } from '@renderer/types/data';
import type { Session, Worktree, WorktreeSource } from '@renderer/types/data';
import type { DateCategory } from '@renderer/types/tabs';
// ---------------------------------------------------------------------------
// Worktree grouping helpers (moved from SidebarHeader)
// ---------------------------------------------------------------------------
interface WorktreeGroup {
source: WorktreeSource;
label: string;
worktrees: Worktree[];
mostRecent: number;
}
const SOURCE_LABELS: Record<WorktreeSource, string> = {
'vibe-kanban': 'Vibe Kanban',
conductor: 'Conductor',
'auto-claude': 'Auto Claude',
'21st': '21st',
'claude-desktop': 'Claude Desktop',
ccswitch: 'ccswitch',
git: 'Git',
unknown: 'Other',
};
function groupWorktreesBySource(worktrees: Worktree[]): {
mainWorktree: Worktree | null;
groups: WorktreeGroup[];
} {
const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null;
const groupMap = new Map<WorktreeSource, Worktree[]>();
for (const wt of worktrees) {
if (wt.isMainWorktree) continue;
const existing = groupMap.get(wt.source) ?? [];
existing.push(wt);
groupMap.set(wt.source, existing);
}
const groups: WorktreeGroup[] = [];
for (const [source, wts] of groupMap) {
const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0));
groups.push({ source, label: SOURCE_LABELS[source] ?? source, worktrees: sorted, mostRecent });
}
groups.sort((a, b) => b.mostRecent - a.mostRecent);
return { mainWorktree, groups };
}
// ---------------------------------------------------------------------------
// WorktreeItem (inline, moved from SidebarHeader)
// ---------------------------------------------------------------------------
const WorktreeItem = ({
worktree,
isSelected,
onSelect,
}: {
worktree: Worktree;
isSelected: boolean;
onSelect: () => void;
}): React.JSX.Element => {
const [isHovered, setIsHovered] = useState(false);
const buttonStyle: React.CSSProperties = isSelected
? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }
: {
backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent',
opacity: isHovered ? 0.5 : 1,
};
return (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-left transition-colors"
style={buttonStyle}
>
<GitBranch
className="size-3.5 shrink-0"
style={{ color: isSelected ? '#34d399' : 'var(--color-text-muted)' }}
/>
{worktree.isMainWorktree && <WorktreeBadge source={worktree.source} isMain />}
<span
className="flex-1 truncate font-mono text-xs"
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{truncateMiddle(worktree.name, 28)}
</span>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{worktree.sessions.length}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};
// Virtual list item types
type VirtualItem =
| { type: 'header'; category: DateCategory; id: string }
@ -74,6 +177,19 @@ export const DateGroupedSessions = (): React.JSX.Element => {
hideMultipleSessions,
unhideMultipleSessions,
pinMultipleSessions,
// Project / repository state
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
selectWorktree,
selectRepository,
viewMode,
projects,
activeProjectId,
setActiveProject,
clearActiveProject,
fetchRepositoryGroups,
fetchProjects,
} = useStore(
useShallow((s) => ({
sessions: s.sessions,
@ -98,12 +214,82 @@ export const DateGroupedSessions = (): React.JSX.Element => {
hideMultipleSessions: s.hideMultipleSessions,
unhideMultipleSessions: s.unhideMultipleSessions,
pinMultipleSessions: s.pinMultipleSessions,
// Project / repository
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
selectWorktree: s.selectWorktree,
selectRepository: s.selectRepository,
viewMode: s.viewMode,
projects: s.projects,
activeProjectId: s.activeProjectId,
setActiveProject: s.setActiveProject,
clearActiveProject: s.clearActiveProject,
fetchRepositoryGroups: s.fetchRepositoryGroups,
fetchProjects: s.fetchProjects,
}))
);
const parentRef = useRef<HTMLDivElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const [showCountTooltip, setShowCountTooltip] = useState(false);
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const worktreeDropdownRef = useRef<HTMLDivElement>(null);
// Fetch project data on mount
useEffect(() => {
if (viewMode === 'grouped' && repositoryGroups.length === 0) {
void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0) {
void fetchProjects();
}
}, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]);
// Project combobox options
const projectComboboxOptions = useMemo((): ComboboxOption[] => {
const items =
viewMode === 'grouped'
? repositoryGroups.filter((r) => r.totalSessions > 0)
: projects.filter((p) => p.sessions.length > 0);
return items.map((item) => {
const sessionCount =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).totalSessions
: (item as (typeof projects)[0]).sessions.length;
const path =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path
: (item as (typeof projects)[0]).path;
return {
value: item.id,
label: item.name,
description: path,
meta: { sessionCount, path },
};
});
}, [viewMode, repositoryGroups, projects]);
const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId;
const handleProjectValueChange = (id: string): void => {
if (viewMode === 'grouped') selectRepository(id);
else setActiveProject(id);
};
// Worktree state
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0);
const hasMultipleWorktrees = worktrees.length > 1;
const worktreeGroupingResult = useMemo(() => groupWorktreesBySource(worktrees), [worktrees]);
const mainWorktree = worktreeGroupingResult.mainWorktree;
const worktreeGroups = worktreeGroupingResult.groups;
const worktreeName = activeWorktree?.name ?? 'main';
const handleSelectWorktree = (worktree: Worktree): void => {
selectWorktree(worktree.id);
setIsWorktreeDropdownOpen(false);
};
const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]);
const hasHiddenSessions = hiddenSessionIds.length > 0;
@ -295,11 +481,158 @@ export const DateGroupedSessions = (): React.JSX.Element => {
clearSidebarSelection();
}, [pinMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]);
// Project selector (always rendered at top)
const projectSelector = (
<div className="shrink-0 space-y-0">
{/* Project combobox */}
<div className="px-2 py-1.5">
<Combobox
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
placeholder="Select Project"
searchPlaceholder="Search..."
emptyMessage="Nothing found"
className="text-[12px]"
resetLabel="Reset selection"
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined;
return (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'text-indigo-400 opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate',
isSelected
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
>
{option.label}
</p>
{path ? (
<p className="truncate text-[10px] text-[var(--color-text-muted)]">{path}</p>
) : null}
</div>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{sessionCount}
</span>
</>
);
}}
/>
</div>
{/* Worktree selector (grouped mode only, when multiple worktrees) */}
{viewMode === 'grouped' && activeRepo && hasMultipleWorktrees && (
<div ref={worktreeDropdownRef} className="relative w-full">
<button
onClick={() => setIsWorktreeDropdownOpen(!isWorktreeDropdownOpen)}
className="flex w-full items-center justify-between px-3 py-1 text-left transition-colors"
style={{
backgroundColor: isWorktreeDropdownOpen
? 'var(--color-surface-raised)'
: 'transparent',
color: isWorktreeDropdownOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
}}
>
<div className="flex flex-1 items-center gap-1.5 overflow-hidden">
<GitBranch
className="size-3.5 shrink-0"
style={{ color: isWorktreeDropdownOpen ? '#34d399' : 'rgba(52, 211, 153, 0.7)' }}
/>
{activeWorktree?.isMainWorktree ? (
<WorktreeBadge source={activeWorktree.source} isMain />
) : (
activeWorktree?.source && <WorktreeBadge source={activeWorktree.source} />
)}
<span className="truncate font-mono text-[11px]">
{truncateMiddle(worktreeName, 24)}
</span>
</div>
<ChevronDown
className={`size-3.5 shrink-0 transition-transform ${isWorktreeDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
</button>
{isWorktreeDropdownOpen && (
<>
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsWorktreeDropdownOpen(false)}
/>
<div
className="absolute inset-x-0 top-full z-20 mt-0 max-h-[300px] overflow-y-auto py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderTopWidth: '0',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
<div
className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch Worktree
</div>
{mainWorktree && (
<WorktreeItem
worktree={mainWorktree}
isSelected={mainWorktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(mainWorktree)}
/>
)}
{worktreeGroups.map((group) => (
<div key={group.source}>
<div
className="mt-1 px-4 py-1.5 text-[9px] font-medium uppercase tracking-wider"
style={{
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{group.label}
</div>
{group.worktrees.map((worktree) => (
<WorktreeItem
key={worktree.id}
worktree={worktree}
isSelected={worktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(worktree)}
/>
))}
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
if (!selectedProjectId) {
return (
<div className="p-4">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<p>Select a project to view sessions</p>
<div className="flex h-full flex-col">
{projectSelector}
<div className="flex flex-1 items-center justify-center p-4">
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<p>Select a project to view sessions</p>
</div>
</div>
</div>
);
@ -313,8 +646,9 @@ export const DateGroupedSessions = (): React.JSX.Element => {
];
return (
<div className="p-4">
<div className="space-y-3">
<div className="flex h-full flex-col">
{projectSelector}
<div className="space-y-3 p-4">
{widths.map((w, i) => (
<div key={i} className="space-y-2">
<div
@ -338,19 +672,22 @@ export const DateGroupedSessions = (): React.JSX.Element => {
if (sessionsError) {
return (
<div className="p-4">
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-muted)',
}}
>
<p className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
Error loading sessions
</p>
<p>{sessionsError}</p>
<div className="flex h-full flex-col">
{projectSelector}
<div className="p-4">
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-muted)',
}}
>
<p className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
Error loading sessions
</p>
<p>{sessionsError}</p>
</div>
</div>
</div>
);
@ -358,11 +695,14 @@ export const DateGroupedSessions = (): React.JSX.Element => {
if (sessions.length === 0) {
return (
<div className="p-4">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<MessageSquareOff className="mx-auto mb-2 size-8 opacity-50" />
<p className="mb-2">No sessions found</p>
<p className="text-xs opacity-70">This project has no sessions yet</p>
<div className="flex h-full flex-col">
{projectSelector}
<div className="flex flex-1 items-center justify-center p-4">
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<MessageSquareOff className="mx-auto mb-2 size-8 opacity-50" />
<p className="mb-2">No sessions found</p>
<p className="text-xs opacity-70">This project has no sessions yet</p>
</div>
</div>
</div>
);
@ -370,7 +710,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="mt-2 flex items-center gap-2 px-4 py-3">
{projectSelector}
<div className="flex items-center gap-2 px-4 py-2">
<Calendar className="size-4" style={{ color: 'var(--color-text-muted)' }} />
<h2
className="text-xs uppercase tracking-wider"

View file

@ -3,14 +3,18 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
import {
getNonEmptyTaskCategories,
groupTasksByDate,
groupTasksByProject,
sortTasksByFreshness,
} from '@renderer/utils/taskGrouping';
import { ListTodo, Search, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { SidebarTaskItem } from './SidebarTaskItem';
import { TaskFiltersPopover } from './TaskFiltersPopover';
import {
@ -25,16 +29,16 @@ import type { GlobalTask } from '@shared/types';
const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping';
export type TaskGroupingMode = 'project' | 'time';
export type TaskGroupingMode = 'none' | 'project' | 'time';
function loadGroupingMode(): TaskGroupingMode {
try {
const v = localStorage.getItem(TASK_GROUPING_STORAGE_KEY);
if (v === 'project' || v === 'time') return v;
if (v === 'none' || v === 'project' || v === 'time') return v;
} catch {
/* ignore */
}
return 'project';
return 'none';
}
function saveGroupingMode(mode: TaskGroupingMode): void {
@ -89,11 +93,8 @@ export const GlobalTaskList = ({
globalTasksLoading,
fetchAllTasks,
projects,
activeProjectId,
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
teams,
} = useStore(
useShallow((s) => ({
@ -101,11 +102,8 @@ export const GlobalTaskList = ({
globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks,
projects: s.projects,
activeProjectId: s.activeProjectId,
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
teams: s.teams,
}))
);
@ -122,6 +120,9 @@ export const GlobalTaskList = ({
const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot();
// Local project filter (independent from sessions tab)
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
const setGroupingMode = (mode: TaskGroupingMode): void => {
setGroupingModeState(mode);
saveGroupingMode(mode);
@ -134,22 +135,34 @@ export const GlobalTaskList = ({
}
}, [fetchAllTasks]);
const selectedProjectPath = useMemo(() => {
if (viewMode === 'grouped') {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId);
return worktree?.path ?? null;
}
const project = projects.find((p) => p.id === activeProjectId);
return project?.path ?? null;
}, [
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
projects,
activeProjectId,
]);
// Build project combobox options from available projects/repos
const projectFilterOptions = useMemo((): ComboboxOption[] => {
const items =
viewMode === 'grouped'
? repositoryGroups
.filter((r) => r.totalSessions > 0)
.map((r) => ({
value: r.worktrees[0]?.path ?? r.id,
label: r.name,
path: r.worktrees[0]?.path,
}))
: projects
.filter((p) => p.sessions.length > 0)
.map((p) => ({
value: p.path,
label: p.name,
path: p.path,
}));
return items.map((item) => ({
value: item.value,
label: item.label,
description: item.path,
}));
}, [viewMode, repositoryGroups, projects]);
// Resolve local filter to a project path
const selectedProjectPath = localProjectFilter;
const filtered = useMemo(() => {
let result = globalTasks;
@ -175,35 +188,32 @@ export const GlobalTaskList = ({
readState,
]);
const sortedFlat = useMemo(() => sortTasksByFreshness(filtered), [filtered]);
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
const projectGroups = useMemo(() => groupTasksByProject(filtered), [filtered]);
const hasContent =
groupingMode === 'time' ? categories.length > 0 : projectGroups.some((g) => g.tasks.length > 0);
groupingMode === 'none'
? sortedFlat.length > 0
: groupingMode === 'time'
? categories.length > 0
: projectGroups.some((g) => g.tasks.length > 0);
return (
<div className="flex size-full min-w-0 flex-col">
{!hideHeader && (
<div
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5"
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
<span className="text-[12px] font-semibold text-text-secondary">Tasks</span>
<TaskFiltersPopover
open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={filters}
onFiltersChange={setFilters}
onApply={() => {}}
/>
</div>
)}
{/* Search bar */}
<div
className="flex shrink-0 items-center gap-1.5 border-b px-2 py-1"
className="mb-[5px] flex shrink-0 items-center gap-1.5 border-b px-2 py-1"
style={{ borderColor: 'var(--color-border)' }}
>
<Search className="size-3 shrink-0 text-text-muted" />
@ -227,6 +237,29 @@ export const GlobalTaskList = ({
<X className="size-3" />
</button>
)}
<TaskFiltersPopover
open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={filters}
onFiltersChange={setFilters}
onApply={() => {}}
/>
</div>
{/* Project filter */}
<div className="shrink-0 px-2 py-1">
<Combobox
options={projectFilterOptions}
value={localProjectFilter ?? ''}
onValueChange={(v) => setLocalProjectFilter(v)}
placeholder="All Projects"
searchPlaceholder="Search projects..."
emptyMessage="No projects"
className="text-[11px]"
resetLabel="All Projects"
onReset={() => setLocalProjectFilter(null)}
/>
</div>
{/* Grouping mode — compact segmented toggle */}
@ -237,21 +270,24 @@ export const GlobalTaskList = ({
role="group"
aria-label="Group by"
>
{(['project', 'time'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-2 py-0.5 transition-colors',
groupingMode === mode
? 'bg-surface-raised text-text shadow-sm'
: 'text-text-muted hover:text-text-secondary'
)}
>
{mode === 'project' ? 'Project' : 'Time'}
</button>
))}
{(['none', 'project', 'time'] as const).map((mode) => {
const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
return (
<button
key={mode}
type="button"
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-2 py-0.5 transition-colors',
groupingMode === mode
? 'bg-surface-raised text-text shadow-sm'
: 'text-text-muted hover:text-text-secondary'
)}
>
{label}
</button>
);
})}
</div>
</div>
@ -274,6 +310,11 @@ export const GlobalTaskList = ({
</div>
)}
{groupingMode === 'none' &&
sortedFlat.map((task) => (
<SidebarTaskItem key={`${task.teamName}-${task.id}`} task={task} showTeamName />
))}
{groupingMode === 'project' &&
projectGroups.map((group) => {
if (group.tasks.length === 0) return null;
@ -281,10 +322,16 @@ export const GlobalTaskList = ({
return (
<div key={group.projectKey}>
<div
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
className="sticky top-0 z-10 flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{group.projectLabel}
<span
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: projectColor(group.projectLabel).border }}
/>
<span style={{ color: projectColor(group.projectLabel).text }}>
{group.projectLabel}
</span>
</div>
{group.tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;

View file

@ -1,7 +1,15 @@
import { useMemo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor';
import { projectColor } from '@renderer/utils/projectColor';
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck } from 'lucide-react';
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import type { GlobalTask, TeamTaskStatus } from '@shared/types';
import type { LucideIcon } from 'lucide-react';
@ -47,13 +55,16 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
interface SidebarTaskItemProps {
task: GlobalTask;
hideTeamName?: boolean;
showTeamName?: boolean;
}
export const SidebarTaskItem = ({
task,
hideTeamName,
showTeamName,
}: SidebarTaskItemProps): React.JSX.Element => {
const openTeamTab = useStore((s) => s.openTeamTab);
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore((s) => s.teams.find((t) => t.teamName === task.teamName)?.members);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const cfg =
task.kanbanColumn === 'approved'
@ -65,48 +76,112 @@ export const SidebarTaskItem = ({
const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
const ownerColorSet = useMemo(() => {
if (!teamMembers || !task.owner) return null;
const colorMap = buildMemberColorMap(teamMembers);
const colorName = colorMap.get(task.owner);
return colorName ? getTeamColorSet(colorName) : null;
}, [teamMembers, task.owner]);
const projectLabel = useMemo(() => {
if (!task.projectPath?.trim()) return null;
return projectLabelFromPath(task.projectPath);
}, [task.projectPath]);
const projectColorSet = useMemo(
() => (projectLabel ? projectColor(projectLabel) : null),
[projectLabel]
);
const teamColor = useMemo(
() => (showTeamName ? nameColorSet(task.teamDisplayName) : null),
[showTeamName, task.teamDisplayName]
);
const showTeamRow = showTeamName && !hideTeamName;
return (
<button
type="button"
className="flex h-[48px] w-full cursor-pointer flex-col justify-center border-b px-3 py-2 text-left transition-colors hover:bg-surface-raised"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => openTeamTab(task.teamName, undefined, task.id)}
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
>
<div className="flex w-full items-center gap-1.5 overflow-hidden">
<span
className="truncate text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{task.subject}
</span>
{/* Row 1: status + subject */}
<div className="flex w-full items-start gap-1.5 overflow-hidden">
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
<Tooltip>
<TooltipTrigger asChild>
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{task.subject}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{task.subject}
</TooltipContent>
</Tooltip>
{unreadCount > 0 && (
<span
className="size-1.5 shrink-0 rounded-full bg-blue-400"
title={`${unreadCount} unread`}
/>
)}
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
</div>
{/* Row 2: project + owner (when no team row) + date */}
<div
className="mt-0.5 flex items-center gap-1.5 text-[10px] leading-tight"
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<span>{task.owner ?? 'unassigned'}</span>
{!hideTeamName && (
<>
<span className="opacity-40">·</span>
<span className="truncate">{task.teamDisplayName}</span>
</>
{task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
{projectLabel && (
<span
className="shrink-0"
style={projectColorSet ? { color: projectColorSet.text } : undefined}
>
{projectLabel}
</span>
)}
{dateLabel && (
{!showTeamRow && (
<>
<span className="opacity-40">·</span>
<span className={`shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
{dateLabel}
{projectLabel && <span className="opacity-40">·</span>}
<span
className="shrink-0 opacity-60"
style={ownerColorSet ? { color: ownerColorSet.text } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</>
)}
{dateLabel && (
<span className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
{dateLabel}
</span>
)}
</div>
{/* Row 3: Team: name · owner */}
{showTeamRow && (
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<span className="shrink-0 opacity-50">Team:</span>
<span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
{task.teamDisplayName}
</span>
<span className="opacity-40">·</span>
<span
className="shrink-0 opacity-60"
style={ownerColorSet ? { color: ownerColorSet.text } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</div>
)}
</button>
);
};

View file

@ -54,10 +54,9 @@ export const TaskFiltersPopover = ({
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-0.5 text-[11px] font-medium text-text-muted transition-colors hover:text-text-secondary data-[state=open]:bg-surface-raised data-[state=open]:text-text"
className="flex shrink-0 items-center justify-center rounded p-0.5 text-text-muted transition-colors hover:text-text-secondary data-[state=open]:bg-surface-raised data-[state=open]:text-text"
>
<Filter className="size-3" />
Filters
<Filter className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>

View file

@ -0,0 +1,230 @@
/**
* CliLogsRichView
*
* Renders CLI stream-json logs using the same rich components as session views:
* thinking blocks, tool call cards, markdown text output.
*
* Replaces raw JSON display in ProvisioningProgressBlock.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { cn } from '@renderer/lib/utils';
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
import { Bot, ChevronRight } from 'lucide-react';
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
interface CliLogsRichViewProps {
cliLogsTail: string;
className?: string;
}
/**
* Derives a scoped Set for a single group from the global prefixed Set.
* Global keys are stored as `groupId::itemId`; this strips the prefix.
*/
function scopedItemIds(globalIds: Set<string>, groupId: string): Set<string> {
const prefix = `${groupId}::`;
const scoped = new Set<string>();
for (const key of globalIds) {
if (key.startsWith(prefix)) {
scoped.add(key.slice(prefix.length));
}
}
return scoped;
}
/**
* Single-item group rendered flat (no collapsible wrapper).
*/
const FlatGroupItem = ({
group,
expandedItemIds,
onItemClick,
}: {
group: StreamJsonGroup;
expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void;
}): React.JSX.Element => {
const groupItemIds = useMemo(
() => scopedItemIds(expandedItemIds, group.id),
[expandedItemIds, group.id]
);
const handleItemClick = useCallback(
(itemId: string) => onItemClick(`${group.id}::${itemId}`),
[group.id, onItemClick]
);
return (
<DisplayItemList
items={group.items}
onItemClick={handleItemClick}
expandedItemIds={groupItemIds}
aiGroupId={group.id}
/>
);
};
/**
* A single collapsible group of assistant items (2+ items).
*/
const StreamGroup = ({
group,
isExpanded,
onToggle,
expandedItemIds,
onItemClick,
}: {
group: StreamJsonGroup;
isExpanded: boolean;
onToggle: () => void;
expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void;
}): React.JSX.Element => {
// Scope item IDs to this group to avoid cross-group collisions
const groupItemIds = useMemo(
() => scopedItemIds(expandedItemIds, group.id),
[expandedItemIds, group.id]
);
const handleItemClick = useCallback(
(itemId: string) => onItemClick(`${group.id}::${itemId}`),
[group.id, onItemClick]
);
return (
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<button
type="button"
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={onToggle}
>
<ChevronRight
size={12}
className={cn(
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
isExpanded && 'rotate-90'
)}
/>
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{group.summary}
</span>
</button>
{isExpanded && (
<div className="border-t border-[var(--color-border)] p-2">
<DisplayItemList
items={group.items}
onItemClick={handleItemClick}
expandedItemIds={groupItemIds}
aiGroupId={group.id}
/>
</div>
)}
</div>
);
};
export const CliLogsRichView = ({
cliLogsTail,
className,
}: CliLogsRichViewProps): React.JSX.Element => {
const scrollRef = useRef<HTMLDivElement>(null);
// Tracks groups manually collapsed by user (default: all auto-expanded)
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]);
// Derive expanded state: all groups expanded unless manually collapsed
const expandedGroupIds = useMemo(() => {
const expanded = new Set<string>();
for (const group of groups) {
if (!collapsedGroupIds.has(group.id)) {
expanded.add(group.id);
}
}
return expanded;
}, [groups, collapsedGroupIds]);
// Auto-scroll to bottom on new content
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [cliLogsTail]);
const handleGroupToggle = useCallback((groupId: string) => {
setCollapsedGroupIds((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
}, []);
const handleItemClick = useCallback((itemId: string) => {
setExpandedItemIds((prev) => {
const next = new Set(prev);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
}, []);
if (groups.length === 0) {
// cliLogsTail has data but no parseable assistant messages — show raw text fallback
const hasContent = cliLogsTail.trim().length > 0;
return (
<div
ref={scrollRef}
className={cn(
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
className
)}
>
{hasContent ? (
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{cliLogsTail}
</pre>
) : (
<p className="p-3 text-center text-[11px] italic text-[var(--color-text-muted)]">
Waiting for CLI output...
</p>
)}
</div>
);
}
return (
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
{groups.map((group) =>
group.items.length === 1 ? (
// Single item — render flat without collapsible group wrapper
<FlatGroupItem
key={group.id}
group={group}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
/>
) : (
<StreamGroup
key={group.id}
group={group}
isExpanded={expandedGroupIds.has(group.id)}
onToggle={() => handleGroupToggle(group.id)}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
/>
)
)}
</div>
);
};

View file

@ -1,10 +1,20 @@
import { useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { ChevronRight } from 'lucide-react';
function scrollAfterExpand(el: HTMLElement): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}
interface CollapsibleTeamSectionProps {
title: string;
/** Icon rendered before the title text. */
icon?: React.ReactNode;
badge?: string | number;
/** Secondary badge (e.g. unread count). Shown next to main badge when defined. */
secondaryBadge?: number;
@ -13,24 +23,45 @@ interface CollapsibleTeamSectionProps {
defaultOpen?: boolean;
forceOpen?: boolean;
action?: React.ReactNode;
/** Stable identifier used for programmatic section navigation. */
sectionId?: string;
children: React.ReactNode;
}
export const CollapsibleTeamSection = ({
title,
icon,
badge,
secondaryBadge,
headerExtra,
defaultOpen = true,
forceOpen,
action,
sectionId,
children,
}: CollapsibleTeamSectionProps): React.JSX.Element => {
const [open, setOpen] = useState(defaultOpen);
const isOpen = forceOpen ? true : open;
const sectionRef = useRef<HTMLElement>(null);
const handleNavigate = useCallback((): void => {
setOpen(true);
if (sectionRef.current) scrollAfterExpand(sectionRef.current);
}, []);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
el.addEventListener('team-section-navigate', handleNavigate);
return () => el.removeEventListener('team-section-navigate', handleNavigate);
}, [handleNavigate]);
return (
<section className="border-b border-[var(--color-border)] pb-3 last:border-b-0">
<section
ref={sectionRef}
data-section-id={sectionId}
className="min-w-0 overflow-hidden border-b border-[var(--color-border)] pb-3 last:border-b-0"
>
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
<button
type="button"
@ -43,6 +74,7 @@ export const CollapsibleTeamSection = ({
size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
/>
{icon ? <span className="shrink-0 text-[var(--color-text-muted)]">{icon}</span> : null}
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
{badge != null && (
<Badge
@ -65,7 +97,7 @@ export const CollapsibleTeamSection = ({
</div>
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div>
{isOpen && <div className="mt-2">{children}</div>}
{isOpen && <div className="mt-2 min-w-0 overflow-hidden">{children}</div>}
</section>
);
};

View file

@ -0,0 +1,131 @@
import { useStore } from '@renderer/store';
import { formatDistanceToNowStrict } from 'date-fns';
import { ExternalLink, Square, Terminal } from 'lucide-react';
import { MemberBadge } from './MemberBadge';
function formatShortTime(date: Date): string {
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
return distance
.replace(' seconds', 's')
.replace(' second', 's')
.replace(' minutes', 'm')
.replace(' minute', 'm')
.replace(' hours', 'h')
.replace(' hour', 'h')
.replace(' days', 'd')
.replace(' day', 'd')
.replace(' weeks', 'w')
.replace(' week', 'w')
.replace(' months', 'mo')
.replace(' month', 'mo')
.replace(' years', 'y')
.replace(' year', 'y');
}
export const ProcessesSection = (): React.JSX.Element | null => {
const teamName = useStore((s) => s.selectedTeamName);
const data = useStore((s) => s.selectedTeamData);
if (!teamName || !data?.processes?.length) return null;
const memberColorMap = new Map(data.members.map((m) => [m.name, m.color]));
const sorted = [...data.processes].sort((a, b) => {
const aAlive = !a.stoppedAt;
const bAlive = !b.stoppedAt;
if (aAlive !== bAlive) return aAlive ? -1 : 1;
return Date.parse(b.registeredAt) - Date.parse(a.registeredAt);
});
return (
<div className="space-y-0.5">
{sorted.map((proc) => {
const alive = !proc.stoppedAt;
const timeStr = alive
? `${formatShortTime(new Date(proc.registeredAt))} ago`
: `stopped ${formatShortTime(new Date(proc.stoppedAt!))} ago`;
return (
<div
key={proc.id}
className={`flex items-center gap-2 rounded px-2 py-1.5 text-xs transition-colors hover:bg-[var(--color-surface-raised)] ${!alive ? 'opacity-50' : ''}`}
>
{/* Status indicator */}
<span
className="relative inline-flex size-2 shrink-0"
title={alive ? 'Running' : 'Stopped'}
>
{alive && (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
)}
<span
className={`relative inline-flex size-2 rounded-full ${alive ? 'bg-emerald-400' : 'bg-zinc-500'}`}
/>
</span>
{/* Icon + label — takes available space */}
<Terminal size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<span
className="min-w-0 truncate font-medium text-[var(--color-text)]"
title={proc.label}
>
{proc.label}
</span>
{/* Port + URL inline — only when present */}
{(proc.port != null || proc.url) && (
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">
{proc.port != null && !proc.url && `:${proc.port}`}
{proc.url && (
<button
type="button"
className="text-[var(--color-text-secondary)] underline decoration-dotted underline-offset-2 transition-colors hover:text-blue-400"
onClick={() => void window.electronAPI.openExternal(proc.url!)}
title={proc.url}
>
{proc.url}
</button>
)}
</span>
)}
{/* Right-aligned group: Kill button, Open button, member badge, PID, time */}
<span className="ml-auto flex shrink-0 items-center gap-2">
{alive && (
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/10"
onClick={() => void window.electronAPI.teams.killProcess(teamName, proc.pid)}
title="Stop process (SIGTERM)"
>
<Square size={8} className="fill-current" />
Kill
</button>
)}
{alive && proc.url && (
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={() => void window.electronAPI.openExternal(proc.url!)}
title="Open in browser"
>
<ExternalLink size={10} />
Open
</button>
)}
<span className="font-mono text-[var(--color-text-muted)]">PID{proc.pid}</span>
{proc.registeredBy && (
<MemberBadge
name={proc.registeredBy}
color={memberColorMap.get(proc.registeredBy)}
/>
)}
<span className="text-[var(--color-text-muted)]">{timeStr}</span>
</span>
</div>
);
})}
</div>
);
};

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@ -7,59 +7,11 @@ import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
import { CliLogsRichView } from './CliLogsRichView';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
// ---------------------------------------------------------------------------
// JSON syntax-highlighted CLI logs
// ---------------------------------------------------------------------------
const JSON_KEY_COLOR = 'var(--syntax-property, #7dd3fc)';
const JSON_STRING_COLOR = 'var(--syntax-string, #86efac)';
const JSON_NUMBER_COLOR = 'var(--syntax-number, #fde68a)';
const JSON_BOOL_NULL_COLOR = 'var(--syntax-keyword, #c4b5fd)';
const JSON_BRACKET_COLOR = 'var(--color-text-muted)';
function syntaxHighlightJson(json: string): string {
return (
json
.replace(/("(?:\\.|[^"\\])*")\s*:/g, `<span style="color:${JSON_KEY_COLOR}">$1</span>:`)
.replace(/:\s*("(?:\\.|[^"\\])*")/g, (match, str: string) =>
match.replace(str, `<span style="color:${JSON_STRING_COLOR}">${str}</span>`)
)
// eslint-disable-next-line security/detect-unsafe-regex -- number format is bounded, input is our JSON
.replace(/:\s*(-?\d+(?:\.\d{1,20})?(?:[eE][+-]?\d{1,5})?)/g, (match, num: string) =>
match.replace(num, `<span style="color:${JSON_NUMBER_COLOR}">${num}</span>`)
)
.replace(/:\s*(true|false|null)/g, (match, kw: string) =>
match.replace(kw, `<span style="color:${JSON_BOOL_NULL_COLOR}">${kw}</span>`)
)
.replace(/([{}[\]])/g, `<span style="color:${JSON_BRACKET_COLOR}">$1</span>`)
);
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function prettyFormatLogs(raw: string): string {
return raw
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as unknown;
const pretty = escapeHtml(JSON.stringify(parsed, null, 2));
return syntaxHighlightJson(pretty);
} catch {
return escapeHtml(trimmed);
}
})
.join('\n');
}
export interface ProvisioningProgressBlockProps {
/** Title above the steps, e.g. "Launching team" */
title: string;
@ -125,19 +77,7 @@ export const ProvisioningProgressBlock = ({
}: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt);
const [logsOpen, setLogsOpen] = useState(false);
const logsRef = useRef<HTMLPreElement>(null);
const outputScrollRef = useRef<HTMLDivElement>(null);
const prettyLogs = useMemo(
() => (cliLogsTail ? prettyFormatLogs(cliLogsTail) : ''),
[cliLogsTail]
);
// Auto-scroll CLI logs
useEffect(() => {
if (logsOpen && logsRef.current) {
logsRef.current.scrollTop = logsRef.current.scrollHeight;
}
}, [logsOpen, cliLogsTail]);
// Auto-scroll assistant output
useEffect(() => {
@ -231,14 +171,7 @@ export const ProvisioningProgressBlock = ({
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
CLI logs
</button>
{logsOpen ? (
<pre
ref={logsRef}
className="mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
// Content is HTML-escaped via escapeHtml() before syntax highlighting spans are added
dangerouslySetInnerHTML={{ __html: prettyLogs }}
/>
) : null}
{logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null}
</div>
) : null}
</div>

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -17,21 +18,28 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
AlertTriangle,
Bell,
CheckCheck,
Columns3,
FolderOpen,
GitBranch,
History,
MessageSquare,
Pencil,
Play,
Plus,
Search,
Square,
Terminal,
Trash2,
UserPlus,
Users,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -48,11 +56,14 @@ import { SendMessageDialog } from './dialogs/SendMessageDialog';
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { TrashDialog } from './kanban/TrashDialog';
import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList';
import { MessageComposer } from './messages/MessageComposer';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { ProcessesSection } from './ProcessesSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection';
@ -115,10 +126,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [stoppingTeam, setStoppingTeam] = useState(false);
const [trashOpen, setTrashOpen] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
undefined
);
const [reviewDialogState, setReviewDialogState] = useState<{
open: boolean;
mode: 'agent' | 'task';
memberName?: string;
taskId?: string;
initialFilePath?: string;
}>({ open: false, mode: 'task' });
// Active teams for conflict warning in LaunchTeamDialog
const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState<
@ -139,6 +158,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
loading,
error,
projects,
repositoryGroups,
teams,
selectTeam,
updateKanban,
@ -161,15 +181,21 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
launchTeam,
provisioningError,
isTeamProvisioning,
leadActivityByTeam,
refreshTeamData,
kanbanFilterQuery,
clearKanbanFilter,
softDeleteTask,
restoreTask,
fetchDeletedTasks,
deletedTasks,
} = useStore(
useShallow((s) => ({
data: s.selectedTeamData,
loading: s.selectedTeamLoading,
error: s.selectedTeamError,
projects: s.projects,
repositoryGroups: s.repositoryGroups,
teams: s.teams,
selectTeam: s.selectTeam,
updateKanban: s.updateKanban,
@ -194,9 +220,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
isTeamProvisioning: Object.values(s.provisioningRuns).some(
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
),
leadActivityByTeam: s.leadActivityByTeam,
refreshTeamData: s.refreshTeamData,
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
softDeleteTask: s.softDeleteTask,
restoreTask: s.restoreTask,
fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks,
}))
);
@ -213,7 +244,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return;
}
void selectTeam(teamName);
}, [teamName, selectTeam]);
void fetchDeletedTasks(teamName);
}, [teamName, selectTeam, fetchDeletedTasks]);
// Fetch active teams when launch dialog opens (for conflict warning)
useEffect(() => {
@ -249,10 +281,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}, [kanbanFilterQuery, clearKanbanFilter]);
// Load sessions for the team's project
const projectId = useMemo(() => {
if (!data?.config.projectPath) return null;
return projects.find((p) => p.path === data.config.projectPath)?.id ?? null;
}, [projects, data?.config.projectPath]);
const projectId = useMemo(
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
[projects, repositoryGroups, data?.config.projectPath]
);
useEffect(() => {
if (!projectId) return;
@ -486,6 +518,57 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}
}, [teamName, refreshTeamData]);
const selectReviewFile = useStore((s) => s.selectReviewFile);
const pendingReviewRequest = useStore((s) => s.pendingReviewRequest);
const setPendingReviewRequest = useStore((s) => s.setPendingReviewRequest);
// Pick up pending review request from GlobalTaskDetailDialog
useEffect(() => {
if (!pendingReviewRequest) return;
setReviewDialogState({
open: true,
mode: 'task',
taskId: pendingReviewRequest.taskId,
initialFilePath: pendingReviewRequest.filePath,
});
if (pendingReviewRequest.filePath) {
selectReviewFile(pendingReviewRequest.filePath);
}
setPendingReviewRequest(null);
}, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]);
const handleDeleteTask = useCallback(
(taskId: string) => {
void (async () => {
const confirmed = await confirm({
title: 'Delete task',
message: `Move task #${taskId} to trash?`,
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (confirmed) {
void softDeleteTask(teamName, taskId);
}
})();
},
[teamName, softDeleteTask]
);
const handleViewChanges = useCallback((taskId: string) => {
setReviewDialogState({ open: true, mode: 'task', taskId });
}, []);
const handleViewChangesForFile = useCallback(
(taskId: string, filePath?: string) => {
setReviewDialogState({ open: true, mode: 'task', taskId });
if (filePath) {
selectReviewFile(filePath);
}
},
[selectReviewFile]
);
const handleDeleteTeam = useCallback((): void => {
setDeleteConfirmOpen(true);
}, []);
@ -583,10 +666,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
);
}
const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : null;
const headerColorSet = data.config.color
? getTeamColorSet(data.config.color)
: nameColorSet(data.config.name);
return (
<div className="size-full overflow-auto p-4">
<div className="size-full overflow-auto p-4" data-team-name={teamName}>
<div
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
style={
@ -729,7 +814,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
{!data.isAlive && !isTeamProvisioning ? (
<div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<span className="text-xs text-amber-200">Team is offline</span>
<span className="flex items-center gap-1.5 text-xs text-amber-200">
<AlertTriangle size={14} className="shrink-0 text-amber-400" />
Team is offline
</span>
<Button
variant="ghost"
size="sm"
@ -756,7 +844,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
) : null}
<CollapsibleTeamSection
sectionId="team"
title="Team"
icon={<Users size={14} />}
badge={activeMembers.length}
defaultOpen
action={
@ -781,6 +871,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
pendingRepliesByMember={pendingRepliesByMember}
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]}
onMemberClick={setSelectedMember}
onSendMessage={(member) => {
setSendDialogRecipient(member.name);
@ -794,7 +885,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
/>
</CollapsibleTeamSection>
<CollapsibleTeamSection title="Sessions" defaultOpen={false}>
<CollapsibleTeamSection
sectionId="sessions"
title="Sessions"
icon={<History size={14} />}
defaultOpen={false}
>
<TeamSessionsSection
sessions={teamSessions}
sessionsLoading={sessionsLoading}
@ -807,7 +903,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</CollapsibleTeamSection>
<CollapsibleTeamSection
sectionId="kanban"
title="Kanban"
icon={<Columns3 size={14} />}
badge={filteredTasks.length}
defaultOpen
forceOpen={kanbanSearch.trim().length > 0}
@ -836,7 +934,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
members={activeMembers}
onFilterChange={setKanbanFilter}
toolbarLeft={
<div className="relative">
<div className="relative max-w-[240px]">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
@ -962,12 +1060,30 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}
}}
onTaskClick={(task) => setSelectedTask(task)}
onViewChanges={handleViewChanges}
onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)}
onDeleteTask={handleDeleteTask}
deletedTaskCount={deletedTasks.length}
onOpenTrash={() => setTrashOpen(true)}
/>
</CollapsibleTeamSection>
{(data.processes?.length ?? 0) > 0 && (
<CollapsibleTeamSection
sectionId="processes"
title="CLI Processes"
icon={<Terminal size={14} />}
badge={data.processes.filter((p) => !p.stoppedAt).length}
defaultOpen
>
<ProcessesSection />
</CollapsibleTeamSection>
)}
<CollapsibleTeamSection
sectionId="messages"
title="Messages"
icon={<MessageSquare size={14} />}
badge={filteredMessages.length}
secondaryBadge={
filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined
@ -975,9 +1091,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
headerExtra={
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="pointer-events-auto rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
void window.electronAPI.openExternal(
@ -986,7 +1103,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}}
>
<Bell size={12} />
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
@ -1078,6 +1195,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setSendDialogOpen(true);
}}
onMessageVisible={handleMessageVisible}
onTaskIdClick={(taskId) => {
const task = taskMap.get(taskId);
if (task) setSelectedTask(task);
}}
/>
</CollapsibleTeamSection>
@ -1113,6 +1234,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
messages={data.messages}
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]}
onClose={() => setSelectedMember(null)}
onSendMessage={() => {
const name = selectedMember?.name ?? '';
@ -1150,6 +1272,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
if (!name) return;
setRemoveMemberConfirm(name);
}}
onViewMemberChanges={(memberName, filePath) => {
setSelectedMember(null);
setReviewDialogState({
open: true,
mode: 'agent',
memberName,
initialFilePath: filePath,
});
}}
/>
<CreateTaskDialog
@ -1315,6 +1446,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onOwnerChange={(taskId, owner) => {
void updateTaskOwner(teamName, taskId, owner);
}}
onViewChanges={handleViewChangesForFile}
onDeleteTask={handleDeleteTask}
/>
<TrashDialog
open={trashOpen}
tasks={deletedTasks}
onClose={() => setTrashOpen(false)}
onRestore={(taskId) => {
void restoreTask(teamName, taskId);
}}
/>
<ChangeReviewDialog
open={reviewDialogState.open}
onOpenChange={(open) =>
setReviewDialogState((prev) => ({
...prev,
open,
...(open ? {} : { initialFilePath: undefined }),
}))
}
teamName={teamName}
mode={reviewDialogState.mode}
memberName={reviewDialogState.memberName}
taskId={reviewDialogState.taskId}
initialFilePath={reviewDialogState.initialFilePath}
/>
</div>
);

View file

@ -0,0 +1,185 @@
/* eslint-disable react-refresh/only-export-components -- TeamListFilterState and EMPTY_TEAM_FILTER shared with TeamListView */
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getBaseName } from '@renderer/utils/pathUtils';
import { Filter } from 'lucide-react';
import type { TeamSummary } from '@shared/types';
export interface TeamListFilterState {
selectedProjects: Set<string>;
selectedStatuses: Set<string>;
}
export const EMPTY_TEAM_FILTER: TeamListFilterState = {
selectedProjects: new Set(),
selectedStatuses: new Set(),
};
function folderName(fullPath: string): string {
return getBaseName(fullPath) || fullPath;
}
interface TeamListFilterPopoverProps {
filter: TeamListFilterState;
teams: TeamSummary[];
aliveTeams: string[];
onFilterChange: (filter: TeamListFilterState) => void;
}
export const TeamListFilterPopover = ({
filter,
teams,
aliveTeams,
onFilterChange,
}: TeamListFilterPopoverProps): React.JSX.Element => {
const activeCount = useMemo(() => {
let count = 0;
if (filter.selectedStatuses.size > 0) count += 1;
if (filter.selectedProjects.size > 0) count += 1;
return count;
}, [filter.selectedStatuses, filter.selectedProjects]);
const uniqueProjects = useMemo(() => {
const paths = new Set<string>();
for (const team of teams) {
if (team.projectPath?.trim()) paths.add(team.projectPath.trim());
}
return [...paths].sort((a, b) => folderName(a).localeCompare(folderName(b)));
}, [teams]);
const handleStatusToggle = (status: string): void => {
const next = new Set(filter.selectedStatuses);
if (next.has(status)) {
next.delete(status);
} else {
next.add(status);
}
onFilterChange({ ...filter, selectedStatuses: next });
};
const handleProjectToggle = (project: string): void => {
const next = new Set(filter.selectedProjects);
if (next.has(project)) {
next.delete(project);
} else {
next.add(project);
}
onFilterChange({ ...filter, selectedProjects: next });
};
const handleClearAll = (): void => {
onFilterChange(EMPTY_TEAM_FILTER);
};
const aliveSet = useMemo(() => new Set(aliveTeams), [aliveTeams]);
const runningCount = useMemo(
() => teams.filter((t) => aliveSet.has(t.teamName)).length,
[teams, aliveSet]
);
const offlineCount = useMemo(
() => teams.filter((t) => !aliveSet.has(t.teamName)).length,
[teams, aliveSet]
);
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-8 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter teams"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter teams</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0">
{/* Status section */}
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Status
</p>
<div className="space-y-1.5">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={filter.selectedStatuses.has('running')}
onCheckedChange={() => handleStatusToggle('running')}
/>
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" />
Running
<span className="text-[var(--color-text-muted)]">({runningCount})</span>
</span>
</label>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={filter.selectedStatuses.has('offline')}
onCheckedChange={() => handleStatusToggle('offline')}
/>
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-zinc-500" />
Offline
<span className="text-[var(--color-text-muted)]">({offlineCount})</span>
</span>
</label>
</div>
</div>
{/* Project section */}
{uniqueProjects.length > 0 && (
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Project
</p>
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{uniqueProjects.map((project) => (
<label
key={project}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
title={project}
>
<Checkbox
checked={filter.selectedProjects.has(project)}
onCheckedChange={() => handleProjectToggle(project)}
/>
<span className="truncate">{folderName(project)}</span>
</label>
))}
</div>
</div>
)}
{/* Footer */}
<div className="flex justify-end p-2">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={activeCount === 0}
onClick={handleClearAll}
>
Clear all
</Button>
</div>
</PopoverContent>
</Popover>
);
};
/* eslint-enable react-refresh/only-export-components -- pair for file-level disable */

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
@ -12,8 +13,10 @@ import {
} from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
CheckCircle,
Clock,
@ -21,6 +24,7 @@ import {
FolderOpen,
GitBranch,
Play,
RotateCcw,
Search,
Square,
Trash2,
@ -29,9 +33,16 @@ import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamCreateRequest, TeamProvisioningProgress, TeamSummary } from '@shared/types';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type {
TeamCreateRequest,
TeamProvisioningProgress,
TeamSummary,
TeamSummaryMember,
} from '@shared/types';
function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, '');
@ -44,7 +55,7 @@ function generateUniqueName(sourceName: string, existingNames: string[]): string
}
}
type TeamStatus = 'running' | 'provisioning' | 'offline';
type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline';
function getRecentProjects(team: TeamSummary): string[] {
const history = team.projectPathHistory;
@ -58,13 +69,69 @@ function folderName(fullPath: string): string {
return getBaseName(fullPath) || fullPath;
}
function renderMemberChips(members: TeamSummaryMember[]): React.JSX.Element {
const teamColorMap = buildMemberColorMap(members);
return (
<>
{members.map((m) => {
const resolvedColor = teamColorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span>
{m.role ? (
<span className="text-[9px] text-[var(--color-text-muted)]">{m.role}</span>
) : null}
</span>
);
})}
</>
);
}
function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX.Element | null {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && (status === 'active' || status === 'idle') ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
}
function resolveTeamStatus(
teamName: string,
aliveTeams: string[],
provisioningRuns: Record<string, TeamProvisioningProgress>
provisioningRuns: Record<string, TeamProvisioningProgress>,
leadActivityByTeam: Record<string, string>
): TeamStatus {
if (aliveTeams.includes(teamName)) {
return 'running';
return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle';
}
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
for (const run of Object.values(provisioningRuns)) {
@ -77,10 +144,17 @@ function resolveTeamStatus(
const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
switch (status) {
case 'running':
case 'active':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
Active
</span>
);
case 'idle':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-400" />
Running
</span>
);
@ -106,6 +180,7 @@ export const TeamListView = (): React.JSX.Element => {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
const {
@ -131,6 +206,8 @@ export const TeamListView = (): React.JSX.Element => {
fetchTeams: s.fetchTeams,
openTeamTab: s.openTeamTab,
deleteTeam: s.deleteTeam,
restoreTeam: s.restoreTeam,
permanentlyDeleteTeam: s.permanentlyDeleteTeam,
projects: s.projects,
globalTasks: s.globalTasks,
fetchAllTasks: s.fetchAllTasks,
@ -141,14 +218,16 @@ export const TeamListView = (): React.JSX.Element => {
activeProjectId: s.activeProjectId,
}))
);
const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
provisioningError: s.provisioningError,
provisioningRuns: s.provisioningRuns,
}))
);
const { connectionMode, createTeam, provisioningError, provisioningRuns, leadActivityByTeam } =
useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
provisioningError: s.provisioningError,
provisioningRuns: s.provisioningRuns,
leadActivityByTeam: s.leadActivityByTeam,
}))
);
const canCreate = electronMode && connectionMode === 'local';
// Fetch alive teams on mount and when teams list changes
@ -200,20 +279,63 @@ export const TeamListView = (): React.JSX.Element => {
);
}
if (currentProjectPath) {
const matches = (t: TeamSummary): boolean => {
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false;
};
result = [...result].sort((a, b) => {
const aMatch = matches(a) ? 0 : 1;
const bMatch = matches(b) ? 0 : 1;
return aMatch - bMatch;
if (filter.selectedStatuses.size > 0) {
result = result.filter((t) => {
const status = resolveTeamStatus(
t.teamName,
aliveTeams,
provisioningRuns,
leadActivityByTeam
);
const isRunning = status !== 'offline';
if (filter.selectedStatuses.has('running') && isRunning) return true;
if (filter.selectedStatuses.has('offline') && !isRunning) return true;
return false;
});
}
if (filter.selectedProjects.size > 0) {
result = result.filter(
(t) => t.projectPath != null && filter.selectedProjects.has(t.projectPath.trim())
);
}
const aliveSet = new Set(aliveTeams);
const matchesProject = currentProjectPath
? (t: TeamSummary): boolean => {
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
return (
t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false
);
}
: null;
result = [...result].sort((a, b) => {
// 1. Alive (running) teams first
const aliveA = aliveSet.has(a.teamName) ? 0 : 1;
const aliveB = aliveSet.has(b.teamName) ? 0 : 1;
if (aliveA !== aliveB) return aliveA - aliveB;
// 2. Matching current project second
if (matchesProject) {
const projA = matchesProject(a) ? 0 : 1;
const projB = matchesProject(b) ? 0 : 1;
if (projA !== projB) return projA - projB;
}
return 0;
});
return result;
}, [teams, searchQuery, currentProjectPath]);
}, [
teams,
searchQuery,
currentProjectPath,
aliveTeams,
filter,
provisioningRuns,
leadActivityByTeam,
]);
// Live branch/worktree for team project paths (poll so it updates during process)
const projectPathsToPoll = useMemo(() => {
@ -258,18 +380,55 @@ export const TeamListView = (): React.JSX.Element => {
};
}, [electronMode, projectPathsToPoll]);
const restoreTeam = useStore((s) => s.restoreTeam);
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm(`Delete team "${teamName}"? This action is irreversible.`);
if (!confirmed) {
return;
}
void deleteTeam(teamName);
void (async () => {
const confirmed = await confirm({
title: 'Move to trash',
message: `Move team "${teamName}" to trash? You can restore it later.`,
confirmLabel: 'Move to trash',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (confirmed) {
void deleteTeam(teamName);
}
})();
},
[deleteTeam]
);
const handleRestoreTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
void restoreTeam(teamName);
},
[restoreTeam]
);
const handlePermanentlyDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
void (async () => {
const confirmed = await confirm({
title: 'Delete permanently',
message: `Delete team "${teamName}" permanently? All data will be lost.`,
confirmLabel: 'Delete forever',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (confirmed) {
void permanentlyDeleteTeam(teamName);
}
})();
},
[permanentlyDeleteTeam]
);
const handleCopyTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
@ -410,39 +569,42 @@ export const TeamListView = (): React.JSX.Element => {
) : null}
{teams.length > 0 ? (
<div className="relative mt-3">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
<div className="mt-3 flex items-center gap-2">
<div className="relative flex-1">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<TeamListFilterPopover
filter={filter}
teams={teams}
aliveTeams={aliveTeams}
onFilterChange={setFilter}
/>
</div>
) : null}
</div>
);
if (teamsLoading) {
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
const renderContent = (): React.JSX.Element => {
if (teamsLoading) {
return (
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Loading teams...
</div>
{createDialogElement}
</div>
);
}
);
}
if (teamsError) {
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
if (teamsError) {
return (
<div className="flex size-full items-center justify-center p-6">
<div className="text-center">
<p className="text-sm font-medium text-red-400">Failed to load teams</p>
@ -459,259 +621,295 @@ export const TeamListView = (): React.JSX.Element => {
</Button>
</div>
</div>
{createDialogElement}
</div>
);
}
);
}
if (teams.length === 0) {
return <TeamEmptyState />;
}
const hasActiveFilters = filter.selectedStatuses.size > 0 || filter.selectedProjects.size > 0;
if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) {
return (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching current filters
</div>
);
}
const activeFiltered = filteredTeams.filter((t) => !t.deletedAt);
const deletedFiltered = filteredTeams.filter((t) => t.deletedAt);
if (teams.length === 0) {
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
<TeamEmptyState />
{createDialogElement}
</div>
);
}
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{activeFiltered.map((team) => {
const status = resolveTeamStatus(
team.teamName,
aliveTeams,
provisioningRuns,
leadActivityByTeam
);
const teamColorSet = team.color
? getTeamColorSet(team.color)
: nameColorSet(team.displayName);
const matchesCurrentProject =
!!currentProjectPath &&
((team.projectPath
? normalizePath(team.projectPath) === currentProjectPath
: false) ||
(team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
false));
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
}
}}
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
{(status === 'active' || status === 'idle') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
>
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
<div className="mt-2 flex min-h-10 items-start gap-2">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
if (!branch) return null;
return (
<span
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branch}
>
<GitBranch size={10} />
<span className="max-w-24 truncate">{branch}</span>
</span>
);
})()}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
renderMemberChips(team.members)
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return (
<div className="mt-2 w-full space-y-1.5">
<div className="flex items-center gap-2">
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
);
})()}
</div>
{renderTeamRecentPaths(team, status)}
</div>
</div>
);
})}
</div>
return (
<TooltipProvider delayDuration={300}>
<div className="size-full overflow-auto p-4">
{renderHeader()}
{filteredTeams.length === 0 && searchQuery.trim() ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot;
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
const matchesCurrentProject =
!!currentProjectPath &&
(() => {
if (team.projectPath && normalizePath(team.projectPath) === currentProjectPath)
return true;
return (
team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
false
);
})();
return (
{deletedFiltered.length > 0 && (
<>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-[var(--color-border)]" />
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Trash ({deletedFiltered.length})
</span>
<div className="h-px flex-1 bg-[var(--color-border)]" />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{deletedFiltered.map((team) => (
<div
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
}
}}
className="group relative cursor-default overflow-hidden rounded-lg border border-[var(--color-border)] bg-zinc-800/40 p-4 opacity-60"
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<Trash2
size={64}
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-zinc-400 opacity-[0.06]"
/>
<div className="relative z-10">
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/15 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
Deleted
</span>
</div>
<div className="flex shrink-0 gap-1">
{status === 'running' && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 group-hover:opacity-100"
onClick={(e) => handleRestoreTeam(team.teamName, e)}
aria-label="Restore team"
>
<Copy size={14} />
<RotateCcw size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</TooltipContent>
<TooltipContent side="bottom">Restore</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
onClick={(e) => handlePermanentlyDeleteTeam(team.teamName, e)}
aria-label="Delete permanently"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
<TooltipContent side="bottom">Delete forever</TooltipContent>
</Tooltip>
</div>
</div>
<div className="mt-2 flex min-h-10 items-start gap-2">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
if (!branch) return null;
return (
<span
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branch}
>
<GitBranch size={10} />
<span className="max-w-24 truncate">{branch}</span>
</span>
);
})()}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span>
{m.role ? (
<span className="text-[9px] text-[var(--color-text-muted)]">
{m.role}
</span>
) : null}
</span>
);
})
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return (
<div className="mt-2 w-full space-y-1.5">
<div className="flex items-center gap-2">
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
);
})()}
</div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && status === 'running' ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
})()}
<p className="mt-2 line-clamp-2 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
{team.members && team.members.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{renderMemberChips(team.members)}
</div>
)}
</div>
</div>
);
})}
</div>
))}
</div>
</>
)}
</>
);
};
return (
<TooltipProvider delayDuration={300}>
<div className="size-full overflow-auto p-4">
{renderHeader()}
{renderContent()}
{createDialogElement}
</div>
</TooltipProvider>

View file

@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import { formatDistanceToNowStrict } from 'date-fns';
import {
AlertCircle,
@ -36,18 +37,19 @@ export const TeamSessionsSection = ({
onSelectSession,
projectPath,
}: TeamSessionsSectionProps): React.JSX.Element => {
const { openTab, selectSession, projects } = useStore(
const { openTab, selectSession, projects, repositoryGroups } = useStore(
useShallow((s) => ({
openTab: s.openTab,
selectSession: s.selectSession,
projects: s.projects,
repositoryGroups: s.repositoryGroups,
}))
);
const projectId = useMemo(() => {
if (!projectPath) return null;
return projects.find((p) => p.path === projectPath)?.id ?? null;
}, [projects, projectPath]);
const projectId = useMemo(
() => resolveProjectIdByPath(projectPath, projects, repositoryGroups),
[projects, repositoryGroups, projectPath]
);
// Sort: lead session first, then by most recent
const sortedSessions = useMemo(() => {

View file

@ -1,6 +1,7 @@
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Loader2 } from 'lucide-react';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -18,6 +19,7 @@ export const ActiveTasksBlock = ({
onMemberClick,
onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null => {
const colorMap = buildMemberColorMap(members);
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const working = members.filter((m) => m.currentTaskId != null);
if (working.length === 0) return null;
@ -30,7 +32,7 @@ export const ActiveTasksBlock = ({
{working.map((member) => {
const taskId = member.currentTaskId!;
const task = taskMap.get(taskId);
const colors = getTeamColorSet(member.color ?? '');
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);
@ -84,32 +86,27 @@ export const ActiveTasksBlock = ({
{roleLabel}
</span>
) : null}
<span
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
working on
</span>
{task &&
(onTaskClick ? (
<button
type="button"
className="truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${colors.border}40` }}
onClick={() => onTaskClick(task)}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
#{task.id} {task.subject}
</button>
) : (
<span
className="truncate px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)]"
className="min-w-0 flex-1 truncate px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)]"
style={{ border: `1px solid ${colors.border}40` }}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
#{task.id} {task.subject}
</span>
))}
</div>

View file

@ -41,6 +41,8 @@ interface ActivityItemProps {
onMemberNameClick?: (memberName: string) => void;
onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void;
/** Called when a task ID link (e.g. #10) is clicked in message text. */
onTaskIdClick?: (taskId: string) => void;
}
function getStringField(obj: StructuredMessage, key: string): string | null {
@ -125,6 +127,33 @@ function getSystemMessageLabel(text: string): string | null {
// Full message card — left colored border, name badge, collapsible content
// ---------------------------------------------------------------------------
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
function linkifyTaskIdsInMarkdown(text: string): string {
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
}
/** Render `#<digits>` in plain text as clickable inline elements. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#\d+)/g).map((part, i) => {
const match = /^#(\d+)$/.exec(part);
if (!match) return <span key={i}>{part}</span>;
const taskId = match[1];
return (
<button
key={i}
type="button"
className="cursor-pointer font-medium text-blue-400 hover:underline"
onClick={(e) => {
e.stopPropagation();
onClick(taskId);
}}
>
{part}
</button>
);
});
}
export const ActivityItem = ({
message,
teamName,
@ -135,6 +164,7 @@ export const ActivityItem = ({
onMemberNameClick,
onCreateTask,
onReply,
onTaskIdClick,
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const formattedRole = formatAgentRole(memberRole);
@ -153,11 +183,13 @@ export const ActivityItem = ({
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text
const displayText = useMemo(
() => (structured ? null : stripAgentBlocks(message.text)),
[structured, message.text]
);
// Strip agent-only blocks from displayed text + linkify task IDs
const displayText = useMemo(() => {
if (structured) return null;
const stripped = stripAgentBlocks(message.text).trim();
if (!stripped) return null; // All content was agent-only blocks → show summary instead
return onTaskIdClick ? linkifyTaskIdsInMarkdown(stripped) : stripped;
}, [structured, message.text, onTaskIdClick]);
// Check if this is a reply message
const parsedReply = useMemo(
@ -180,7 +212,9 @@ export const ActivityItem = ({
const handleCreateTask = (): void => {
const subject = message.summary || autoSummary || `Task from ${message.from}`;
const plainText = structured ? JSON.stringify(structured, null, 2) : message.text;
const plainText = structured
? JSON.stringify(structured, null, 2)
: stripAgentBlocks(message.text);
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
onCreateTask?.(subject, description);
};
@ -289,7 +323,7 @@ export const ActivityItem = ({
{/* Summary */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText}
{onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText}
</span>
{/* Timestamp + reply + create task */}
@ -355,9 +389,31 @@ export const ActivityItem = ({
</div>
) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} />
) : (
<MarkdownViewer content={displayText ?? ''} maxHeight="max-h-56" copyable bare />
)}
) : displayText ? (
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'a[href^="task://"]'
);
if (link) {
e.preventDefault();
e.stopPropagation();
const taskId = link.getAttribute('href')?.replace('task://', '');
if (taskId) onTaskIdClick(taskId);
}
}
: undefined
}
>
<MarkdownViewer content={displayText} maxHeight="max-h-56" copyable bare />
</span>
) : summaryText ? (
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText}
</p>
) : null}
{message.attachments?.length && message.messageId ? (
<AttachmentDisplay
teamName={teamName}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem } from './ActivityItem';
@ -20,9 +20,12 @@ interface ActivityTimelineProps {
onMemberClick?: (member: ResolvedTeamMember) => void;
/** Called when a message enters the viewport (for marking as read). */
onMessageVisible?: (message: InboxMessage) => void;
/** Called when a task ID link (e.g. #10) is clicked in message text. */
onTaskIdClick?: (taskId: string) => void;
}
const VIEWPORT_THRESHOLD = 0.15;
const MESSAGES_PAGE_SIZE = 30;
const MessageRowWithObserver = ({
message,
@ -35,6 +38,7 @@ const MessageRowWithObserver = ({
onCreateTask,
onReply,
onVisible,
onTaskIdClick,
}: {
message: InboxMessage;
teamName: string;
@ -46,6 +50,7 @@ const MessageRowWithObserver = ({
onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void;
onVisible?: (message: InboxMessage) => void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false);
@ -89,6 +94,7 @@ const MessageRowWithObserver = ({
onMemberNameClick={onMemberNameClick}
onCreateTask={onCreateTask}
onReply={onReply}
onTaskIdClick={onTaskIdClick}
/>
</div>
);
@ -103,36 +109,36 @@ export const ActivityTimeline = ({
onReplyToMessage,
onMemberClick,
onMessageVisible,
onTaskIdClick,
}: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
// Track whether the user was seeing ALL messages (no hidden ones).
// If so, auto-expand when new messages push count past the limit,
// so previously visible messages don't silently disappear.
const wasShowingAllRef = useRef(messages.length <= MESSAGES_PAGE_SIZE);
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const memberInfo = new Map<string, { role?: string; color?: string }>();
if (members) {
for (const m of members) {
const info = {
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
color: m.color,
color: colorMap.get(m.name),
};
memberInfo.set(m.name, info);
if (m.agentType && m.agentType !== m.name) {
memberInfo.set(m.agentType, info);
}
}
// Map "user" to team-lead's resolved color and role
const leadMember = members.find(
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
);
if (leadMember) {
const leadInfo = memberInfo.get(leadMember.name);
if (leadInfo) {
const teamLeadColor = leadInfo.color ?? getMemberColorByName('team-lead');
const resolvedLeadInfo = { role: leadInfo.role, color: teamLeadColor };
memberInfo.set('team-lead', resolvedLeadInfo);
memberInfo.set(leadMember.name, resolvedLeadInfo);
if (
leadMember.agentType &&
leadMember.agentType !== 'team-lead' &&
leadMember.agentType !== leadMember.name
) {
memberInfo.set(leadMember.agentType, resolvedLeadInfo);
}
memberInfo.set('user', { role: leadInfo.role, color: colorMap.get('user') });
}
}
}
@ -142,6 +148,31 @@ export const ActivityTimeline = ({
if (member) onMemberClick?.(member);
};
const hiddenCount = Math.max(0, messages.length - visibleCount);
// Auto-expand when user was seeing all and new messages arrive — derived state sync.
// Reading/updating ref during render is intentional (React docs: derived state sync).
/* eslint-disable react-hooks/refs -- ref stores previous frame's "showing all" for derived state sync */
const wasShowingAll = wasShowingAllRef.current;
if (wasShowingAll && hiddenCount > 0) {
setVisibleCount(messages.length);
}
wasShowingAllRef.current = hiddenCount === 0;
/* eslint-enable react-hooks/refs -- end of intentional ref access during render */
const visibleMessages = useMemo(
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
[messages, visibleCount, hiddenCount]
);
const handleShowMore = (): void => {
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
};
const handleShowAll = (): void => {
setVisibleCount(Infinity);
};
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
@ -153,12 +184,13 @@ export const ActivityTimeline = ({
return (
<div className="space-y-1">
{messages.slice(0, 200).map((message, index) => {
{visibleMessages.map((message, index) => {
const info = memberInfo.get(message.from);
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor =
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
const globalIndex = index;
const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`;
const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read;
@ -175,9 +207,54 @@ export const ActivityTimeline = ({
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
/>
);
})}
{hiddenCount > 0 && (
<div className="relative flex justify-center pb-3 pt-1">
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}
<div
className="pointer-events-none absolute inset-x-0 -top-24"
style={{
bottom: '-1.6rem',
background:
'linear-gradient(to top, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.25) 25%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.03) 75%, transparent 100%)',
}}
/>
<div
className="relative z-[1] flex items-center gap-3 rounded-full px-4 py-1.5"
style={{
backgroundColor: 'var(--color-surface-raised)',
boxShadow:
'0 0 12px 4px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.04)',
border: '1px solid var(--color-border-emphasis)',
}}
>
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">
+{hiddenCount} older
</span>
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
<button
onClick={handleShowMore}
className="rounded-full px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text)]"
>
Show {Math.min(MESSAGES_PAGE_SIZE, hiddenCount)} more
</button>
{hiddenCount > MESSAGES_PAGE_SIZE && (
<>
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
<button
onClick={handleShowAll}
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
>
Show all
</button>
</>
)}
</div>
</div>
)}
</div>
);
};

View file

@ -1,6 +1,7 @@
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { formatDistanceToNowStrict } from 'date-fns';
import { Loader2 } from 'lucide-react';
@ -17,6 +18,7 @@ export const PendingRepliesBlock = ({
pendingRepliesByMember,
onMemberClick,
}: PendingRepliesBlockProps): React.JSX.Element | null => {
const colorMap = buildMemberColorMap(members);
const pending = Object.entries(pendingRepliesByMember)
.map(([name, sentAtMs]) => ({
member: members.find((m) => m.name === name) ?? null,
@ -34,7 +36,7 @@ export const PendingRepliesBlock = ({
Awaiting replies
</p>
{pending.map(({ member, sentAtMs }) => {
const colors = getTeamColorSet(member.color ?? '');
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);

View file

@ -18,12 +18,9 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { Loader2 } from 'lucide-react';
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
const CUSTOM_ROLE = '__custom__';
const NO_ROLE = '__none__';
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
interface AddMemberDialogProps {
@ -113,7 +110,7 @@ export const AddMemberDialog = ({
</div>
<div className="space-y-2">
<Label>Role (optional)</Label>
<Label className="label-optional">Role (optional)</Label>
<Select value={roleSelect} onValueChange={setRoleSelect}>
<SelectTrigger>
<SelectValue placeholder="No role" />

View file

@ -24,16 +24,17 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertTriangle, Search } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface CreateTaskDialogProps {
open: boolean;
teamName: string;
members: ResolvedTeamMember[];
tasks: TeamTask[];
tasks: TeamTaskWithKanban[];
isTeamAlive?: boolean;
defaultSubject?: string;
defaultDescription?: string;
@ -66,6 +67,7 @@ export const CreateTaskDialog = ({
onSubmit,
submitting = false,
}: CreateTaskDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const [subject, setSubject] = useState(defaultSubject);
const descriptionDraft = useDraftPersistence({
key: `createTask:${teamName}:description`,
@ -103,16 +105,18 @@ export const CreateTaskDialog = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const requiresOwner = defaultStartImmediately === true;
const canSubmit = subject.trim().length > 0 && !submitting && (!requiresOwner || !!owner);
// Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter((t) => t.status !== 'deleted');
const availableTasks = tasks.filter(
(t) => t.status !== 'deleted' && t.kanbanColumn !== 'approved'
);
const toggleBlockedBy = (taskId: string): void => {
setBlockedBy((prev) =>
@ -149,7 +153,9 @@ export const CreateTaskDialog = ({
const assigneeField = (
<div className="grid gap-2">
<Label>{requiresOwner ? 'Assignee' : 'Assignee (optional)'}</Label>
<Label className={requiresOwner ? undefined : 'label-optional'}>
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
</Label>
<Select
value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
@ -161,7 +167,8 @@ export const CreateTaskDialog = ({
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
@ -223,7 +230,9 @@ export const CreateTaskDialog = ({
{assigneeField}
<div className="grid gap-2">
<Label htmlFor="task-description">Description (optional)</Label>
<Label htmlFor="task-description" className="label-optional">
Description (optional)
</Label>
<MentionableTextarea
id="task-description"
placeholder="Task details..."
@ -241,7 +250,9 @@ export const CreateTaskDialog = ({
</div>
<div className="grid gap-2">
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label>
<Label htmlFor="task-prompt" className="label-optional">
Prompt for assignee (optional)
</Label>
<MentionableTextarea
id="task-prompt"
placeholder="Custom instructions for the team member..."
@ -284,7 +295,7 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label>Blocked by tasks (optional)</Label>
<Label className="label-optional">Blocked by tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
@ -353,7 +364,7 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label>Related tasks (optional)</Label>
<Label className="label-optional">Related tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">

View file

@ -4,7 +4,6 @@ import { api } from '@renderer/api';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
import {
Dialog,
DialogContent,
@ -28,7 +27,10 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors';
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { MembersJsonEditor } from './MembersJsonEditor';
import { ProjectPathSelector } from './ProjectPathSelector';
const TEAM_COLOR_NAMES = [
'blue',
@ -84,9 +86,7 @@ interface ValidationResult {
};
}
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
const CUSTOM_ROLE = '__custom__';
const NO_ROLE = '__none__';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
const DEV_DEFAULT_TEAM = {
teamName: 'team-alpha',
description: 'Dev test team for provisioning flow',
@ -119,39 +119,6 @@ function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
};
}
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) => {
@ -271,6 +238,9 @@ export const CreateTeamDialog = ({
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const resetUIState = (): void => {
setLocalError(null);
@ -292,6 +262,9 @@ export const CreateTeamDialog = ({
setCustomCwd('');
setLaunchTeam(true);
setSelectedModel('');
setJsonEditorOpen(false);
setJsonText('');
setJsonError(null);
resetUIState();
};
@ -448,6 +421,60 @@ export const CreateTeamDialog = ({
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const membersToJsonText = (drafts: MemberDraft[]): string => {
const arr = drafts
.filter((d) => d.name.trim())
.map((d) => {
const role =
d.roleSelection === CUSTOM_ROLE
? d.customRole.trim() || undefined
: d.roleSelection === NO_ROLE
? undefined
: d.roleSelection.trim() || undefined;
return role ? { name: d.name.trim(), role } : { name: d.name.trim() };
});
return JSON.stringify(arr, null, 2);
};
const handleJsonChange = (text: string): void => {
setJsonText(text);
try {
const arr: unknown = JSON.parse(text);
if (!Array.isArray(arr)) {
setJsonError('Root must be an array');
return;
}
const drafts: MemberDraft[] = (arr as Record<string, unknown>[]).map((item) => {
const name = typeof item.name === 'string' ? item.name : '';
const role = typeof item.role === 'string' ? item.role.trim() : '';
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role);
return createMemberDraft({
name,
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
});
});
setMembers(drafts);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
}
};
const toggleJsonEditor = (): void => {
if (!jsonEditorOpen) {
setJsonText(membersToJsonText(members));
setJsonError(null);
}
setJsonEditorOpen((prev) => !prev);
};
useEffect(() => {
if (!jsonEditorOpen || jsonError !== null) return;
setJsonText(membersToJsonText(members));
}, [members, jsonEditorOpen, jsonError]);
const description = descriptionDraft.value;
const prompt = promptDraft.value;
@ -655,9 +682,7 @@ export const CreateTeamDialog = ({
<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>
<Label htmlFor="team-name">Team name</Label>
<Input
id="team-name"
className="h-8 text-xs"
@ -673,56 +698,23 @@ export const CreateTeamDialog = ({
</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>
<AutoResizeTextarea
id="team-description"
className="text-xs"
minRows={2}
maxRows={8}
value={description}
onChange={(event) => descriptionDraft.setValue(event.target.value)}
placeholder="Brief description of the team purpose"
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">color (optional)</Label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = teamColor === colorName;
return (
<button
key={colorName}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)}
style={{
backgroundColor: colorSet.badge,
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setTeamColor(isSelected ? '' : colorName)}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
<div className="flex items-center justify-between">
<Label>Members</Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setMembers((prev) => [...prev, createMemberDraft()]);
}}
>
Add member
</Button>
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
{jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
</Button>
</div>
</div>
</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) => {
const memberColorSet = getTeamColorSet(getMemberColor(index));
@ -795,183 +787,150 @@ export const CreateTeamDialog = ({
</div>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => {
setMembers((prev) => [...prev, createMemberDraft()]);
}}
>
Add member
</Button>
{jsonEditorOpen ? (
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
) : null}
</div>
{fieldErrors.members ? (
<p className="text-[11px] text-red-300">{fieldErrors.members}</p>
) : null}
</div>
<div className="flex items-center gap-2 md:col-span-2">
<Checkbox
id="launch-team"
checked={launchTeam}
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
/>
<Label
htmlFor="launch-team"
className="cursor-pointer text-xs text-[var(--color-text)]"
>
Launch team
</Label>
</div>
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt for team lead (optional)
</Label>
<MentionableTextarea
id="team-prompt"
className="text-xs"
minRows={3}
maxRows={12}
value={prompt}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
placeholder="Instructions for the team lead during provisioning..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4 md:col-span-2">
<div className="flex items-center gap-2">
<Checkbox
id="launch-team"
checked={launchTeam}
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
/>
<Label htmlFor="launch-team" className="cursor-pointer">
Launch team
</Label>
</div>
) : null}
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{launchTeam ? (
<div className="mt-4 space-y-4">
<ProjectPathSelector
cwdMode={cwdMode}
onCwdModeChange={setCwdMode}
selectedProjectPath={selectedProjectPath}
onSelectedProjectPathChange={setSelectedProjectPath}
customCwd={customCwd}
onCustomCwdChange={setCustomCwd}
projects={projects}
projectsLoading={projectsLoading}
projectsError={projectsError}
fieldError={fieldErrors.cwd}
/>
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">Project</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 className="space-y-1.5">
<Label htmlFor="team-prompt" className="label-optional">
Prompt for team lead (optional)
</Label>
<MentionableTextarea
id="team-prompt"
className="text-xs"
minRows={3}
maxRows={12}
value={prompt}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
placeholder="Instructions for the team lead during provisioning..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">
Draft saved
</span>
) : null
}
/>
</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 className="space-y-1.5">
<Label className="label-optional">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
{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>
</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>
) : 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}
</div>
{fieldErrors.cwd ? (
<p className="text-[11px] text-red-300">{fieldErrors.cwd}</p>
) : null}
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-description" className="label-optional">
Description (optional)
</Label>
<AutoResizeTextarea
id="team-description"
className="text-xs"
minRows={2}
maxRows={8}
value={description}
onChange={(event) => descriptionDraft.setValue(event.target.value)}
placeholder="Brief description of the team purpose"
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="label-optional">Color (optional)</Label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = teamColor === colorName;
return (
<button
key={colorName}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)}
style={{
backgroundColor: colorSet.badge,
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setTeamColor(isSelected ? '' : colorName)}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
</div>
) : null}
</div>
</div>
{activeError ? (
@ -980,27 +939,6 @@ export const CreateTeamDialog = ({
</p>
) : null}
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<span className="text-[var(--color-text-muted)]">&middot;</span>
<span>Team provisioning via local Claude CLI.</span>
</div>
) : null}
{canCreate && launchTeam && 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">
{canOpenExistingTeam ? (
<Button

View file

@ -129,7 +129,7 @@ export const EditTeamDialog = ({
</div>
<div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
<label className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]">
<label className="label-optional mb-1 block text-xs font-medium">
Color (optional)
</label>
<div className="flex flex-wrap gap-2">

View file

@ -0,0 +1,92 @@
import { useCallback, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { TaskDetailDialog } from './TaskDetailDialog';
import type { TeamTaskWithKanban } from '@shared/types';
/**
* Global wrapper around TaskDetailDialog.
* Mounted at layout level so it can be opened from anywhere (e.g. sidebar)
* without navigating to the team page first.
*/
export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
const {
globalTaskDetail,
closeGlobalTaskDetail,
selectedTeamData,
selectedTeamLoading,
openTeamTab,
setPendingReviewRequest,
} = useStore(
useShallow((s) => ({
globalTaskDetail: s.globalTaskDetail,
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
selectedTeamData: s.selectedTeamData,
selectedTeamLoading: s.selectedTeamLoading,
openTeamTab: s.openTeamTab,
setPendingReviewRequest: s.setPendingReviewRequest,
}))
);
const taskMap = useMemo(() => {
const map = new Map<string, TeamTaskWithKanban>();
if (!selectedTeamData) return map;
for (const t of selectedTeamData.tasks) map.set(t.id, t);
return map;
}, [selectedTeamData]);
const activeMembers = useMemo(
() => selectedTeamData?.members.filter((m) => !m.removedAt) ?? [],
[selectedTeamData]
);
const teamName = globalTaskDetail?.teamName ?? '';
const taskId = globalTaskDetail?.taskId ?? '';
const handleOpenTeam = useCallback((): void => {
closeGlobalTaskDetail();
openTeamTab(teamName, undefined, taskId);
}, [closeGlobalTaskDetail, openTeamTab, teamName, taskId]);
const handleViewChanges = useCallback(
(viewTaskId: string, filePath?: string) => {
setPendingReviewRequest({ taskId: viewTaskId, filePath });
closeGlobalTaskDetail();
openTeamTab(teamName);
},
[closeGlobalTaskDetail, openTeamTab, setPendingReviewRequest, teamName]
);
if (!globalTaskDetail) return null;
const task = taskMap.get(taskId) ?? null;
const kanbanTaskState = selectedTeamData?.kanbanState.tasks[taskId];
return (
<TaskDetailDialog
open
task={selectedTeamLoading ? null : task}
teamName={teamName}
kanbanTaskState={kanbanTaskState}
taskMap={taskMap}
members={activeMembers}
onClose={closeGlobalTaskDetail}
onOwnerChange={undefined}
onViewChanges={handleViewChanges}
headerExtra={
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={handleOpenTeam}
>
<ExternalLink size={12} />
Open team
</button>
}
/>
);
};

View file

@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Combobox } from '@renderer/components/ui/combobox';
import {
Dialog,
DialogContent,
@ -11,7 +10,6 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
@ -22,11 +20,13 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { ProjectPathSelector } from './ProjectPathSelector';
import type { ActiveTeamRef } from './CreateTeamDialog';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -48,39 +48,6 @@ interface LaunchTeamDialogProps {
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
}
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>
);
}
export const LaunchTeamDialog = ({
open,
teamName,
@ -248,15 +215,16 @@ export const LaunchTeamDialog = ({
);
}, [activeTeams, effectiveCwd, teamName]);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const activeError = localError ?? provisioningError;
@ -354,110 +322,21 @@ export const LaunchTeamDialog = ({
) : null}
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs text-[var(--color-text-muted)]">Project</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>
</div>
)}
</div>
</div>
<ProjectPathSelector
cwdMode={cwdMode}
onCwdModeChange={setCwdMode}
selectedProjectPath={selectedProjectPath}
onSelectedProjectPathChange={setSelectedProjectPath}
customCwd={customCwd}
onCustomCwdChange={setCustomCwd}
projects={projects}
projectsLoading={projectsLoading}
projectsError={projectsError}
/>
<div className="space-y-1.5">
<Label htmlFor="launch-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt (optional)
<Label htmlFor="launch-prompt" className="label-optional">
Prompt for team lead (optional)
</Label>
<MentionableTextarea
id="launch-prompt"
@ -477,7 +356,7 @@ export const LaunchTeamDialog = ({
</div>
<div className="space-y-1.5">
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
<Label className="label-optional">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />

View file

@ -0,0 +1,96 @@
import React, { useEffect, useRef } from 'react';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
interface MembersJsonEditorProps {
value: string;
onChange: (json: string) => void;
error: string | null;
}
export const MembersJsonEditor = ({
value,
onChange,
error,
}: MembersJsonEditorProps): React.JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!containerRef.current) return;
const state = EditorState.create({
doc: value,
extensions: [
json(),
oneDark,
lineNumbers(),
history(),
bracketMatching(),
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.theme({
'&': {
fontSize: '12px',
maxHeight: '300px',
},
'.cm-scroller': {
overflow: 'auto',
},
'.cm-content': {
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
},
}),
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- EditorView created once on mount
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const currentDoc = view.state.doc.toString();
if (currentDoc !== value) {
view.dispatch({
changes: { from: 0, to: currentDoc.length, insert: value },
});
}
}, [value]);
return (
<div className="space-y-1">
<div
ref={containerRef}
className="overflow-hidden rounded border border-[var(--color-border)]"
/>
{error ? <p className="text-[11px] text-red-300">{error}</p> : null}
</div>
);
};

View file

@ -0,0 +1,176 @@
import React from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
import { Check } from 'lucide-react';
import type { Project } from '@shared/types';
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>
);
}
export type CwdMode = 'project' | 'custom';
interface ProjectPathSelectorProps {
cwdMode: CwdMode;
onCwdModeChange: (mode: CwdMode) => void;
selectedProjectPath: string;
onSelectedProjectPathChange: (path: string) => void;
customCwd: string;
onCustomCwdChange: (cwd: string) => void;
projects: Project[];
projectsLoading: boolean;
projectsError: string | null;
fieldError?: string | null;
}
export const ProjectPathSelector = ({
cwdMode,
onCwdModeChange,
selectedProjectPath,
onSelectedProjectPathChange,
customCwd,
onCustomCwdChange,
projects,
projectsLoading,
projectsError,
fieldError,
}: ProjectPathSelectorProps): React.JSX.Element => (
<div className="space-y-1.5">
<Label>Project</Label>
<div className="space-y-2">
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'project'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('project')}
>
From project list
</button>
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'custom'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('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={onSelectedProjectPathChange}
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) => onCustomCwdChange(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) {
onCustomCwdChange(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>
{fieldError ? <p className="text-[11px] text-red-300">{fieldError}</p> : null}
</div>
);

View file

@ -13,6 +13,7 @@ import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
@ -38,6 +39,7 @@ export const ReviewDialog = ({
key: `requestChanges:${teamName}:${taskId ?? ''}`,
enabled: Boolean(teamName && taskId),
});
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
@ -45,9 +47,9 @@ export const ReviewDialog = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const handleCancel = (): void => {
@ -76,7 +78,9 @@ export const ReviewDialog = ({
</DialogHeader>
<div className="grid gap-2 py-2">
<Label htmlFor="review-comment">Comment (optional)</Label>
<Label htmlFor="review-comment" className="label-optional">
Comment (optional)
</Label>
<MentionableTextarea
id="review-comment"
className="min-h-[110px] text-xs"

View file

@ -24,6 +24,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -59,6 +60,7 @@ export const SendMessageDialog = ({
onSend,
onClose,
}: SendMessageDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
const [member, setMember] = useState('');
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
@ -102,18 +104,22 @@ export const SendMessageDialog = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
const canSend =
member.trim().length > 0 &&
textDraft.value.trim().length > 0 &&
summary.trim().length > 0 &&
!sending;
const handleSubmit = (): void => {
if (!canSend) return;
const rawText = textDraft.value.trim();
const finalText = quote ? buildReplyBlock(quote.from, quote.text, rawText) : rawText;
onSend(member.trim(), finalText, summary.trim() || undefined);
onSend(member.trim(), finalText, summary.trim());
textDraft.clearDraft();
};
@ -145,7 +151,8 @@ export const SendMessageDialog = ({
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
@ -169,16 +176,6 @@ export const SendMessageDialog = ({
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="smd-summary">Summary (optional)</Label>
<Input
id="smd-summary"
placeholder="Brief description shown as preview..."
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
</div>
{quote ? (
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<Tooltip>
@ -220,6 +217,20 @@ export const SendMessageDialog = ({
/>
</div>
<div className="grid gap-2">
<Label htmlFor="smd-summary">Summary</Label>
<Input
id="smd-summary"
className="h-8 text-xs"
placeholder="Brief summary reflecting the message intent"
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
Shown as notification preview. Team lead also sees this for peer messages.
</p>
</div>
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
</div>

View file

@ -0,0 +1,145 @@
import { useCallback, useMemo } from 'react';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Send, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
const MAX_COMMENT_LENGTH = 2000;
interface TaskCommentInputProps {
teamName: string;
taskId: string;
members: ResolvedTeamMember[];
replyTo: { author: string; text: string } | null;
onClearReply: () => void;
}
export const TaskCommentInput = ({
teamName,
taskId,
members,
replyTo,
onClearReply,
}: TaskCommentInputProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: colorMap.get(m.name),
})),
[members, colorMap]
);
const trimmed = draft.value.trim();
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment;
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
try {
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed;
await addTaskComment(teamName, taskId, text);
draft.clearDraft();
onClearReply();
} catch {
// Error is stored in addCommentError via store
}
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo, onClearReply]);
return (
<div>
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color: (() => {
const rc = colorMap.get(replyTo.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
@{replyTo.author}
</span>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={onClearReply}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div>
) : null}
<div className="relative">
<MentionableTextarea
id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
minRows={2}
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}
disabled={addingComment}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
>
<Send size={12} />
Comment
</button>
}
footerRight={
<div className="flex items-center gap-2">
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
</div>
);
};

View file

@ -4,12 +4,14 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
@ -26,6 +28,10 @@ interface TaskCommentsSectionProps {
members: ResolvedTeamMember[];
/** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */
hideHeader?: boolean;
/** When true, the comment input area is not rendered (useful when input is rendered externally). */
hideInput?: boolean;
/** Called when the user clicks Reply on a comment (used when input is rendered externally). */
onReply?: (author: string, text: string) => void;
}
export const TaskCommentsSection = ({
@ -34,6 +40,8 @@ export const TaskCommentsSection = ({
comments,
members,
hideHeader = false,
hideInput = false,
onReply,
}: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
@ -52,6 +60,7 @@ export const TaskCommentsSection = ({
}, []);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
@ -59,9 +68,9 @@ export const TaskCommentsSection = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const trimmed = draft.value.trim();
@ -96,205 +105,211 @@ export const TaskCommentsSection = ({
{comments.length > 0 ? (
<div className="mb-3 space-y-2">
{comments.map((comment) => (
<div key={comment.id} className="group p-2.5">
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span
className="font-medium"
style={{
color:
comment.author === 'user'
? 'var(--color-text-secondary)'
: (members.find((m) => m.name === comment.author)?.color ??
'var(--color-text-secondary)'),
}}
>
{comment.author}
</span>
<span>
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
? 'unknown time'
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() =>
setReplyTo({
author: comment.author,
text: stripAgentBlocks(
{[...comments]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((comment) => (
<div key={comment.id} className="group p-2.5">
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span
className="font-medium"
style={{
color: (() => {
const rc = colorMap.get(comment.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
{comment.author}
</span>
<span>
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
? 'unknown time'
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => {
const replyText = stripAgentBlocks(
parseMessageReply(comment.text)?.replyText ?? comment.text
),
})
}
>
<Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = stripAgentBlocks(rawForDisplay);
const needsExpandCollapse = displayText.includes('\n');
const expanded = expandedCommentIds.has(comment.id);
const collapsedHeight = 'max-h-[120px]';
const showCollapsed = needsExpandCollapse && !expanded;
const showExpandedButton = needsExpandCollapse && expanded;
return (
<div className="relative text-xs">
<div
className={
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
}
>
{reply ? (
<ReplyQuoteBlock
reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
}}
bodyMaxHeight={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
);
if (onReply) {
onReply(comment.author, replyText);
} else {
setReplyTo({ author: comment.author, text: replyText });
}
/>
) : (
<MarkdownViewer
content={displayText}
maxHeight={
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
}
/>
)}
{showCollapsed && (
<>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
style={{
background:
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
}}
>
<Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = stripAgentBlocks(rawForDisplay);
const needsExpandCollapse = displayText.includes('\n');
const expanded = expandedCommentIds.has(comment.id);
const collapsedHeight = 'max-h-[120px]';
const showCollapsed = needsExpandCollapse && !expanded;
const showExpandedButton = needsExpandCollapse && expanded;
return (
<div className="relative text-xs">
<div
className={
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
}
>
{reply ? (
<ReplyQuoteBlock
reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
}}
aria-hidden
bodyMaxHeight={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
}
/>
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Expand"
>
<ChevronDown size={12} />
Expand
</button>
</div>
</>
) : (
<MarkdownViewer
content={displayText}
maxHeight={
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
}
/>
)}
{showCollapsed && (
<>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
style={{
background:
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
}}
aria-hidden
/>
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Expand"
>
<ChevronDown size={12} />
Expand
</button>
</div>
</>
)}
</div>
{showExpandedButton && (
<div className="flex justify-center pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Collapse"
>
<ChevronUp size={12} />
Collapse
</button>
</div>
)}
</div>
{showExpandedButton && (
<div className="flex justify-center pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Collapse"
>
<ChevronUp size={12} />
Collapse
</button>
</div>
)}
</div>
);
})()}
</div>
))}
);
})()}
</div>
))}
</div>
) : null}
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color:
replyTo.author === 'user'
? 'var(--color-text-secondary)'
: (members.find((m) => m.name === replyTo.author)?.color ??
'var(--color-text-secondary)'),
}}
>
@{replyTo.author}
</span>
{!hideInput && (
<>
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color: (() => {
const rc = colorMap.get(replyTo.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
@{replyTo.author}
</span>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setReplyTo(null)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setReplyTo(null)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div>
) : null}
) : null}
<div className="relative">
<MentionableTextarea
id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
minRows={2}
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}
disabled={addingComment}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
>
<Send size={12} />
Comment
</button>
}
footerRight={
<div className="flex items-center gap-2">
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
<div className="relative">
<MentionableTextarea
id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
minRows={2}
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}
disabled={addingComment}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
>
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
<Send size={12} />
Comment
</button>
}
footerRight={
<div className="flex items-center gap-2">
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
</>
)}
</div>
);
};

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
@ -23,15 +23,32 @@ import {
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
buildMemberColorMap,
KANBAN_COLUMN_DISPLAY,
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns';
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react';
import {
AlignLeft,
ArrowLeftFromLine,
ArrowRightFromLine,
Clock,
FileCode,
FileDiff,
HelpCircle,
Link2,
Loader2,
MessageSquare,
PenLine,
ScrollText,
Trash2,
} from 'lucide-react';
import { TaskCommentInput } from './TaskCommentInput';
import { TaskCommentsSection } from './TaskCommentsSection';
import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -46,6 +63,10 @@ interface TaskDetailDialogProps {
onClose: () => void;
onScrollToTask?: (taskId: string) => void;
onOwnerChange?: (taskId: string, owner: string | null) => void;
onViewChanges?: (taskId: string, filePath?: string) => void;
onDeleteTask?: (taskId: string) => void;
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
headerExtra?: React.ReactNode;
}
export const TaskDetailDialog = ({
@ -58,8 +79,34 @@ export const TaskDetailDialog = ({
onClose,
onScrollToTask,
onOwnerChange,
onViewChanges,
onDeleteTask,
headerExtra,
}: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const [replyTo, setReplyTo] = useState<{
taskId: string;
author: string;
text: string;
} | null>(null);
const handleReply = useCallback(
(author: string, text: string) => {
if (currentTask) setReplyTo({ taskId: currentTask.id, author, text });
},
[currentTask]
);
const clearReply = useCallback(() => setReplyTo(null), []);
const handleClose = useCallback(() => {
setReplyTo(null);
onClose();
}, [onClose]);
const effectiveReplyTo =
replyTo && replyTo.taskId === currentTask?.id
? { author: replyTo.author, text: replyTo.text }
: null;
useEffect(() => {
if (!open || !currentTask) return;
@ -69,14 +116,48 @@ export const TaskDetailDialog = ({
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
}, [open, teamName, currentTask]);
// Lazy-load task changes when dialog is open and task is completed
const isTaskCompleted = currentTask?.status === 'completed';
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
const activeChangeSet = useStore((s) => s.activeChangeSet);
const changeSetLoading = useStore((s) => s.changeSetLoading);
const fetchTaskChanges = useStore((s) => s.fetchTaskChanges);
// Use the lightweight cache to know if changes exist before full data loads
const changesCacheKey = currentTask ? `${teamName}:${currentTask.id}` : '';
const taskKnownHasChanges = useStore((s) => s.taskHasChanges[changesCacheKey]) === true;
const taskChangesFiles = useMemo(() => {
if (!activeChangeSet || !currentTask) return null;
if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) {
return activeChangeSet.files;
}
return null;
}, [activeChangeSet, currentTask]);
useEffect(() => {
if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return;
// Only fetch if we don't already have data for this task
if (taskChangesFiles !== null) return;
void fetchTaskChanges(teamName, currentTask.id);
}, [
open,
currentTask,
isTaskCompleted,
teamName,
fetchTaskChanges,
taskChangesFiles,
onViewChanges,
]);
const handleDependencyClick = (taskId: string): void => {
onClose();
handleClose();
onScrollToTask?.(taskId);
};
if (!currentTask) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Task not found</DialogTitle>
@ -110,7 +191,6 @@ export const TaskDetailDialog = ({
t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id)
)
.map((t) => t.id);
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange;
@ -127,6 +207,7 @@ export const TaskDetailDialog = ({
>
{statusLabel}
</span>
{headerExtra ? <div className="ml-auto mr-4">{headerExtra}</div> : null}
</div>
<DialogTitle className="text-base">{currentTask.subject}</DialogTitle>
{currentTask.activeForm ? (
@ -151,7 +232,8 @@ export const TaskDetailDialog = ({
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
@ -174,7 +256,11 @@ export const TaskDetailDialog = ({
</SelectContent>
</Select>
) : currentTask.owner ? (
<MemberBadge name={currentTask.owner} color={ownerMember?.color} size="md" />
<MemberBadge
name={currentTask.owner}
color={colorMap.get(currentTask.owner)}
size="md"
/>
) : (
<span className="text-xs text-[var(--color-text-muted)]">&mdash;</span>
)}
@ -200,8 +286,37 @@ export const TaskDetailDialog = ({
: null}
</div>
{/* Clarification banner */}
{currentTask.needsClarification ? (
<div
className={`flex items-center justify-between rounded-md px-3 py-2 text-xs ${
currentTask.needsClarification === 'user'
? 'border border-red-500/20 bg-red-500/10 text-red-400'
: 'border border-blue-500/20 bg-blue-500/10 text-blue-400'
}`}
>
<span className="flex items-center gap-1.5">
<HelpCircle size={14} />
{currentTask.needsClarification === 'user'
? 'Awaiting clarification from you'
: 'Awaiting clarification from team lead'}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={(e) => {
e.stopPropagation();
void setTaskNeedsClarification(teamName, currentTask.id, null);
}}
>
Mark resolved
</Button>
</div>
) : null}
{/* Description */}
<CollapsibleTeamSection title="Description" defaultOpen>
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
{currentTask.description ? (
<div className="max-h-[200px] overflow-y-auto">
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
@ -211,6 +326,64 @@ export const TaskDetailDialog = ({
)}
</CollapsibleTeamSection>
{/* Changes */}
{isTaskCompleted && onViewChanges ? (
<CollapsibleTeamSection
title="Changes"
icon={<FileDiff size={14} />}
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
defaultOpen={taskKnownHasChanges}
>
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Loading changes...
</div>
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
{taskChangesFiles.map((file) => (
<button
key={file.filePath}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate font-mono text-[var(--color-text-secondary)]">
{file.relativePath}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? (
<span className="text-emerald-400">+{file.linesAdded}</span>
) : null}
{file.linesRemoved > 0 ? (
<span className="text-red-400">-{file.linesRemoved}</span>
) : null}
</span>
</button>
))}
</div>
) : (
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
)}
</CollapsibleTeamSection>
) : null}
{/* Execution Logs — sessions that reference this task */}
<CollapsibleTeamSection title="Execution Logs" icon={<ScrollText size={14} />} defaultOpen>
<div className="min-w-0 overflow-hidden">
<MemberLogsTab
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
/>
</div>
</CollapsibleTeamSection>
<div className="mb-3 space-y-2">
{/* Dependencies */}
{blockedByIds.length > 0 ? (
@ -337,6 +510,7 @@ export const TaskDetailDialog = ({
{/* Comments */}
<CollapsibleTeamSection
title="Comments"
icon={<MessageSquare size={14} />}
badge={
(currentTask.comments?.length ?? 0) > 0
? (currentTask.comments?.length ?? 0)
@ -344,29 +518,41 @@ export const TaskDetailDialog = ({
}
defaultOpen
>
<TaskCommentInput
teamName={teamName}
taskId={currentTask.id}
members={members}
replyTo={effectiveReplyTo}
onClearReply={clearReply}
/>
<TaskCommentsSection
teamName={teamName}
taskId={currentTask.id}
comments={currentTask.comments ?? []}
members={members}
hideHeader
hideInput
onReply={handleReply}
/>
</CollapsibleTeamSection>
{/* Execution Logs — sessions that reference this task */}
<CollapsibleTeamSection title="Execution Logs" defaultOpen>
<div className="min-w-0 overflow-hidden">
<MemberLogsTab
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
/>
</div>
</CollapsibleTeamSection>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<DialogFooter className="flex items-center justify-between sm:justify-between">
{onDeleteTask && currentTask ? (
<Button
variant="destructive"
size="sm"
onClick={() => {
onDeleteTask(currentTask.id);
handleClose();
}}
>
<Trash2 size={14} className="mr-1" />
Delete
</Button>
) : (
<div />
)}
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</DialogFooter>

View file

@ -16,6 +16,7 @@ import {
PlayCircle,
Plus,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { KanbanColumn } from './KanbanColumn';
@ -76,12 +77,20 @@ interface KanbanBoardProps {
onCancelTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
/** Открывает diff-просмотр изменений задачи. */
onViewChanges?: (taskId: string) => void;
/** Вызывается после изменения порядка задач в колонке (drag-and-drop). */
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
toolbarLeft?: React.ReactNode;
/** Opens the create-task dialog with pre-set startImmediately value. */
onAddTask?: (startImmediately: boolean) => void;
/** Soft-delete a task. */
onDeleteTask?: (taskId: string) => void;
/** Number of soft-deleted tasks (for trash button badge). */
deletedTaskCount?: number;
/** Opens the trash dialog. */
onOpenTrash?: () => void;
}
type KanbanViewMode = 'grid' | 'columns';
@ -140,6 +149,7 @@ interface SortableKanbanTaskCardProps {
columnId: KanbanColumnId;
teamName: string;
kanbanState: KanbanState;
compact?: boolean;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void;
@ -151,6 +161,8 @@ interface SortableKanbanTaskCardProps {
onCancelTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
onViewChanges?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
const SortableKanbanTaskCard = ({
@ -158,6 +170,7 @@ const SortableKanbanTaskCard = ({
columnId,
teamName,
kanbanState,
compact,
taskMap,
members,
onRequestReview,
@ -169,6 +182,8 @@ const SortableKanbanTaskCard = ({
onCancelTask,
onScrollToTask,
onTaskClick,
onViewChanges,
onDeleteTask,
}: SortableKanbanTaskCardProps): React.JSX.Element => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
@ -190,6 +205,7 @@ const SortableKanbanTaskCard = ({
columnId={columnId}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
compact={compact}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
@ -201,6 +217,8 @@ const SortableKanbanTaskCard = ({
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
</div>
);
@ -224,9 +242,13 @@ export const KanbanBoard = ({
onCancelTask,
onScrollToTask,
onTaskClick,
onViewChanges,
onColumnOrderChange,
toolbarLeft,
onAddTask,
onDeleteTask,
deletedTaskCount,
onOpenTrash,
}: KanbanBoardProps): React.JSX.Element => {
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
@ -284,7 +306,11 @@ export const KanbanBoard = ({
[onColumnOrderChange, groupedOrdered]
);
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
const renderCards = (
columnId: KanbanColumnId,
columnTasks: TeamTask[],
compact?: boolean
): React.JSX.Element => {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
@ -324,6 +350,7 @@ export const KanbanBoard = ({
columnId={columnId}
teamName={teamName}
kanbanState={kanbanState}
compact={compact}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
@ -335,6 +362,8 @@ export const KanbanBoard = ({
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
))}
</SortableContext>
@ -352,6 +381,7 @@ export const KanbanBoard = ({
columnId={columnId}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
compact={compact}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
@ -363,6 +393,8 @@ export const KanbanBoard = ({
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
))}
{addButton}
@ -382,6 +414,22 @@ export const KanbanBoard = ({
members={members}
onFilterChange={onFilterChange}
/>
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[var(--color-text-muted)]"
onClick={onOpenTrash}
>
<Trash2 size={14} />
<span className="ml-1 text-xs">{deletedTaskCount}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Trash</TooltipContent>
</Tooltip>
) : null}
<div className="inline-flex rounded-md border border-[var(--color-border)]">
<Tooltip>
<TooltipTrigger asChild>
@ -458,7 +506,7 @@ export const KanbanBoard = ({
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
>
{renderCards(column.id, columnTasks)}
{renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
);

View file

@ -38,7 +38,7 @@ export const KanbanColumn = ({
{count}
</Badge>
</header>
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
<div className="flex max-h-[480px] flex-col overflow-auto p-2">{children}</div>
</section>
);
};

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
@ -6,7 +6,18 @@ import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play, XCircle } from 'lucide-react';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
ArrowLeftFromLine,
ArrowRightFromLine,
CheckCircle2,
FileCode,
HelpCircle,
Play,
Trash2,
XCircle,
} from 'lucide-react';
import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
@ -16,6 +27,7 @@ interface KanbanTaskCardProps {
columnId: KanbanColumnId;
kanbanTaskState?: KanbanTaskState;
hasReviewers: boolean;
compact?: boolean;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void;
@ -27,6 +39,8 @@ interface KanbanTaskCardProps {
onCancelTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
onViewChanges?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
interface DependencyBadgeProps {
@ -121,6 +135,7 @@ export const KanbanTaskCard = ({
columnId,
kanbanTaskState: _kanbanTaskState,
hasReviewers,
compact,
taskMap,
members,
onRequestReview,
@ -132,13 +147,29 @@ export const KanbanTaskCard = ({
onCancelTask,
onScrollToTask,
onTaskClick,
onViewChanges,
onDeleteTask,
}: KanbanTaskCardProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
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;
// Lazy-check if task has file changes (only for done/review/approved columns)
const showChangesColumn =
(columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges;
const cacheKey = `${teamName}:${task.id}`;
const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]);
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
useEffect(() => {
if (showChangesColumn && task.status === 'completed' && taskHasChanges == null) {
void checkTaskHasChanges(teamName, task.id);
}
}, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]);
return (
<div
data-task-id={task.id}
@ -157,19 +188,35 @@ export const KanbanTaskCard = ({
}
}}
>
<div className="mb-2 flex items-center gap-1">
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
{task.owner ? (
<MemberBadge
name={task.owner}
color={members.find((m) => m.name === task.owner)?.color}
/>
) : null}
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
<div className="mb-2">
<div className="flex items-center gap-1">
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{task.needsClarification ? (
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
task.needsClarification === 'user'
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-400'
}`}
>
<HelpCircle size={10} />
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span>
) : null}
{!compact && (
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
</div>
{compact && (
<h5 className="mt-1 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
</div>
{hasBlockedBy ? (
@ -296,12 +343,14 @@ export const KanbanTaskCard = ({
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Approve task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
>
<CheckCircle2 size={12} />
Approve
</Button>
<Button
@ -334,7 +383,35 @@ export const KanbanTaskCard = ({
) : null}
</div>
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
<div className="flex items-center gap-1.5">
{showChangesColumn && taskHasChanges === true ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewChanges(task.id);
}}
className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)] transition-colors hover:text-blue-400"
>
<FileCode className="size-3" />
Changes
</button>
) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
{onDeleteTask ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDeleteTask(task.id);
}}
className="text-[var(--color-text-muted)] transition-colors hover:text-red-400"
title="Delete task"
>
<Trash2 size={12} />
</button>
) : null}
</div>
</div>
</div>
);

View file

@ -0,0 +1,109 @@
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { formatDistanceToNow } from 'date-fns';
import { RotateCcw, Trash2 } from 'lucide-react';
import type { TeamTask } from '@shared/types';
interface TrashDialogProps {
open: boolean;
tasks: TeamTask[];
onClose: () => void;
onRestore?: (taskId: string) => void;
}
export const TrashDialog = ({
open,
tasks,
onClose,
onRestore,
}: TrashDialogProps): React.JSX.Element => {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Trash2 size={14} className="text-[var(--color-text-muted)]" />
Trash
</DialogTitle>
</DialogHeader>
{tasks.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
No deleted tasks
</div>
) : (
<TooltipProvider delayDuration={300}>
<div className="max-h-[400px] overflow-y-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Subject</th>
<th className="pb-2 pr-3 font-medium">Owner</th>
<th className="pb-2 pr-3 font-medium">Deleted</th>
{onRestore ? <th className="pb-2 font-medium" /> : null}
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr
key={task.id}
className="border-b border-[var(--color-border-subtle)] last:border-0"
>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? '—'}
</td>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
{task.deletedAt
? formatDistanceToNow(new Date(task.deletedAt), { addSuffix: true })
: '—'}
</td>
{onRestore ? (
<td className="py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={() => onRestore(task.id)}
aria-label="Restore task"
>
<RotateCcw size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Restore</TooltipContent>
</Tooltip>
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</TooltipProvider>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -6,7 +6,7 @@ import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/u
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberCardProps {
member: ResolvedTeamMember;
@ -14,6 +14,7 @@ interface MemberCardProps {
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
currentTask?: TeamTaskWithKanban | null;
isAwaitingReply?: boolean;
isRemoved?: boolean;
@ -29,6 +30,7 @@ export const MemberCard = ({
taskCounts,
isTeamAlive,
isTeamProvisioning,
leadActivity,
currentTask,
isAwaitingReply,
isRemoved,
@ -37,8 +39,8 @@ export const MemberCard = ({
onSendMessage,
onAssignTask,
}: MemberCardProps): React.JSX.Element => {
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0;

View file

@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
import { MemberDetailHeader } from './MemberDetailHeader';
@ -12,7 +13,12 @@ import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type {
InboxMessage,
LeadActivityState,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
interface MemberDetailDialogProps {
open: boolean;
@ -22,6 +28,7 @@ interface MemberDetailDialogProps {
messages: InboxMessage[];
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
onClose: () => void;
onSendMessage: () => void;
onAssignTask: () => void;
@ -29,6 +36,7 @@ interface MemberDetailDialogProps {
onRemoveMember?: () => void;
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | void;
updatingRole?: boolean;
onViewMemberChanges?: (memberName: string, filePath?: string) => void;
}
export const MemberDetailDialog = ({
@ -39,6 +47,7 @@ export const MemberDetailDialog = ({
messages,
isTeamAlive,
isTeamProvisioning,
leadActivity,
onClose,
onSendMessage,
onAssignTask,
@ -46,6 +55,7 @@ export const MemberDetailDialog = ({
onRemoveMember,
onUpdateRole,
updatingRole,
onViewMemberChanges,
}: MemberDetailDialogProps): React.JSX.Element | null => {
const memberTasks = useMemo(
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
@ -69,6 +79,14 @@ export const MemberDetailDialog = ({
const [activeTab, setActiveTab] = useState<MemberDetailTab>('tasks');
const {
stats: memberStats,
loading: statsLoading,
error: statsError,
} = useMemberStats(teamName, member?.name ?? null);
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
if (!member) return null;
return (
@ -80,6 +98,7 @@ export const MemberDetailDialog = ({
member={member}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
}
@ -92,7 +111,9 @@ export const MemberDetailDialog = ({
inProgressTasks={inProgressTasks}
completedTasks={completedTasks}
messageCount={memberMessages.length}
lastActiveAt={member.lastActiveAt}
totalTokens={totalTokens}
statsLoading={statsLoading}
statsComputedAt={memberStats?.computedAt}
onTabChange={setActiveTab}
/>
</div>
@ -135,7 +156,15 @@ export const MemberDetailDialog = ({
<MemberMessagesTab messages={memberMessages} teamName={teamName} />
</TabsContent>
<TabsContent value="stats">
<MemberStatsTab teamName={teamName} memberName={member.name} />
<MemberStatsTab
teamName={teamName}
memberName={member.name}
prefetchedStats={memberStats}
prefetchedLoading={statsLoading}
prefetchedError={statsError}
onFileClick={(filePath) => onViewMemberChanges?.(member.name, filePath)}
onShowAllFiles={() => onViewMemberChanges?.(member.name)}
/>
</TabsContent>
<TabsContent value="logs" className="min-w-0 overflow-hidden">
<MemberLogsTab teamName={teamName} memberName={member.name} />

View file

@ -8,12 +8,13 @@ import { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor';
import type { ResolvedTeamMember } from '@shared/types';
import type { LeadActivityState, ResolvedTeamMember } from '@shared/types';
interface MemberDetailHeaderProps {
member: ResolvedTeamMember;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
updatingRole?: boolean;
}
@ -22,14 +23,15 @@ export const MemberDetailHeader = ({
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
onUpdateRole,
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
const [editing, setEditing] = useState(false);
const role = member.role || formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const canEditRole =
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;

View file

@ -1,4 +1,4 @@
import { formatDistanceToNow } from 'date-fns';
import { formatRelativeTime, formatTokensCompact } from '@renderer/utils/formatters';
export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs';
@ -7,7 +7,9 @@ interface MemberDetailStatsProps {
inProgressTasks: number;
completedTasks: number;
messageCount: number;
lastActiveAt: string | null;
totalTokens: number | null;
statsLoading?: boolean;
statsComputedAt?: string;
onTabChange?: (tab: MemberDetailTab) => void;
}
@ -50,12 +52,18 @@ export const MemberDetailStats = ({
inProgressTasks,
completedTasks,
messageCount,
lastActiveAt,
totalTokens,
statsLoading,
statsComputedAt,
onTabChange,
}: MemberDetailStatsProps): React.JSX.Element => {
const lastActive = lastActiveAt
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
: '—';
const tokensValue = statsLoading
? '...'
: totalTokens != null
? formatTokensCompact(totalTokens)
: '—';
const tokensSub =
!statsLoading && statsComputedAt ? `updated ${formatRelativeTime(statsComputedAt)}` : undefined;
return (
<div className="grid min-w-0 flex-1 grid-cols-4 gap-1.5">
@ -76,9 +84,10 @@ export const MemberDetailStats = ({
onClick={onTabChange ? () => onTabChange('messages') : undefined}
/>
<StatBlock
label="Activity"
value={lastActive}
onClick={onTabChange ? () => onTabChange('logs') : undefined}
label="Tokens"
value={tokensValue}
sub={tokensSub}
onClick={onTabChange ? () => onTabChange('stats') : undefined}
/>
</div>
);

View file

@ -1,9 +1,9 @@
import { getMemberColor } from '@shared/constants/memberColors';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
@ -12,6 +12,7 @@ interface MemberListProps {
pendingRepliesByMember?: Record<string, number>;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
@ -25,6 +26,7 @@ export const MemberList = ({
pendingRepliesByMember,
isTeamAlive,
isTeamProvisioning,
leadActivity,
onMemberClick,
onSendMessage,
onAssignTask,
@ -32,6 +34,7 @@ export const MemberList = ({
}: MemberListProps): React.JSX.Element => {
const activeMembers = members.filter((m) => !m.removedAt);
const removedMembers = members.filter((m) => m.removedAt);
const colorMap = buildMemberColorMap(members);
if (members.length === 0) {
return (
@ -41,11 +44,7 @@ export const MemberList = ({
);
}
const renderCard = (
member: ResolvedTeamMember,
index: number,
isRemoved: boolean
): React.JSX.Element => {
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
@ -53,10 +52,11 @@ export const MemberList = ({
<MemberCard
key={member.name}
member={member}
memberColor={member.color ?? getMemberColor(index)}
memberColor={colorMap.get(member.name) ?? 'blue'}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
@ -69,16 +69,14 @@ export const MemberList = ({
};
return (
<div className="flex flex-col gap-0.5">
{activeMembers.map((member, index) => renderCard(member, index, false))}
<div className="flex flex-col">
{activeMembers.map((member) => renderCard(member, false))}
{removedMembers.length > 0 && (
<>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length})
</div>
{removedMembers.map((member, index) =>
renderCard(member, activeMembers.length + index, true)
)}
{removedMembers.map((member) => renderCard(member, true))}
</>
)}
</div>

Some files were not shown because too many files have changed in this diff Show more