commit
1698319443
162 changed files with 29532 additions and 2127 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. They’ll 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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
255
docs/iterations/diff-view/continuous-scroll/overview.md
Normal file
255
docs/iterations/diff-view/continuous-scroll/overview.md
Normal 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)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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
988
docs/iterations/diff-view/continuous-scroll/phase-5-polish.md
Normal file
988
docs/iterations/diff-view/continuous-scroll/phase-5-polish.md
Normal 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) |
|
||||
536
docs/research/diff-view-audit.md
Normal file
536
docs/research/diff-view-audit.md
Normal 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 |
|
||||
814
docs/research/diff-view-fix-plans.md
Normal file
814
docs/research/diff-view-fix-plans.md
Normal 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 — **НЕ СУЩЕСТВУЕТ!**
|
||||
- **Нужно создать оба перед реализацией**
|
||||
239
docs/research/diff-view-round3-research.md
Normal file
239
docs/research/diff-view-round3-research.md
Normal 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
466
docs/research/git.md
Normal 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)
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
43
package.json
43
package.json
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
910
pnpm-lock.yaml
910
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
79
src/main/ipc/cliInstaller.ts
Normal file
79
src/main/ipc/cliInstaller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
318
src/main/ipc/review.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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
101
src/main/ipc/terminal.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
497
src/main/services/infrastructure/CliInstallerService.ts
Normal file
497
src/main/services/infrastructure/CliInstallerService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
112
src/main/services/infrastructure/PtyTerminalService.ts
Normal file
112
src/main/services/infrastructure/PtyTerminalService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
619
src/main/services/team/ChangeExtractorService.ts
Normal file
619
src/main/services/team/ChangeExtractorService.ts
Normal 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 hunk↔snippet 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.'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
506
src/main/services/team/FileContentResolver.ts
Normal file
506
src/main/services/team/FileContentResolver.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
134
src/main/services/team/GitDiffFallback.ts
Normal file
134
src/main/services/team/GitDiffFallback.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/main/services/team/HunkSnippetMatcher.ts
Normal file
150
src/main/services/team/HunkSnippetMatcher.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { structuredPatch } from 'diff';
|
||||
|
||||
import type { SnippetDiff } from '@shared/types';
|
||||
|
||||
/**
|
||||
* Reliable hunk↔snippet 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 hunkIndex→snippetIndex 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
509
src/main/services/team/ReviewApplierService.ts
Normal file
509
src/main/services/team/ReviewApplierService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
103
src/main/services/team/ReviewDecisionStore.ts
Normal file
103
src/main/services/team/ReviewDecisionStore.ts
Normal 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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
378
src/main/services/team/TaskBoundaryParser.ts
Normal file
378
src/main/services/team/TaskBoundaryParser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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` +
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
25
src/main/services/team/UnifiedLineCounter.ts
Normal file
25
src/main/services/team/UnifiedLineCounter.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
11
src/main/utils/processHealth.ts
Normal file
11
src/main/utils/processHealth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => () => {},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
urlTransform={(url) => url}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
|
|
|
|||
513
src/renderer/components/dashboard/CliStatusBanner.tsx
Normal file
513
src/renderer/components/dashboard/CliStatusBanner.tsx
Normal 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 }}>›</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' }}>
|
||||
→ 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
134
src/renderer/components/layout/TeamTabSectionNav.tsx
Normal file
134
src/renderer/components/layout/TeamTabSectionNav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
246
src/renderer/components/settings/sections/CliStatusSection.tsx
Normal file
246
src/renderer/components/settings/sections/CliStatusSection.tsx
Normal 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} → 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
230
src/renderer/components/team/CliLogsRichView.tsx
Normal file
230
src/renderer/components/team/CliLogsRichView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
131
src/renderer/components/team/ProcessesSection.tsx
Normal file
131
src/renderer/components/team/ProcessesSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
185
src/renderer/components/team/TeamListFilterPopover.tsx
Normal file
185
src/renderer/components/team/TeamListFilterPopover.tsx
Normal 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 */
|
||||
|
|
@ -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 "{searchQuery.trim()}"
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)]">·</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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)" />
|
||||
|
|
|
|||
96
src/renderer/components/team/dialogs/MembersJsonEditor.tsx
Normal file
96
src/renderer/components/team/dialogs/MembersJsonEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
176
src/renderer/components/team/dialogs/ProjectPathSelector.tsx
Normal file
176
src/renderer/components/team/dialogs/ProjectPathSelector.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
145
src/renderer/components/team/dialogs/TaskCommentInput.tsx
Normal file
145
src/renderer/components/team/dialogs/TaskCommentInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)]">—</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
109
src/renderer/components/team/kanban/TrashDialog.tsx
Normal file
109
src/renderer/components/team/kanban/TrashDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue