` (для IntersectionObserver)
+2. Документ ранее предлагал его на sticky header в `FileSectionHeader`
+
+**Решение:** Оставить `data-file-path` **только на section div** (через `registerFileSectionRef`). `scrollToFile` найдёт section div через `querySelector`, `scrollIntoView({ block: 'start' })` покажет начало секции. Scroll-spy observer тоже наблюдает section div. Один источник правды.
+
+В `FileSectionHeader` `data-file-path` **не добавлять**.
+
+### 4.7. Загрузка контента при mode='task'
+
+`fetchFileContent(teamName, memberName, filePath)` — в mode='task' `memberName` может быть `undefined`. Текущая signature в store (строка 261): `fetchFileContent(teamName: string, memberName: string | undefined, filePath: string)`. Это ОК.
+
+---
+
+## 5. Порядок реализации
+
+### Шаг 1: Создать `FileSectionPlaceholder.tsx`
+- Простой компонент, без зависимостей
+- Тестирование: визуально убедиться что skeleton выглядит ок
+
+### Шаг 2: Создать `FileSectionHeader.tsx`
+- Извлечь из ChangeReviewDialog строки 437-509
+- Вынести `CONTENT_SOURCE_LABELS`
+- Добавить sticky positioning
+- Импортировать `Save`, `Undo2`, `Loader2` из lucide-react, `Tooltip`/`TooltipTrigger`/`TooltipContent`
+- **НЕ добавлять** `data-file-path` на header (только на section div в ContinuousScrollView)
+- Тестирование: рендерить standalone, проверить sticky поведение
+
+### Шаг 3: Создать `FileSectionDiff.tsx`
+- Извлечь из ChangeReviewDialog строки 511-561
+- Добавить проверку `fileContent.modifiedFullContent !== null` в условие рендеринга CodeMirror
+- Добавить sentinel для auto-viewed (threshold: 0.85)
+- Добавить editorView registration callback
+- Передать `onFullyViewed={undefined}` в CodeMirrorDiffView (отключить встроенный sentinel)
+- Тестирование: рендерить с mock data, проверить что CodeMirror создаётся
+
+### Шаг 4: Создать `useVisibleFileSection.ts`
+- По паттерну `useVisibleAIGroup.ts`
+- Добавить debounce и isProgrammaticScroll
+- Guard на `scrollContainerRef.current` is null
+- Тестирование: unit test с mock IntersectionObserver
+
+### Шаг 5: Создать `useContinuousScrollNav.ts`
+- scrollToFile с waitForScrollEnd(container, 500)
+- Keyboard listeners (Alt+Arrow)
+- Scroll generation counter для предотвращения race при быстрых кликах
+- Тестирование: unit test keyboard events
+
+### Шаг 6: Создать `ContinuousScrollView.tsx`
+- Собрать все компоненты вместе
+- files.map -> section (header + diff)
+- Интегрировать useVisibleFileSection
+- `data-file-path` только на section div (через registerFileSectionRef)
+- Тестирование: рендерить с 3-5 файлами, проверить scroll и sticky headers
+
+### Шаг 7: Модифицировать `ReviewFileTree.tsx`
+- Добавить `activeFilePath` prop в `ReviewFileTreeProps`
+- Добавить `activeFilePath` в inline props TreeItem и пробросить рекурсивно
+- Добавить `data-tree-file` attribute на button
+- Добавить auto-scroll useEffect
+- Добавить визуальную подсветку active файла (isActive condition + border-l-2 стиль)
+- Тестирование: проверить подсветку active vs selected
+
+### Шаг 8: Модифицировать `ChangeReviewDialog.tsx`
+- Заменить single-file area на ContinuousScrollView
+- Убрать: editorViewRef, editorStateCache, cachedInitialState, handleSelectFile, hasCurrentFileEdits, selectedFile (заменить на activeFile)
+- Добавить: activeFilePath, editorViewMapRef, scrollContainerRef, activeEditorViewRef
+- Заменить lazy-load на bulk-load useEffect
+- Интегрировать useContinuousScrollNav
+- Адаптировать: handleAcceptAll/handleRejectAll, handleSaveFile (принимает filePath), handleDiscardFile (принимает filePath), handleFullyViewed (принимает filePath)
+- Адаптировать useDiffNavigation: activeFilePath, scrollToFile, activeEditorViewRef
+- Адаптировать Cmd+N handler: getTargetEditorView
+- Timeline sidebar: selectedFile -> activeFile
+- FileTree: selectedFilePath={null}, activeFilePath, onSelectFile=handleTreeFileClick
+- Убрать неиспользуемые импорты
+- Тестирование: полный E2E flow
+
+---
+
+## 6. Проверка
+
+### Функциональная проверка
+
+- [ ] Открыть ChangeReviewDialog с 3+ файлами
+- [ ] Все файлы отображаются вертикально друг под другом
+- [ ] Sticky headers прилипают при скролле
+- [ ] File tree подсвечивает текущий файл при скролле (scroll-spy)
+- [ ] Клик по файлу в tree = smooth scroll к файлу в контенте
+- [ ] Нет "мигания" active файла при programmatic scroll
+- [ ] Alt+ArrowDown/Up переключает между файлами
+- [ ] Cmd+Y accept chunk работает (focused editor и fallback через getTargetEditorView)
+- [ ] Cmd+N reject chunk работает (через IPC и через useDiffNavigation fallback)
+- [ ] Accept All / Reject All применяются к active файлу
+- [ ] Save/Discard кнопки в header работают (каждый файл независимо)
+- [ ] Auto-viewed работает (скроллить до конца файла — sentinel 85%)
+- [ ] Viewed checkbox в tree работает
+- [ ] Edit timeline sidebar показывается для active файла
+- [ ] Escape закрывает диалог
+- [ ] Быстрый двойной клик по разным файлам в tree — нет race condition (scroll generation counter)
+
+### Edge cases
+
+- [ ] 0 файлов — показывает "No file changes detected"
+- [ ] 1 файл — scroll-spy стабильно, нет navigation issues
+- [ ] Файл с `contentSource: 'unavailable'` — показывает ReviewDiffContent fallback
+- [ ] Файл с `modifiedFullContent === null` — показывает ReviewDiffContent fallback
+- [ ] Файл загружается — показывает FileSectionPlaceholder
+- [ ] Файл с isNewFile — показывает NEW badge в sticky header
+- [ ] Очень длинный файл (1000+ строк) — smooth scroll работает
+- [ ] Collapse unchanged toggle — все editors обновляются (через collapseUnchanged prop -> CodeMirrorDiffView reconfigure)
+- [ ] Discard edits — editor пересоздаётся (key с discardCounter; глобальный counter, все editors rebuild — ОК для фазы 1)
+- [ ] Resize window — IntersectionObserver пересчитывает, scroll-spy корректен
+- [ ] mode='task' с memberName=undefined — bulk-load работает
+
+### Performance
+
+- [ ] 20 файлов — открытие < 2 секунд
+- [ ] Scroll не лагает с 10+ CodeMirror editors
+- [ ] Memory не утекает при close/reopen диалога (EditorView.destroy() через cleanup в CodeMirrorDiffView + editorViewMapRef cleanup)
diff --git a/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md b/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md
new file mode 100644
index 00000000..34b785aa
--- /dev/null
+++ b/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md
@@ -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
;
+
+ /**
+ * Загруженный контент из store (для проверки: уже загружен?).
+ * Тип: Record из changeReviewSlice.
+ */
+ fileContents: Record;
+
+ /** Флаги загрузки из store (для проверки: уже грузится?) */
+ fileContentsLoading: Record;
+
+ /**
+ * Функция загрузки контента из store.
+ * Сигнатура точно как в changeReviewSlice.fetchFileContent:
+ * (teamName: string, memberName: string | undefined, filePath: string) => Promise
+ *
+ * Внутри store уже есть guard от дубликатов (строка 264):
+ * if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
+ * Поэтому двойной вызов безопасен.
+ */
+ fetchFileContent: (
+ teamName: string,
+ memberName: string | undefined,
+ filePath: string
+ ) => Promise;
+
+ /** Lazy loading включён (false = загрузить всё сразу, для fallback) */
+ enabled: boolean;
+}
+
+interface UseLazyFileContentReturn {
+ /**
+ * Регистрация file section для lazy-load наблюдения.
+ * Возвращает ref callback — передать в div section.
+ * Пример:
+ */
+ 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
());
+
+ // Queue: filePath ожидающих загрузки (FIFO, но с приоритетом)
+ const pendingQueue = useRef([]);
+
+ // Max параллельных загрузок
+ const MAX_CONCURRENT = 3;
+
+ // Observer ref
+ const observerRef = useRef(null);
+
+ // Element refs
+ const elementRefs = useRef(new Map());
+
+ // 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 => {
+ 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
+ * Из changeReviewSlice.fetchFileContent
+ */
+ fetchFileContent: (
+ teamName: string,
+ memberName: string | undefined,
+ filePath: string
+ ) => Promise;
+}
+```
+
+**Интеграция в компоненте:**
+
+```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 (
+
+ {files.map((file) => {
+ const filePath = file.filePath;
+ const content = fileContents[filePath] ?? null;
+ const isLoading = fileContentsLoading[filePath] ?? false;
+ const hasContent = content !== null;
+
+ return (
+
+
+
+ {/* Контент ещё не загружен — placeholder */}
+ {!hasContent && isLoading && (
+
+ )}
+
+ {/* Контент ещё не начал грузиться — тоже placeholder */}
+ {!hasContent && !isLoading && (
+
+ )}
+
+ {/* Контент загружен — diff */}
+ {hasContent && (
+
+ )}
+
+ );
+ })}
+
+ {files.length === 0 && (
+
+ No file changes detected
+
+ )}
+
+ );
+};
+```
+
+**Отличие от 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
+
+```
+
+**Примечание:** `teamName` берётся из props `ChangeReviewDialogProps`, `memberName` оттуда же (optional prop). `fetchFileContent` берётся из `useStore()` (строка 77 текущего файла).
+
+---
+
+## 4. Throttle реализация: детали
+
+### Структура данных
+
+```
+┌─────────────────┐
+│ activeLoads │ Set -- 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` (value из `.current`), а Phase 2 работает с `MutableRefObject`. Нет конфликта -- 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
diff --git a/docs/iterations/diff-view/continuous-scroll/phase-3-navigation.md b/docs/iterations/diff-view/continuous-scroll/phase-3-navigation.md
new file mode 100644
index 00000000..ebe7804d
--- /dev/null
+++ b/docs/iterations/diff-view/continuous-scroll/phase-3-navigation.md
@@ -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` вместо одного `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,
+ 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;
+
+ /**
+ * Текущий видимый файл из 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,
+ 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 | 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;
+
+ /** Диалог открыт (для cleanup) */
+ isOpen: boolean;
+}
+
+interface UseContinuousScrollNavReturn {
+ /** Scroll к файлу по filePath (smooth) */
+ scrollToFile: (filePath: string) => void;
+
+ /** Ref-flag: true пока идёт programmatic scroll */
+ isProgrammaticScroll: RefObject;
+}
+```
+
+3. **scrollToFile -- без `setActiveFilePath`:**
+
+```typescript
+const scrollToFile = useCallback(
+ (filePath: string) => {
+ const container = scrollContainerRef.current;
+ if (!container) return;
+
+ const section = container.querySelector(
+ `[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
+```
+- `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());
+```
+
+#### Получение данных из 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 */}
+
+```
+
+**Примечание:** 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`. 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 |
diff --git a/docs/iterations/diff-view/continuous-scroll/phase-4-portion-collapse.md b/docs/iterations/diff-view/continuous-scroll/phase-4-portion-collapse.md
new file mode 100644
index 00000000..2ccedd7b
--- /dev/null
+++ b/docs/iterations/diff-view/continuous-scroll/phase-4-portion-collapse.md
@@ -0,0 +1,1264 @@
+# Phase 4: Portion Collapse
+
+## Обзор
+
+**Проблема:** Стандартный `collapseUnchanged` из `@codemirror/merge` при клике на collapsed region разворачивает ВСЮ зону целиком. Для файлов с 500+ неизменённых строк между изменениями это создаёт резкий скачок контента и потерю контекста. GitHub решает это кнопками "Expand 20 lines" / "Expand all", позволяя раскрывать порциями.
+
+**Решение:** Кастомный CodeMirror StateField (`portionCollapseExtension`) который создаёт `Decoration.replace` с виджетами, содержащими кнопки "Expand N" и "Expand All". При клике на "Expand N" виджет разворачивает только указанное количество строк, оставляя остаток свёрнутым.
+
+**Зависимости:** Независим от Phase 1-3 (continuous scroll). Может использоваться как в single-file mode, так и в continuous mode.
+
+---
+
+## Почему кастомный StateField
+
+### Ограничения CM's collapseUnchanged
+
+`@codemirror/merge` реализует `collapseUnchanged` через приватный StateField `CollapsedRanges` + `Decoration.replace` с внутренним `CollapseWidget`. При клике на collapsed widget используется StateEffect `uncollapseUnchanged` (экспортируется из `@codemirror/merge`), который ПОЛНОСТЬЮ удаляет decoration для зоны через `deco.update({ filter: from => from != e.value })`.
+
+```typescript
+// Экспорт из @codemirror/merge
+declare const uncollapseUnchanged: StateEffectType;
+```
+
+**Ключевая деталь реализации CM:** `CollapsedRanges` использует паттерн `StateField.define` + `StateField.init()`:
+- `create()` возвращает `Decoration.none` (пустые decorations)
+- `collapseUnchanged()` возвращает `CollapsedRanges.init(state => buildCollapsedRanges(state, margin, minSize))` — init переопределяет create при инициализации state
+- `update()` делает ТОЛЬКО `deco.map(tr.changes)` + filter по `uncollapseUnchanged` effect
+- `update()` НЕ делает rebuild при `docChanged` или `updateOriginalDoc` — CM пересоздаёт collapse decorations через reconfigure compartment при изменении chunks
+
+Проблемы:
+1. **Нет partial expand** — `uncollapseUnchanged` принимает только `pos: number` и разворачивает всю зону
+2. **Нет public API** для получения списка collapsed зон или модификации отдельных зон
+3. **WidgetType** внутренний (CollapseWidget) — нет возможности заменить DOM widget без форка
+4. **CollapsedRanges StateField** — приватный, недоступен для расширения
+
+### Почему не обёртка
+
+Теоретически можно было бы:
+- Перехватить `uncollapseUnchanged` effect в транзакции
+- Вместо полного uncollapse — создать два новых collapsed regionа
+- Но `uncollapseUnchanged` привязан к внутреннему `CollapsedRanges` StateField, который фильтрует decorations по `from` position
+
+Это хрупко и сломается при обновлении @codemirror/merge. Надёжнее написать свой StateField.
+
+---
+
+## Новый файл: portionCollapse.ts
+
+**Путь:** `src/renderer/components/team/review/portionCollapse.ts`
+
+### Exports
+
+```typescript
+import {
+ Decoration,
+ type DecorationSet,
+ EditorView,
+ WidgetType,
+} from '@codemirror/view';
+import {
+ type ChangeDesc,
+ type EditorState,
+ type Extension,
+ RangeSetBuilder,
+ StateEffect,
+ type StateEffectType,
+ StateField,
+ type Transaction,
+} from '@codemirror/state';
+
+import { getChunks, type updateOriginalDoc } from './CodeMirrorDiffUtils';
+// updateOriginalDoc используется только для .is() проверки в update(),
+// поэтому импортируем его напрямую:
+import { updateOriginalDoc } from '@codemirror/merge';
+
+// ─── Configuration ───
+
+interface PortionCollapseConfig {
+ /**
+ * Количество строк контекста, оставляемых видимыми до/после изменения.
+ * Default: 3
+ * Соответствует поведению CM's collapseUnchanged.margin.
+ */
+ margin?: number;
+
+ /**
+ * Минимальное количество строк в unchanged зоне для создания collapse.
+ * Зоны короче этого значения остаются видимыми целиком.
+ * Default: 4
+ * Соответствует CM's collapseUnchanged.minSize.
+ */
+ minSize?: number;
+
+ /**
+ * Количество строк, раскрываемых за одно нажатие "Expand N".
+ * Default: 100
+ * При меньшем остатке кнопка показывает "Expand <остаток>".
+ */
+ portionSize?: number;
+}
+
+// ─── State Effects ───
+
+/**
+ * Раскрыть portionSize строк из collapsed зоны.
+ * pos — позиция начала текущей collapsed decoration.
+ * count — количество строк для раскрытия (обычно = portionSize).
+ *
+ * StateEffect.define с map() для корректного ремаппинга при изменениях документа.
+ * map callback: (value, mapping: ChangeDesc) => Value | undefined.
+ * Возврат undefined удаляет effect (не наш случай — always remap).
+ */
+export const expandPortion: StateEffectType<{
+ pos: number;
+ count: number;
+}> = StateEffect.define<{ pos: number; count: number }>({
+ map: (value, mapping: ChangeDesc) => ({
+ pos: mapping.mapPos(value.pos),
+ count: value.count,
+ }),
+});
+
+/**
+ * Полностью раскрыть collapsed зону по позиции.
+ * pos — позиция начала collapsed decoration.
+ */
+export const expandAllAtPos: StateEffectType = StateEffect.define({
+ map: (pos, mapping: ChangeDesc) => mapping.mapPos(pos),
+});
+
+// ─── Public API ───
+
+/**
+ * Создаёт Extension для порционного collapse неизменённых зон.
+ *
+ * ВАЖНО: Эта extension НЕ совместима с collapseUnchanged из unifiedMergeView.
+ * Если portionCollapse включён — collapseUnchanged в mergeConfig НЕ должен быть задан.
+ *
+ * @param config — опциональная конфигурация
+ * @returns Extension для добавления в EditorView
+ */
+export function portionCollapseExtension(config?: PortionCollapseConfig): Extension;
+```
+
+### Внутренняя структура
+
+#### PortionCollapseWidget
+
+```typescript
+/**
+ * Widget для отображения collapsed зоны с кнопками "Expand N" / "Expand All".
+ *
+ * Визуально повторяет стиль .cm-collapsedLines из CM's collapseUnchanged,
+ * но с двумя кнопками вместо одной кликабельной полосы.
+ *
+ * Сравнение с CM's CollapseWidget:
+ * - CM: `ignoreEvent(e) { return e instanceof MouseEvent; }` (игнорирует ВСЕ MouseEvent'ы)
+ * - Мы: `ignoreEvent(e) { return e.type === 'mousedown'; }` (игнорируем только mousedown)
+ * - CM: `estimatedHeight` = 27 (фиксированная высота виджета)
+ * - Мы: `estimatedHeight` = 28 (наш виджет чуть выше из-за кнопок)
+ */
+class PortionCollapseWidget extends WidgetType {
+ /**
+ * @param lineCount — количество скрытых строк в этой зоне
+ * @param pos — позиция начала decoration в документе (для dispatch effects)
+ * @param portionSize — количество строк для "Expand N" кнопки
+ */
+ constructor(
+ readonly lineCount: number,
+ readonly pos: number,
+ readonly portionSize: number
+ ) {
+ super();
+ }
+
+ /**
+ * Создаёт DOM для collapsed зоны.
+ *
+ * Структура DOM:
+ * ```html
+ *
+ *
+ * ··· 247 unchanged lines ···
+ *
+ *
+ *
+ * Expand 100
+ *
+ *
+ * Expand All
+ *
+ *
+ *
+ * ```
+ *
+ * Кнопка "Expand N":
+ * - Если lineCount <= portionSize: скрывается (остаётся только "Expand All")
+ * - Если lineCount > portionSize: показывает "Expand {portionSize}"
+ * - При клике: dispatch expandPortion.of({ pos: this.pos, count: this.portionSize })
+ *
+ * Кнопка "Expand All":
+ * - Всегда видна
+ * - При клике: dispatch expandAllAtPos.of(this.pos)
+ *
+ * ВАЖНО: Обе кнопки используют onmousedown (не onclick) с preventDefault()
+ * чтобы предотвратить потерю фокуса CM editor.
+ * Паттерн аналогичен CM's CollapseWidget (addEventListener("click")),
+ * но mousedown + preventDefault надёжнее предотвращает перемещение selection.
+ */
+ toDOM(view: EditorView): HTMLElement {
+ const container = document.createElement('div');
+ container.className = 'cm-portion-collapse';
+
+ // Текст: "··· N unchanged lines ···"
+ const text = document.createElement('span');
+ text.className = 'cm-portion-collapse-text';
+ text.textContent = `\u00B7\u00B7\u00B7 ${this.lineCount} unchanged line${this.lineCount !== 1 ? 's' : ''} \u00B7\u00B7\u00B7`;
+ container.appendChild(text);
+
+ // Actions container
+ const actions = document.createElement('div');
+ actions.className = 'cm-portion-collapse-actions';
+
+ // "Expand N" button (только если lineCount > portionSize)
+ if (this.lineCount > this.portionSize) {
+ const expandBtn = document.createElement('button');
+ expandBtn.className = 'cm-portion-expand-btn';
+ expandBtn.textContent = `Expand ${this.portionSize}`;
+ expandBtn.title = `Show next ${this.portionSize} lines`;
+ expandBtn.onmousedown = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ view.dispatch({
+ effects: expandPortion.of({
+ pos: this.pos,
+ count: this.portionSize,
+ }),
+ });
+ };
+ actions.appendChild(expandBtn);
+ }
+
+ // "Expand All" button (всегда)
+ const expandAllBtn = document.createElement('button');
+ expandAllBtn.className = 'cm-portion-expand-all-btn';
+ expandAllBtn.textContent = 'Expand All';
+ expandAllBtn.title = `Show all ${this.lineCount} unchanged lines`;
+ expandAllBtn.onmousedown = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ view.dispatch({
+ effects: expandAllAtPos.of(this.pos),
+ });
+ };
+ actions.appendChild(expandAllBtn);
+
+ container.appendChild(actions);
+ return container;
+ }
+
+ /**
+ * Сравнение виджетов для оптимизации рендеринга.
+ * CM вызывает eq() при обновлении decorations — если true, DOM не пересоздаётся.
+ *
+ * ВАЖНО: pos НЕ нужно сравнивать в eq(). CM вызывает eq() только для decorations
+ * на ОДИНАКОВЫХ позициях. Если позиция decoration изменилась — это уже другой range,
+ * и CM не вызовет eq(). Сравниваем только визуально-значимые параметры.
+ */
+ eq(other: PortionCollapseWidget): boolean {
+ return (
+ this.lineCount === other.lineCount &&
+ this.portionSize === other.portionSize
+ );
+ }
+
+ /**
+ * Оценка высоты widget для scrollbar.
+ *
+ * CM использует estimatedHeight для замещающих (replace) decorations
+ * чтобы скорректировать scrollbar. Возвращаем высоту самого widget (28px),
+ * а НЕ высоту скрытого контента. CM's CollapseWidget возвращает 27.
+ *
+ * Это корректно: scrollbar должен отражать ВИДИМУЮ высоту документа.
+ * Скрытые строки не занимают места — вместо них виден widget.
+ */
+ get estimatedHeight(): number {
+ return 28;
+ }
+
+ /**
+ * Ignore events: позволяет кнопкам внутри widget обрабатывать клики.
+ * Без этого CM перехватит mousedown и поставит курсор.
+ *
+ * CM's CollapseWidget использует `e instanceof MouseEvent` (блокирует все mouse events).
+ * Мы используем проверку по type для большей точности.
+ */
+ ignoreEvent(event: Event): boolean {
+ return event instanceof MouseEvent;
+ }
+}
+```
+
+#### buildPortionRanges()
+
+```typescript
+/**
+ * Вычисляет ranges для collapsed зон на основе текущих chunks.
+ *
+ * Алгоритм повторяет CM's buildCollapsedRanges() из @codemirror/merge,
+ * но с добавлением portionSize для PortionCollapseWidget.
+ *
+ * Оригинальный алгоритм CM (для справки):
+ * ```javascript
+ * function buildCollapsedRanges(state, margin, minLines) {
+ * let builder = new RangeSetBuilder();
+ * let isA = state.facet(mergeConfig).side == "a";
+ * let chunks = state.field(ChunkField);
+ * let prevLine = 1;
+ * for (let i = 0;; i++) {
+ * let chunk = i < chunks.length ? chunks[i] : null;
+ * let collapseFrom = i ? prevLine + margin : 1;
+ * let collapseTo = chunk
+ * ? state.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - margin
+ * : state.doc.lines;
+ * let lines = collapseTo - collapseFrom + 1;
+ * if (lines >= minLines) {
+ * builder.add(
+ * state.doc.line(collapseFrom).from,
+ * state.doc.line(collapseTo).to,
+ * Decoration.replace({ widget: new CollapseWidget(lines), block: true })
+ * );
+ * }
+ * if (!chunk) break;
+ * prevLine = state.doc.lineAt(Math.min(state.doc.length, isA ? chunk.toA : chunk.toB)).number;
+ * }
+ * return builder.finish();
+ * }
+ * ```
+ *
+ * Ключевые отличия от CM:
+ * 1. Используем getChunks(state) вместо state.field(ChunkField) — public API
+ * 2. Unified view → side="b", поэтому всегда используем fromB/toB
+ * 3. Первая зона: CM начинает с line 1 без margin (collapseFrom = 1 при i=0),
+ * мы делаем то же самое для совместимости
+ * 4. Добавляем portionSize в PortionCollapseWidget
+ *
+ * @param state — текущее состояние EditorState
+ * @param margin — количество строк контекста (default 3)
+ * @param minSize — минимум строк для collapse (default 4)
+ * @param portionSize — строк за "Expand N" (default 100)
+ * @returns DecorationSet с collapsed зонами
+ */
+function buildPortionRanges(
+ state: EditorState,
+ margin: number,
+ minSize: number,
+ portionSize: number
+): DecorationSet {
+ const result = getChunks(state);
+ const doc = state.doc;
+
+ // Если merge view ещё не инициализирован — пустые decorations
+ if (!result) return Decoration.none;
+
+ const chunks = result.chunks;
+ const builder = new RangeSetBuilder();
+
+ // Повторяем алгоритм CM's buildCollapsedRanges для unified view (side="b")
+ let prevLine = 1;
+
+ for (let i = 0; ; i++) {
+ const chunk = i < chunks.length ? chunks[i] : null;
+
+ // Для первой зоны (i=0): начинаем с line 1 БЕЗ margin (как CM)
+ // Для последующих: prevLine + margin
+ const collapseFrom = i ? prevLine + margin : 1;
+
+ // Конец зоны: строка перед началом следующего chunk - margin
+ // Или последняя строка документа (если chunk=null = зона после последнего chunk)
+ const collapseTo = chunk
+ ? doc.lineAt(chunk.fromB).number - 1 - margin
+ : doc.lines;
+
+ const lines = collapseTo - collapseFrom + 1;
+
+ if (lines >= minSize) {
+ const from = doc.line(collapseFrom).from;
+ const to = doc.line(collapseTo).to;
+
+ const widget = new PortionCollapseWidget(lines, from, portionSize);
+
+ builder.add(
+ from,
+ to,
+ Decoration.replace({
+ widget,
+ block: true,
+ })
+ );
+ }
+
+ if (!chunk) break;
+
+ // prevLine = номер строки конца текущего chunk (для вычисления следующей зоны)
+ // Math.min(doc.length, chunk.toB) — защита от toB за пределами документа
+ // (CM Chunk: toB может быть "1 past the end of the last line")
+ prevLine = doc.lineAt(Math.min(doc.length, chunk.toB)).number;
+ }
+
+ return builder.finish();
+}
+```
+
+**Важно про chunks и позиции:**
+
+Chunks из `getChunks(state)` содержат:
+- `fromA / toA` — позиции в original документе (A)
+- `fromB / toB` — позиции в текущем документе (B = EditorView's doc)
+
+Для unified merge view `side` = `"b"` (или `null`), поэтому decorations в документе B. Используем `fromB / toB`.
+
+Из типов `@codemirror/merge`:
+```typescript
+class Chunk {
+ readonly fromA: number; // Start в original doc (character offset, 0-based)
+ readonly toA: number; // End в original doc (1 past end of last line, or = fromA if empty)
+ readonly fromB: number; // Start в current doc
+ readonly toB: number; // End в current doc (1 past end of last line, or = fromB if empty)
+ readonly changes: readonly Change[];
+ readonly precise: boolean;
+ get endA(): number; // fromA if empty, else end of last line (valid doc position)
+ get endB(): number; // fromB if empty, else end of last line (valid doc position)
+}
+```
+
+**ВАЖНО про toA/toB:** Документация CM явно указывает:
+> "Note that `to` positions may point past the end of the document. Use `endA`/`endB` if you need an end position that is certain to be a valid document position."
+
+Поэтому `Math.min(doc.length, chunk.toB)` обязателен при использовании `toB` для `doc.lineAt()`.
+
+Позиции — это OFFSETS в документе (0-based character positions), НЕ номера строк. Конвертация:
+```typescript
+const lineNumber = doc.lineAt(chunk.fromB).number; // 1-based line number
+const lineStart = doc.line(lineNumber).from; // character offset
+```
+
+#### PortionCollapsedField — StateField
+
+```typescript
+/**
+ * StateField хранящий текущие collapsed decorations.
+ *
+ * Обновляется при:
+ * 1. Изменении документа (docChanged) — ремаппинг позиций через map()
+ * 2. expandPortion effect — частичное раскрытие зоны
+ * 3. expandAllAtPos effect — полное раскрытие зоны
+ * 4. updateOriginalDoc effect (accept chunk) — полный rebuild
+ * 5. Lazy init: если create() вернул Decoration.none (chunks не готовы)
+ *
+ * Отличие от CM's CollapsedRanges:
+ * - CM использует .init() для начального build и map+filter в update
+ * - CM НЕ делает rebuild в update (полагается на reconfigure через compartment)
+ * - Мы делаем rebuild при accept/reject потому что portion expand state теряется
+ */
+const PortionCollapsedField = StateField.define({
+ create(state: EditorState): DecorationSet {
+ // getChunks(state) может вернуть null здесь если ChunkField ещё не инициализирован.
+ // Это нормально — buildPortionRanges обработает null и вернёт Decoration.none.
+ // Decorations будут построены при первом update (lazy init).
+ return buildPortionRanges(state, margin, minSize, portionSize);
+ },
+
+ update(value: DecorationSet, tr: Transaction): DecorationSet {
+ // === 1. Expand effects ===
+ let hasExpandEffect = false;
+
+ for (const effect of tr.effects) {
+ if (effect.is(expandPortion)) {
+ hasExpandEffect = true;
+ value = handleExpandPortion(value, effect.value, tr.state, minSize, portionSize);
+ }
+
+ if (effect.is(expandAllAtPos)) {
+ hasExpandEffect = true;
+ value = handleExpandAll(value, effect.value);
+ }
+ }
+
+ if (hasExpandEffect) {
+ return value;
+ }
+
+ // === 2. Accept chunk (updateOriginalDoc) → полный rebuild ===
+ // acceptChunk() dispatch'ит updateOriginalDoc effect БЕЗ docChanged.
+ // Это меняет original doc → chunks пересчитываются → наши decorations невалидны.
+ const hasUpdateOriginalDoc = tr.effects.some(e => e.is(updateOriginalDoc));
+ if (hasUpdateOriginalDoc) {
+ return buildPortionRanges(tr.state, margin, minSize, portionSize);
+ }
+
+ // === 3. Document changed (reject chunk, user editing) ===
+ if (tr.docChanged) {
+ // rejectChunk() делает docChanged (вставляет original текст).
+ // Chunks пересчитываются CM автоматически.
+ // Полный rebuild — корректнее чем map, т.к. chunks изменились.
+ return buildPortionRanges(tr.state, margin, minSize, portionSize);
+ }
+
+ // === 4. Lazy init: create() вернул Decoration.none ===
+ // Это происходит если getChunks() вернул null при create().
+ // После первой транзакции ChunkField уже инициализирован.
+ if (value === Decoration.none) {
+ const chunks = getChunks(tr.state);
+ if (chunks) {
+ return buildPortionRanges(tr.state, margin, minSize, portionSize);
+ }
+ }
+
+ return value;
+ },
+
+ provide(field): Extension {
+ return EditorView.decorations.from(field);
+ },
+});
+```
+
+**Импорт updateOriginalDoc:**
+```typescript
+import { updateOriginalDoc } from '@codemirror/merge';
+```
+
+Этот effect dispatch'ится при `acceptChunk()` — он обновляет original doc, что меняет chunks. Нужен полный rebuild decorations.
+
+**ВАЖНО:** `updateOriginalDoc` уже импортируется в `CodeMirrorDiffUtils.ts` (строка 7), но НЕ реэкспортируется. Два варианта:
+1. Добавить реэкспорт в CodeMirrorDiffUtils.ts: `export { acceptChunk, getChunks, rejectChunk, updateOriginalDoc };`
+2. Импортировать напрямую из `@codemirror/merge` (рекомендуется — updateOriginalDoc это низкоуровневый effect, а не utility)
+
+#### handleExpandPortion()
+
+```typescript
+/**
+ * Обрабатывает частичное раскрытие collapsed зоны.
+ *
+ * Алгоритм:
+ * 1. Найти decoration range, содержащий pos (через DecorationSet.between)
+ * 2. Вычислить новые границы: сдвинуть from на count строк вниз
+ * 3. Если оставшихся строк < minSize — удалить decoration (= expand all)
+ * 4. Иначе — заменить decoration на новый с уменьшенным lineCount и обновлённым pos/from
+ *
+ * Использует DecorationSet.update({ filter, add }) вместо ручной итерации
+ * через RangeSetBuilder — это идиоматичнее и безопаснее.
+ *
+ * @param decorations — текущий DecorationSet
+ * @param value — { pos, count } из expandPortion effect
+ * @param state — текущий EditorState (после transaction)
+ * @param minSize — минимум строк для collapse
+ * @param portionSize — строк для "Expand N" кнопки
+ * @returns обновлённый DecorationSet
+ */
+function handleExpandPortion(
+ decorations: DecorationSet,
+ value: { pos: number; count: number },
+ state: EditorState,
+ minSize: number,
+ portionSize: number
+): DecorationSet {
+ const { pos, count } = value;
+ const doc = state.doc;
+
+ // Поиск decoration, содержащей pos
+ let targetFrom = -1;
+ let targetTo = -1;
+
+ decorations.between(0, doc.length, (from, to) => {
+ if (from <= pos && pos <= to) {
+ targetFrom = from;
+ targetTo = to;
+ return false; // stop iteration
+ }
+ });
+
+ // pos не найден — возвращаем без изменений
+ if (targetFrom < 0) return decorations;
+
+ // Вычисляем строки
+ const fromLine = doc.lineAt(targetFrom).number;
+ const toLine = doc.lineAt(targetTo).number;
+
+ // Новый from = старый from + count строк
+ const newFromLine = fromLine + count;
+ const remainingLines = toLine - newFromLine + 1;
+
+ if (remainingLines < minSize) {
+ // Слишком мало строк осталось — убираем decoration целиком
+ return decorations.update({
+ filter: (from) => from !== targetFrom,
+ });
+ }
+
+ // Убираем старую decoration и добавляем новую с уменьшенным range
+ const newFrom = doc.line(newFromLine).from;
+ const widget = new PortionCollapseWidget(remainingLines, newFrom, portionSize);
+
+ return decorations.update({
+ filter: (from) => from !== targetFrom,
+ add: [
+ Decoration.replace({ widget, block: true }).range(newFrom, targetTo),
+ ],
+ });
+}
+```
+
+#### handleExpandAll()
+
+```typescript
+/**
+ * Обрабатывает полное раскрытие collapsed зоны.
+ *
+ * Использует DecorationSet.update({ filter }) — идиоматичный CM подход.
+ * Аналогично тому, как CM's CollapsedRanges обрабатывает uncollapseUnchanged:
+ * deco.update({ filter: from => from != e.value })
+ *
+ * @param decorations — текущий DecorationSet
+ * @param pos — позиция из expandAllAtPos effect
+ * @returns обновлённый DecorationSet (без удалённой decoration)
+ */
+function handleExpandAll(
+ decorations: DecorationSet,
+ pos: number
+): DecorationSet {
+ return decorations.update({
+ filter: (from, to) => !(from <= pos && pos <= to),
+ });
+}
+```
+
+#### portionCollapseExtension() — реализация
+
+```typescript
+export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
+ const resolvedMargin = config?.margin ?? 3;
+ const resolvedMinSize = config?.minSize ?? 4;
+ const resolvedPortionSize = config?.portionSize ?? 100;
+
+ // Validate
+ if (resolvedMargin < 0) throw new Error('portionCollapse: margin must be >= 0');
+ if (resolvedMinSize < 1) throw new Error('portionCollapse: minSize must be >= 1');
+ if (resolvedPortionSize < 1) throw new Error('portionCollapse: portionSize must be >= 1');
+
+ // Замыкаем config значения для StateField
+ const margin = resolvedMargin;
+ const minSize = resolvedMinSize;
+ const portionSize = resolvedPortionSize;
+
+ // StateField с замыканием на config
+ const field = StateField.define({
+ create(state) {
+ return buildPortionRanges(state, margin, minSize, portionSize);
+ },
+ update(value, tr) {
+ // Полная реализация PortionCollapsedField (см. выше)
+ // с замыканием на margin, minSize, portionSize
+
+ // 1. Expand effects
+ let hasExpandEffect = false;
+ for (const effect of tr.effects) {
+ if (effect.is(expandPortion)) {
+ hasExpandEffect = true;
+ value = handleExpandPortion(value, effect.value, tr.state, minSize, portionSize);
+ }
+ if (effect.is(expandAllAtPos)) {
+ hasExpandEffect = true;
+ value = handleExpandAll(value, effect.value);
+ }
+ }
+ if (hasExpandEffect) return value;
+
+ // 2. Accept (updateOriginalDoc) → rebuild
+ if (tr.effects.some(e => e.is(updateOriginalDoc))) {
+ return buildPortionRanges(tr.state, margin, minSize, portionSize);
+ }
+
+ // 3. docChanged (reject, user edit) → rebuild
+ if (tr.docChanged) {
+ return buildPortionRanges(tr.state, margin, minSize, portionSize);
+ }
+
+ // 4. Lazy init
+ if (value === Decoration.none) {
+ const chunks = getChunks(tr.state);
+ if (chunks) {
+ return buildPortionRanges(tr.state, margin, minSize, portionSize);
+ }
+ }
+
+ return value;
+ },
+ provide(f) {
+ return EditorView.decorations.from(f);
+ },
+ });
+
+ return [field, portionCollapseTheme];
+}
+```
+
+**Дизайн-решение: closure vs Facet.**
+
+Config передаётся через closure в `portionCollapseExtension()`, а не через Facet. Причина: config не меняется после создания editor (только при dynamic reconfigure через Compartment). При reconfigure extension пересоздаётся целиком с новым config.
+
+---
+
+## Модификация CodeMirrorDiffView.tsx
+
+**Файл:** `src/renderer/components/team/review/CodeMirrorDiffView.tsx`
+
+### Новый prop
+
+```typescript
+interface CodeMirrorDiffViewProps {
+ // ... существующие props ...
+
+ /**
+ * Использовать порционный collapse вместо CM's collapseUnchanged.
+ * Когда true: collapseUnchanged НЕ передаётся в mergeConfig.
+ * Вместо этого portionCollapseExtension добавляется отдельно.
+ * Default: false (обратная совместимость).
+ */
+ usePortionCollapse?: boolean;
+
+ /**
+ * Количество строк за одно нажатие "Expand N".
+ * Используется только когда usePortionCollapse=true.
+ * Default: 100
+ */
+ portionSize?: number;
+}
+```
+
+### Новый Compartment для portionCollapse
+
+```typescript
+// Существующий:
+const mergeCompartment = useRef(new Compartment());
+
+// НОВЫЙ:
+const portionCompartment = useRef(new Compartment());
+```
+
+### buildMergeExtension: условное исключение collapseUnchanged
+
+```typescript
+const buildMergeExtension = useCallback(
+ (collapse: boolean, margin: number): Extension => {
+ const mergeConfig: Parameters[0] = {
+ original,
+ highlightChanges: false,
+ gutter: true,
+ syntaxHighlightDeletions: true,
+ };
+
+ // ИЗМЕНЕНИЕ: collapseUnchanged добавляется ТОЛЬКО если portionCollapse выключен
+ if (collapse && !usePortionCollapse) {
+ mergeConfig.collapseUnchanged = {
+ margin,
+ minSize: 4,
+ };
+ }
+
+ // ... mergeControls logic без изменений ...
+
+ return unifiedMergeView(mergeConfig);
+ },
+ [original, showMergeControls, scrollToNextChunk, usePortionCollapse]
+);
+```
+
+### buildExtensions: добавление portionCollapseExtension
+
+```typescript
+const buildExtensions = useCallback(() => {
+ const extensions: Extension[] = [
+ diffTheme,
+ lineNumbers(),
+ syntaxHighlighting(oneDarkHighlightStyle),
+ EditorView.editable.of(!readOnly),
+ EditorState.readOnly.of(readOnly),
+ ];
+
+ // ... существующие extensions (history, keymap, language, merge controls) ...
+
+ // Unified merge view (compartment) — ОБЯЗАТЕЛЬНО ПЕРВЫМ
+ // portionCollapse зависит от ChunkField из merge view
+ extensions.push(
+ mergeCompartment.current.of(
+ buildMergeExtension(collapseRef.current.enabled, collapseRef.current.margin)
+ )
+ );
+
+ // НОВОЕ: Portion collapse (отдельный compartment для dynamic reconfigure)
+ // ОБЯЗАТЕЛЬНО ПОСЛЕ merge view чтобы ChunkField был доступен в create()
+ extensions.push(
+ portionCompartment.current.of(
+ usePortionCollapse && collapseRef.current.enabled
+ ? portionCollapseExtension({
+ margin: collapseRef.current.margin,
+ minSize: 4,
+ portionSize: portionSize ?? 100,
+ })
+ : []
+ )
+ );
+
+ return extensions;
+}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]);
+```
+
+### Dynamic reconfigure: portionCollapse toggle
+
+```typescript
+// Существующий effect для collapse toggle:
+useEffect(() => {
+ const view = viewRef.current;
+ if (!view) return;
+
+ // Merge view reconfigure (без collapseUnchanged если portionCollapse включён)
+ view.dispatch({
+ effects: mergeCompartment.current.reconfigure(
+ buildMergeExtension(collapseUnchangedProp, collapseMargin)
+ ),
+ });
+
+ // НОВОЕ: portionCollapse reconfigure
+ if (usePortionCollapse) {
+ view.dispatch({
+ effects: portionCompartment.current.reconfigure(
+ collapseUnchangedProp
+ ? portionCollapseExtension({
+ margin: collapseMargin,
+ minSize: 4,
+ portionSize: portionSize ?? 100,
+ })
+ : [] // Collapse выключен — убираем portionCollapse decorations
+ ),
+ });
+ }
+}, [collapseUnchangedProp, collapseMargin, buildMergeExtension, usePortionCollapse, portionSize]);
+```
+
+**Оптимизация:** Два dispatch можно объединить в один:
+```typescript
+view.dispatch({
+ effects: [
+ mergeCompartment.current.reconfigure(
+ buildMergeExtension(collapseUnchangedProp, collapseMargin)
+ ),
+ ...(usePortionCollapse
+ ? [portionCompartment.current.reconfigure(
+ collapseUnchangedProp
+ ? portionCollapseExtension({ margin: collapseMargin, minSize: 4, portionSize: portionSize ?? 100 })
+ : []
+ )]
+ : []),
+ ],
+});
+```
+Один dispatch = одна транзакция = один update всех StateField. Это важно для consistency.
+
+**Поведение toggle:**
+- `collapseUnchanged: true` + `usePortionCollapse: true` = portion collapse ВКЛЮЧЁН
+- `collapseUnchanged: false` + `usePortionCollapse: true` = все зоны развёрнуты (portionCollapse off)
+- `collapseUnchanged: true` + `usePortionCollapse: false` = CM's стандартный collapse
+- `collapseUnchanged: false` + `usePortionCollapse: false` = все зоны развёрнуты
+
+### Import
+
+```typescript
+import {
+ portionCollapseExtension,
+} from './portionCollapse';
+```
+
+---
+
+## Стили
+
+### Размещение: отдельная тема в portionCollapse.ts
+
+Стили включаются как часть extension через `portionCollapseTheme` — инкапсулированы рядом с логикой, автоматически включаются/выключаются вместе с extension.
+
+```typescript
+// В portionCollapse.ts
+const portionCollapseTheme = EditorView.theme({
+ '.cm-portion-collapse': {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '4px 12px',
+ backgroundColor: 'var(--color-surface-raised)',
+ borderTop: '1px solid var(--color-border)',
+ borderBottom: '1px solid var(--color-border)',
+ minHeight: '28px',
+ cursor: 'default',
+ userSelect: 'none',
+ },
+
+ '.cm-portion-collapse-text': {
+ fontSize: '12px',
+ color: 'var(--color-text-muted)',
+ letterSpacing: '0.5px',
+ },
+
+ '.cm-portion-collapse-actions': {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px',
+ },
+
+ '.cm-portion-expand-btn': {
+ padding: '2px 10px',
+ fontSize: '11px',
+ fontWeight: '500',
+ lineHeight: '18px',
+ color: 'var(--color-text-secondary)',
+ backgroundColor: 'transparent',
+ border: '1px solid var(--color-border)',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ transition: 'all 0.15s ease',
+ '&:hover': {
+ color: 'var(--color-text)',
+ backgroundColor: 'rgba(255, 255, 255, 0.06)',
+ borderColor: 'var(--color-border-emphasis)',
+ },
+ '&:active': {
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ },
+ },
+
+ '.cm-portion-expand-all-btn': {
+ padding: '2px 10px',
+ fontSize: '11px',
+ fontWeight: '500',
+ lineHeight: '18px',
+ color: 'var(--color-text-muted)',
+ backgroundColor: 'transparent',
+ border: '1px solid transparent',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ transition: 'all 0.15s ease',
+ '&:hover': {
+ color: 'var(--color-text-secondary)',
+ backgroundColor: 'rgba(255, 255, 255, 0.04)',
+ borderColor: 'var(--color-border)',
+ },
+ '&:active': {
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+ },
+ },
+});
+
+// Включается в extension
+export function portionCollapseExtension(config?): Extension {
+ return [field, portionCollapseTheme];
+}
+```
+
+**Почему не в diffTheme:**
+1. Инкапсулирует стили рядом с логикой
+2. Тема автоматически включается/выключается с extension
+3. Не загрязняет diffTheme стилями для feature, который может быть отключён
+4. CM dedup'ит тему если extension добавлена несколько раз
+
+---
+
+## Как portionCollapse получает changed ranges
+
+### Доступные варианты
+
+#### Вариант A: getChunks из @codemirror/merge (public API)
+
+```typescript
+import { getChunks } from '@codemirror/merge';
+```
+
+`getChunks(state)` возвращает `{ chunks: readonly Chunk[], side: "a" | "b" | null } | null`.
+
+- **Плюс:** Официальный public API
+- **Плюс:** Всегда актуальные chunks (обновляются при accept/reject)
+- **Минус:** Может вернуть `null` если merge view ещё не инициализирован
+- **Минус:** `side` в unified view = `"b"` (не `null` — в документации CM: unified = side "b")
+
+#### Вариант B: getChunks() из CodeMirrorDiffUtils.ts
+
+```typescript
+import { getChunks } from './CodeMirrorDiffUtils';
+```
+
+Это реэкспорт `getChunks` из `@codemirror/merge`:
+```typescript
+// CodeMirrorDiffUtils.ts, line 75
+export { acceptChunk, getChunks, rejectChunk };
+```
+
+- **Плюс:** Уже используется в CodeMirrorDiffView.tsx
+- **Плюс:** Единая точка импорта для всех merge utilities
+- **Минус:** Тот же API что вариант A (просто реэкспорт)
+
+#### Вариант C: самостоятельное вычисление через diff
+
+```typescript
+import { Chunk, getOriginalDoc } from '@codemirror/merge';
+
+function computeChangedRanges(state: EditorState): readonly Chunk[] {
+ const original = getOriginalDoc(state);
+ return Chunk.build(original, state.doc);
+}
+```
+
+- **Плюс:** Не зависит от внутреннего ChunkField merge view
+- **Минус:** Дублирование вычислений (chunks считаются дважды)
+- **Минус:** `Chunk.build` может быть дорогим на больших файлах
+
+### Рекомендация: Вариант B
+
+Используем `getChunks()` из `CodeMirrorDiffUtils.ts` — уже проверенный и используемый в проекте. Это реэкспорт official API, но через единую точку проекта.
+
+```typescript
+// portionCollapse.ts
+import { getChunks } from './CodeMirrorDiffUtils';
+```
+
+**Обработка null:**
+```typescript
+const result = getChunks(state);
+if (!result) return Decoration.none; // Merge view ещё не готов
+```
+
+### Порядок extensions и lazy init
+
+`getChunks` вернёт `null` в `create()` если ChunkField ещё не инициализирован. CM вызывает `create()` для всех StateField при создании EditorState. Порядок вызова `create()` определяется порядком extensions.
+
+**Но** даже если merge extension идёт первой в списке, `ChunkField.init()` может ещё не быть applied в момент `create()` нашего field, потому что `init()` работает как override для `create()` и применяется к тому же проходу инициализации.
+
+Поэтому `buildPortionRanges` обрабатывает `null` от `getChunks` и возвращает `Decoration.none`. Lazy init в `update()` StateField исправляет это при первой же транзакции:
+
+```typescript
+update(value, tr) {
+ // Lazy init: если create() вернул Decoration.none потому что chunks были null
+ if (value === Decoration.none) {
+ const chunks = getChunks(tr.state);
+ if (chunks) {
+ return buildPortionRanges(tr.state, margin, minSize, portionSize);
+ }
+ }
+ // ...
+}
+```
+
+**Когда вызовется первый update?** При первом dispatch в EditorView. В CodeMirrorDiffView.tsx после создания view сразу идёт reconfigure для language (`langCompartment.current.reconfigure(syncLang)`) — это transaction, которая триггерит update всех StateField. Поэтому lazy init сработает практически мгновенно.
+
+---
+
+## Edge-cases
+
+### 1. portionCollapse + accept/reject
+
+**Проблема:** При `acceptChunk(view)` CM dispatch'ит `updateOriginalDoc` effect (обновляет original doc). Это меняет ChunkField — collapsed зоны могут стать невалидными. При `rejectChunk(view)` CM dispatch'ит `docChanged` (заменяет текст в document B).
+
+**Решение:** В `update()` StateField:
+- `updateOriginalDoc` effect detected → полный rebuild через `buildPortionRanges()`
+- `tr.docChanged` → полный rebuild через `buildPortionRanges()`
+
+Существующие expanded зоны (пользователь уже нажал "Expand 100") теряются — все collapsed зоны пересоздаются.
+
+**Обоснование:** Accept/reject — редкая операция. Потеря расширенных зон допустима (пользователь expand'ил чтобы посмотреть контекст, а после accept/reject контекст изменился). GitHub ведёт себя аналогично.
+
+**Отличие от CM:** CM's `CollapsedRanges` при accept/reject НЕ делает rebuild в `update()`. CM полагается на reconfigure compartment для пересоздания decorations. Наш подход с rebuild в `update()` проще и не требует внешнего координатора.
+
+### 2. Expand на границе файла
+
+**Проблема:** Collapsed зона в начале файла (строки 1-100). "Expand 100" сдвинет from на 100 строк — зона исчезнет (0 строк). Это нормальное поведение.
+
+**Проблема:** Collapsed зона в конце файла (строки 450-500). "Expand 100" запросит 100 строк, но доступно только 50.
+
+**Решение:** `handleExpandPortion` вычисляет `remainingLines`. Если `remainingLines < minSize` — зона удаляется целиком (эквивалент "Expand All"). Widget показывает `Expand {min(portionSize, lineCount)}` если lineCount < portionSize.
+
+Проверка в PortionCollapseWidget.toDOM():
+```typescript
+if (this.lineCount > this.portionSize) {
+ // Кнопка "Expand {portionSize}"
+} else {
+ // lineCount <= portionSize — показываем только "Expand All"
+ // (кнопка "Expand N" не создаётся — она бы expand'ила всё равно всё)
+}
+```
+
+### 3. Файл целиком новый (isNewFile: true)
+
+**Проблема:** Новый файл = весь контент "inserted". `getChunks` вернёт один chunk покрывающий весь файл. Unchanged зон НЕТ.
+
+**Решение:** `buildPortionRanges` не создаёт decorations если нет промежутков между chunks. Цикл `for (let i = 0; ; i++)` проверяет `lines >= minSize` для каждой зоны — зон нет → `builder.finish()` возвращает пустой `DecorationSet`.
+
+### 4. Reconfigure при toggle collapseUnchanged
+
+**Проблема:** Пользователь включает/выключает collapse через ReviewToolbar toggle.
+
+**Решение:** Dynamic reconfigure через portionCompartment:
+- Toggle ON: `portionCompartment.reconfigure(portionCollapseExtension(config))` — создаётся новый StateField с новыми decorations
+- Toggle OFF: `portionCompartment.reconfigure([])` — StateField удаляется, decorations пропадают
+
+**Важно:** При reconfigure ВСЕ expanded зоны сбрасываются (новый StateField = новые decorations). Это ожидаемо — toggle collapse = "пересоздать все collapsed зоны".
+
+### 5. Конфликт с CM's collapseUnchanged
+
+**Проблема:** Если случайно включены оба (collapseUnchanged в mergeConfig И portionCollapseExtension) — двойные `Decoration.replace` на одних и тех же зонах. Это приведёт к невалидным overlapping replace decorations.
+
+**Решение:** `buildMergeExtension` проверяет `usePortionCollapse` и НЕ добавляет collapseUnchanged если portionCollapse включён. Дополнительно, в документации portionCollapseExtension явно указано: "НЕ совместима с collapseUnchanged".
+
+**Дополнительная защита:** Можно добавить runtime check в `buildPortionRanges`:
+```typescript
+// Если CM's collapseUnchanged уже создал decorations — пропускаем
+// (проверка через наличие .cm-collapsedLines элементов в DOM)
+```
+Но это over-engineering — достаточно документации и проверки в `buildMergeExtension`.
+
+### 6. Очень длинные файлы (10000+ строк)
+
+**Проблема:** Много collapsed зон может замедлить DecorationSet operations.
+
+**Решение:**
+1. `RangeSetBuilder` создаёт balanced B-tree RangeSet эффективно (O(n))
+2. `DecorationSet.update({ filter })` = O(n) для фильтрации
+3. Количество collapsed зон = chunks.length + 1 (максимум)
+4. Типичное количество chunks < 100, поэтому collapsed зон < 101
+5. CM RangeSet оптимизирован для тысяч ranges
+
+### 7. Expand + docChanged одновременно (concurrent editing)
+
+**Проблема:** Пользователь нажимает "Expand 100" в момент когда CM обрабатывает typing transaction.
+
+**Решение:** CM гарантирует атомарность transactions. `expandPortion` effect будет в отдельной transaction. `StateEffect.define({ map })` обеспечивает корректный ремаппинг позиций если document изменился между dispatch и apply.
+
+Сигнатура map callback: `(value: Value, mapping: ChangeDesc) => Value | undefined`. Если map вернёт `undefined` — effect удаляется из transaction. Наш `expandPortion.map` всегда возвращает объект (mapPos не может вернуть undefined), поэтому effect всегда сохраняется.
+
+### 8. updateDOM vs toDOM в PortionCollapseWidget
+
+**Проблема:** CM вызывает `updateDOM(dom, view)` когда widget с `eq() = false` но того же типа. Можно обновить DOM вместо пересоздания.
+
+**Решение:** НЕ реализуем `updateDOM()`. Причина:
+1. Widget пересоздаётся только при expand (нечасто)
+2. `eq()` возвращает true если lineCount/portionSize не изменились — DOM не пересоздаётся
+3. При rebuild decorations (accept/reject) все widgets пересоздаются в любом случае
+4. Сложность updateDOM (изменение текста + показ/скрытие кнопок) не оправдана для редкой операции
+
+### 9. scrollbar высота при collapsed зонах
+
+**Проблема:** CM вычисляет высоту scrollbar на основе видимого контента. Collapsed зоны скрывают строки — scrollbar может стать неточным.
+
+**Решение:** `Decoration.replace({ block: true })` корректно обрабатывается CM для расчёта scrollbar. CM использует `estimatedHeight` widget'а для приблизительной высоты. Наш widget возвращает фиксированную высоту (28px) — это высота видимого widget, не скрытого контента. CM's CollapseWidget возвращает 27px. Scrollbar корректно отражает видимую высоту документа.
+
+### 10. eq() и pos — когда decoration перемещается
+
+**Проблема:** После `map(tr.changes)` позиция decoration может сдвинуться. Виджет хранит `pos` — он станет stale.
+
+**Решение:** `eq()` НЕ сравнивает `pos`. CM вызывает `eq()` только для decorations на ОДНОЙ позиции. Если позиция decoration изменилась после map — это другой range, CM пересоздаёт widget через `toDOM()`. Однако `pos` внутри виджета всё равно может быть stale если decoration map'нулась но eq() вернул true.
+
+**Но:** `pos` используется только в onmousedown для dispatch effect. К моменту клика пользователя — document уже stable, и `pos` совпадает с `from` decoration (потому что виджет создавался с `pos = from`). Если map изменил from — CM пересоздаст виджет (eq = false из-за lineCount изменения или нового toDOM).
+
+Для дополнительной надёжности можно использовать `view.posAtDOM(container)` вместо сохранённого `this.pos`:
+```typescript
+expandBtn.onmousedown = (e) => {
+ e.preventDefault();
+ const pos = view.posAtDOM(container);
+ view.dispatch({ effects: expandPortion.of({ pos, count: this.portionSize }) });
+};
+```
+Это паттерн из CM's CollapseWidget (`view.posAtDOM(e.target)`). **Рекомендуется** для robustness.
+
+---
+
+## Проверка
+
+### Unit тесты
+
+```
+test/renderer/components/team/review/portionCollapse.test.ts
+```
+
+**Тест-кейсы для buildPortionRanges:**
+
+1. **Нет chunks** — getChunks returns null → Decoration.none
+2. **Один chunk в середине** — создаёт 2 collapsed зоны (до и после)
+3. **Chunk в начале файла** — одна collapsed зона после chunk
+4. **Chunk в конце файла** — одна collapsed зона до chunk
+5. **Два chunks рядом** — зона между ними < minSize → не collapse
+6. **Два chunks далеко** — зона между ними >= minSize → collapse с margin
+7. **Весь файл — новый (1 chunk на весь файл)** — пустой DecorationSet
+8. **margin = 0** — collapse начинается сразу после chunk
+9. **minSize = 1** — даже 1 строка сворачивается
+10. **Первая зона (до первого chunk)** — начинается с line 1 без margin (как CM)
+
+**Тест-кейсы для handleExpandPortion:**
+
+11. **Expand 100 строк из 247** — новая decoration с 147 строками, смещённый from
+12. **Expand 100 строк из 103** — 3 строки осталось < minSize(4) → decoration удалена
+13. **Expand 100 строк из 100** — lineCount == portionSize → decoration удалена (< minSize)
+14. **pos не найден в decorations** — decorations без изменений
+
+**Тест-кейсы для handleExpandAll:**
+
+15. **Expand all — decoration удалена** — DecorationSet без этой decoration
+16. **pos не найден** — decorations без изменений
+
+**Тест-кейсы для PortionCollapseWidget:**
+
+17. **toDOM: lineCount > portionSize** — 2 кнопки (Expand N + Expand All)
+18. **toDOM: lineCount <= portionSize** — 1 кнопка (только Expand All)
+19. **toDOM: lineCount = 1** — "1 unchanged line" (singular)
+20. **eq: same lineCount + portionSize** — true
+21. **eq: different lineCount** — false
+22. **ignoreEvent: MouseEvent** — true
+23. **ignoreEvent: KeyboardEvent** — false
+
+**Тест-кейсы для StateField update:**
+
+24. **updateOriginalDoc effect (accept)** — полный rebuild
+25. **docChanged (reject)** — полный rebuild
+26. **expandPortion effect** — partial expand
+27. **expandAllAtPos effect** — полное удаление
+28. **Lazy init: Decoration.none → chunks available** — rebuild
+29. **No-op transaction** — value unchanged
+
+### Ручная проверка
+
+1. Открыть файл с 500+ строк между изменениями
+2. Видна collapsed зона: "... 247 unchanged lines ..."
+3. Кнопка "Expand 100" → зона уменьшается до "... 147 unchanged lines ..."
+4. Повторный "Expand 100" → "... 47 unchanged lines ..." (только Expand All если <= 100)
+5. "Expand All" → зона полностью развёрнута
+6. Accept chunk → collapsed зоны пересчитаны
+7. Toggle collapse off/on → все зоны пересозданы (expanded зоны сброшены)
+8. Файл целиком новый → нет collapsed зон
+9. Маленький файл (< minSize между chunks) → нет collapsed зон
+
+### Визуальная проверка стилей
+
+1. Collapsed зона визуально совпадает с CM's `.cm-collapsedLines` (bg, border, font)
+2. Кнопки: hover → subtle highlight
+3. Кнопки: active → darker highlight
+4. Кнопки не ломают layout при resize окна
+5. Текст "N unchanged lines" корректно обновляется при expand
+
+---
+
+## Файлы
+
+| Файл | Тип | ~LOC |
+|------|-----|---:|
+| `src/renderer/components/team/review/portionCollapse.ts` | NEW | ~300 (StateField + Widget + helpers + theme) |
+| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | ~40 (usePortionCollapse prop, compartment, buildExtensions) |
+| `test/renderer/components/team/review/portionCollapse.test.ts` | NEW | ~300 (29 тест-кейсов) |
+| **Итого** | 2 NEW + 1 MODIFY | ~640 |
diff --git a/docs/iterations/diff-view/continuous-scroll/phase-5-polish.md b/docs/iterations/diff-view/continuous-scroll/phase-5-polish.md
new file mode 100644
index 00000000..ef39aab8
--- /dev/null
+++ b/docs/iterations/diff-view/continuous-scroll/phase-5-polish.md
@@ -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>(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(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
+
+```
+
+### 2.5. Передача Map наружу
+
+ContinuousScrollView передает Map наружу через `useImperativeHandle`:
+
+```typescript
+// ContinuousScrollView.tsx
+export interface ContinuousScrollViewHandle {
+ getEditorViewMap: () => Map;
+ getActiveEditorView: () => EditorView | null;
+}
+
+const ContinuousScrollView = forwardRef(
+ (props, ref) => {
+ const editorViewMapRef = useRef>(new Map());
+
+ useImperativeHandle(ref, () => ({
+ getEditorViewMap: () => editorViewMapRef.current,
+ getActiveEditorView: () => {
+ // Логика определения активного editor (см. секцию 3)
+ return resolveActiveEditorView(editorViewMapRef.current, props.activeFilePath);
+ },
+ }), [props.activeFilePath]);
+
+ // ...
+ }
+);
+```
+
+В ChangeReviewDialog:
+
+```typescript
+const continuousScrollRef = useRef(null);
+
+
+
+// Использование:
+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,
+ 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;
+ 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` -- невидимый `` после 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
(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 (
+
+
+ {/* Sentinel для auto-viewed detection */}
+
+
+);
+```
+
+### 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
+
+```
+
+### 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
+
+ {isContinuousMode
+ ? 'Accept all changes across all files'
+ : 'Accept all changes in current file'}
+
+```
+
+### 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 && (
+
+
+
0 ? (reviewedCount! / totalHunks) * 100 : 0}%` }}
+ />
+
+
+ {reviewedCount} of {totalHunks} reviewed
+
+
+)}
+```
+
+**Позиция в toolbar:** после change stats (`+N -M across K files`), перед separator (`
`).
+
+### 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
(null);
+
+
+```
+
+---
+
+## 7. Per-file discard counter
+
+### 7.1. Проблема
+
+Текущий `discardCounter` -- одно число для всего диалога. При discard оно инкрементируется, и CodeMirrorDiffView пересоздается через `key={filePath}:${discardCounter}`.
+
+В continuous mode каждый файл имеет свой `CodeMirrorDiffView`. Инкремент общего counter пересоздаст ВСЕ EditorView -- это неэффективно и потеряет scroll position.
+
+### 7.2. Решение: Record
+
+```typescript
+// ChangeReviewDialog.tsx
+const [discardCounters, setDiscardCounters] = useState>({});
+```
+
+### 7.3. Использование в FileSectionDiff key
+
+```tsx
+// ContinuousScrollView.tsx — передает counter каждому FileSectionDiff
+{files.map(file => (
+
+))}
+```
+
+Внутри FileSectionDiff, CodeMirrorDiffView:
+
+```tsx
+
+```
+
+### 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` используется только в continuous mode. Оба варианта сосуществуют в ChangeReviewDialog:
+
+```typescript
+// Single-file mode: существующий counter
+const [discardCounter, setDiscardCounter] = useState(0);
+
+// Continuous mode: per-file counters
+const [discardCounters, setDiscardCounters] = useState>({});
+```
+
+---
+
+## 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) |
diff --git a/docs/research/diff-view-audit.md b/docs/research/diff-view-audit.md
new file mode 100644
index 00000000..543d4551
--- /dev/null
+++ b/docs/research/diff-view-audit.md
@@ -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`.
+**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 |
diff --git a/docs/research/diff-view-fix-plans.md b/docs/research/diff-view-fix-plans.md
new file mode 100644
index 00000000..861c100a
--- /dev/null
+++ b/docs/research/diff-view-fix-plans.md
@@ -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` 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();
+
+// 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> {
+ const patch = structuredPatch('file', 'file', original, modified);
+ if (!patch.hunks || patch.hunks.length === 0) return new Map();
+
+ const mapping = new Map>();
+
+ for (const hunkIdx of hunkIndices) {
+ if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
+ const hunk = patch.hunks[hunkIdx];
+ const snippetSet = new Set();
+
+ // 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();
+ 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
+ // Один 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 — **НЕ СУЩЕСТВУЕТ!**
+- **Нужно создать оба перед реализацией**
diff --git a/docs/research/diff-view-round3-research.md b/docs/research/diff-view-round3-research.md
new file mode 100644
index 00000000..c43ed206
--- /dev/null
+++ b/docs/research/diff-view-round3-research.md
@@ -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`, но ключи имеют **двойную семантику**:
+- До 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
diff --git a/docs/research/git.md b/docs/research/git.md
new file mode 100644
index 00000000..390afd4b
--- /dev/null
+++ b/docs/research/git.md
@@ -0,0 +1,466 @@
+# Исследование: встраивание Git UI в Electron + React приложение
+
+> Дата: 2026-02-25
+
+## TL;DR
+
+**Готового ` ` компонента не существует.** Все 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(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
;
+}
+```
+
+| Критерий | Оценка |
+|---|---|
+| Сложность | **Низкая** (~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
+ └──
+```
+
+| Критерий | Оценка |
+|---|---|
+| Сложность | **Низкая** |
+| 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)
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index ad7b62be..dc573831 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -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'),
diff --git a/package.json b/package.json
index 0b74d0d3..2529b301 100644
--- a/package.json
+++ b/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"
+ ]
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8e7e7612..2ecedb1d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,78 @@ importers:
.:
dependencies:
+ '@codemirror/autocomplete':
+ specifier: ^6.20.0
+ version: 6.20.0
+ '@codemirror/commands':
+ specifier: ^6.10.2
+ version: 6.10.2
+ '@codemirror/lang-cpp':
+ specifier: ^6.0.3
+ version: 6.0.3
+ '@codemirror/lang-css':
+ specifier: ^6.3.1
+ version: 6.3.1
+ '@codemirror/lang-go':
+ specifier: ^6.0.1
+ version: 6.0.1
+ '@codemirror/lang-html':
+ specifier: ^6.4.11
+ version: 6.4.11
+ '@codemirror/lang-java':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@codemirror/lang-javascript':
+ specifier: ^6.2.4
+ version: 6.2.4
+ '@codemirror/lang-json':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@codemirror/lang-less':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@codemirror/lang-markdown':
+ specifier: ^6.5.0
+ version: 6.5.0
+ '@codemirror/lang-php':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@codemirror/lang-python':
+ specifier: ^6.2.1
+ version: 6.2.1
+ '@codemirror/lang-rust':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@codemirror/lang-sass':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@codemirror/lang-sql':
+ specifier: ^6.10.0
+ version: 6.10.0
+ '@codemirror/lang-xml':
+ specifier: ^6.1.0
+ version: 6.1.0
+ '@codemirror/lang-yaml':
+ specifier: ^6.1.2
+ version: 6.1.2
+ '@codemirror/language':
+ specifier: ^6.12.1
+ version: 6.12.1
+ '@codemirror/language-data':
+ specifier: ^6.5.2
+ version: 6.5.2
+ '@codemirror/merge':
+ specifier: ^6.12.0
+ version: 6.12.0
+ '@codemirror/state':
+ specifier: ^6.5.4
+ version: 6.5.4
+ '@codemirror/theme-one-dark':
+ specifier: ^6.1.3
+ version: 6.1.3
+ '@codemirror/view':
+ specifier: ^6.39.15
+ version: 6.39.15
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -53,6 +125,12 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.10.8
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@xterm/addon-fit':
+ specifier: ^0.11.0
+ version: 0.11.0
+ '@xterm/xterm':
+ specifier: ^6.0.0
+ version: 6.0.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -65,6 +143,9 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
+ diff:
+ specifier: ^8.0.3
+ version: 8.0.3
electron-updater:
specifier: ^6.7.3
version: 6.7.3
@@ -83,6 +164,12 @@ importers:
mdast-util-to-hast:
specifier: ^13.2.1
version: 13.2.1
+ node-diff3:
+ specifier: ^3.2.0
+ version: 3.2.0
+ node-pty:
+ specifier: ^1.1.0
+ version: 1.1.0
react:
specifier: ^18.3.1
version: 18.3.1
@@ -120,6 +207,9 @@ importers:
specifier: ^4.5.0
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
devDependencies:
+ '@electron/rebuild':
+ specifier: ^4.0.3
+ version: 4.0.3
'@eslint-community/eslint-plugin-eslint-comments':
specifier: ^4.6.0
version: 4.6.0(eslint@9.39.2(jiti@1.21.7))
@@ -357,6 +447,99 @@ packages:
resolution: {integrity: sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==}
engines: {node: '>=18.18'}
+ '@codemirror/autocomplete@6.20.0':
+ resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
+
+ '@codemirror/commands@6.10.2':
+ resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
+
+ '@codemirror/lang-angular@0.1.4':
+ resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
+
+ '@codemirror/lang-cpp@6.0.3':
+ resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
+
+ '@codemirror/lang-css@6.3.1':
+ resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
+
+ '@codemirror/lang-go@6.0.1':
+ resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
+
+ '@codemirror/lang-html@6.4.11':
+ resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
+
+ '@codemirror/lang-java@6.0.2':
+ resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
+
+ '@codemirror/lang-javascript@6.2.4':
+ resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
+
+ '@codemirror/lang-jinja@6.0.0':
+ resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==}
+
+ '@codemirror/lang-json@6.0.2':
+ resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
+
+ '@codemirror/lang-less@6.0.2':
+ resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
+
+ '@codemirror/lang-liquid@6.3.2':
+ resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
+
+ '@codemirror/lang-markdown@6.5.0':
+ resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
+
+ '@codemirror/lang-php@6.0.2':
+ resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
+
+ '@codemirror/lang-python@6.2.1':
+ resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
+
+ '@codemirror/lang-rust@6.0.2':
+ resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
+
+ '@codemirror/lang-sass@6.0.2':
+ resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
+
+ '@codemirror/lang-sql@6.10.0':
+ resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
+
+ '@codemirror/lang-vue@0.1.3':
+ resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
+
+ '@codemirror/lang-wast@6.0.2':
+ resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
+
+ '@codemirror/lang-xml@6.1.0':
+ resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
+
+ '@codemirror/lang-yaml@6.1.2':
+ resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
+
+ '@codemirror/language-data@6.5.2':
+ resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
+
+ '@codemirror/language@6.12.1':
+ resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
+
+ '@codemirror/legacy-modes@6.5.2':
+ resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
+
+ '@codemirror/lint@6.9.4':
+ resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
+
+ '@codemirror/merge@6.12.0':
+ resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==}
+
+ '@codemirror/state@6.5.4':
+ resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
+
+ '@codemirror/theme-one-dark@6.1.3':
+ resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
+
+ '@codemirror/view@6.39.15':
+ resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
+
'@develar/schema-utils@2.6.5':
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'}
@@ -415,6 +598,11 @@ packages:
engines: {node: '>=12.13.0'}
hasBin: true
+ '@electron/rebuild@4.0.3':
+ resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==}
+ engines: {node: '>=22.12.0'}
+ hasBin: true
+
'@electron/universal@1.5.1':
resolution: {integrity: sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==}
engines: {node: '>=8.6'}
@@ -846,6 +1034,10 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
+ '@isaacs/fs-minipass@4.0.1':
+ resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+ engines: {node: '>=18.0.0'}
+
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
@@ -869,6 +1061,57 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@lezer/common@1.5.1':
+ resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
+
+ '@lezer/cpp@1.1.5':
+ resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
+
+ '@lezer/css@1.3.1':
+ resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
+
+ '@lezer/go@1.0.1':
+ resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
+
+ '@lezer/highlight@1.2.3':
+ resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
+
+ '@lezer/html@1.3.13':
+ resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
+
+ '@lezer/java@1.1.3':
+ resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
+
+ '@lezer/javascript@1.5.4':
+ resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
+
+ '@lezer/json@1.0.3':
+ resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
+
+ '@lezer/lr@1.4.8':
+ resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
+
+ '@lezer/markdown@1.6.3':
+ resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
+
+ '@lezer/php@1.0.5':
+ resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
+
+ '@lezer/python@1.1.18':
+ resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
+
+ '@lezer/rust@1.0.2':
+ resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
+
+ '@lezer/sass@1.1.0':
+ resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
+
+ '@lezer/xml@1.0.6':
+ resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
+
+ '@lezer/yaml@1.0.4':
+ resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
+
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
@@ -885,6 +1128,9 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'}
+ '@marijn/find-cluster-break@1.0.2':
+ resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -903,10 +1149,18 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@npmcli/agent@3.0.0':
+ resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
'@npmcli/fs@2.1.2':
resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ '@npmcli/fs@4.0.0':
+ resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
'@npmcli/move-file@2.0.1':
resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -1876,9 +2130,19 @@ packages:
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
+ '@xterm/addon-fit@0.11.0':
+ resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
+
+ '@xterm/xterm@6.0.0':
+ resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
+
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+ abbrev@3.0.1:
+ resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -2217,6 +2481,10 @@ packages:
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ cacache@19.0.1:
+ resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
cacheable-lookup@5.0.4:
resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
engines: {node: '>=10.6.0'}
@@ -2283,6 +2551,10 @@ packages:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
+ chownr@3.0.0:
+ resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+ engines: {node: '>=18'}
+
chromium-pickle-js@0.2.0:
resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==}
@@ -2427,6 +2699,9 @@ packages:
crc@3.8.0:
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
+ crelt@1.0.6:
+ resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2534,6 +2809,10 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+ diff@8.0.3:
+ resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
+ engines: {node: '>=0.3.1'}
+
dir-compare@3.3.0:
resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==}
@@ -3007,6 +3286,10 @@ packages:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
+ fs-minipass@3.0.3:
+ resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==}
+ engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -3454,6 +3737,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isexe@3.1.5:
+ resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
+ engines: {node: '>=18'}
+
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -3688,6 +3975,10 @@ packages:
resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ make-fetch-happen@14.0.3:
+ resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -3896,10 +4187,18 @@ packages:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'}
+ minipass-collect@2.0.1:
+ resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
minipass-fetch@2.1.2:
resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ minipass-fetch@4.0.1:
+ resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
minipass-flush@1.0.5:
resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==}
engines: {node: '>= 8'}
@@ -3928,6 +4227,10 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
+ minizlib@3.1.0:
+ resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
+ engines: {node: '>= 18'}
+
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@@ -3963,6 +4266,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -3970,17 +4277,36 @@ packages:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
+ node-abi@4.26.0:
+ resolution: {integrity: sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==}
+ engines: {node: '>=22.12.0'}
+
node-addon-api@1.7.2:
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
+ node-addon-api@7.1.1:
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
+ node-diff3@3.2.0:
+ resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==}
+ engines: {bun: '>=1.3.0', node: '>=18'}
+
+ node-gyp@11.5.0:
+ resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+ hasBin: true
+
node-gyp@9.4.1:
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
engines: {node: ^12.13 || ^14.13 || >=16}
hasBin: true
+ node-pty@1.1.0:
+ resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
+
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -3989,6 +4315,11 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
hasBin: true
+ nopt@8.1.0:
+ resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+ hasBin: true
+
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -4084,6 +4415,10 @@ packages:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
+ p-map@7.0.4:
+ resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
+ engines: {node: '>=18'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -4288,6 +4623,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ proc-log@5.0.0:
+ resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -4674,6 +5013,10 @@ packages:
resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==}
engines: {node: '>= 10'}
+ socks-proxy-agent@8.0.5:
+ resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
+ engines: {node: '>= 14'}
+
socks@2.8.7:
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
@@ -4709,6 +5052,10 @@ packages:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
+ ssri@12.0.0:
+ resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
ssri@9.0.1:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -4810,6 +5157,9 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+ style-mod@4.1.3:
+ resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -4855,6 +5205,10 @@ packages:
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ tar@7.5.9:
+ resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==}
+ engines: {node: '>=18'}
+
temp-file@3.4.0:
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
@@ -5015,10 +5369,18 @@ packages:
resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ unique-filename@4.0.0:
+ resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
unique-slug@3.0.0:
resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ unique-slug@5.0.0:
+ resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
@@ -5162,6 +5524,9 @@ packages:
jsdom:
optional: true
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
walk-up-path@4.0.0:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
@@ -5194,6 +5559,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
+ which@5.0.0:
+ resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+ hasBin: true
+
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -5238,6 +5608,10 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+ yallist@5.0.0:
+ resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+ engines: {node: '>=18'}
+
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
@@ -5433,6 +5807,263 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
+ '@codemirror/autocomplete@6.20.0':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+
+ '@codemirror/commands@6.10.2':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+
+ '@codemirror/lang-angular@0.1.4':
+ dependencies:
+ '@codemirror/lang-html': 6.4.11
+ '@codemirror/lang-javascript': 6.2.4
+ '@codemirror/language': 6.12.1
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/lang-cpp@6.0.3':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@lezer/cpp': 1.1.5
+
+ '@codemirror/lang-css@6.3.1':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/css': 1.3.1
+
+ '@codemirror/lang-go@6.0.1':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/go': 1.0.1
+
+ '@codemirror/lang-html@6.4.11':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/lang-css': 6.3.1
+ '@codemirror/lang-javascript': 6.2.4
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+ '@lezer/css': 1.3.1
+ '@lezer/html': 1.3.13
+
+ '@codemirror/lang-java@6.0.2':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@lezer/java': 1.1.3
+
+ '@codemirror/lang-javascript@6.2.4':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/lint': 6.9.4
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+ '@lezer/javascript': 1.5.4
+
+ '@codemirror/lang-jinja@6.0.0':
+ dependencies:
+ '@codemirror/lang-html': 6.4.11
+ '@codemirror/language': 6.12.1
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/lang-json@6.0.2':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@lezer/json': 1.0.3
+
+ '@codemirror/lang-less@6.0.2':
+ dependencies:
+ '@codemirror/lang-css': 6.3.1
+ '@codemirror/language': 6.12.1
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/lang-liquid@6.3.2':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/lang-html': 6.4.11
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/lang-markdown@6.5.0':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/lang-html': 6.4.11
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+ '@lezer/markdown': 1.6.3
+
+ '@codemirror/lang-php@6.0.2':
+ dependencies:
+ '@codemirror/lang-html': 6.4.11
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/php': 1.0.5
+
+ '@codemirror/lang-python@6.2.1':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/python': 1.1.18
+
+ '@codemirror/lang-rust@6.0.2':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@lezer/rust': 1.0.2
+
+ '@codemirror/lang-sass@6.0.2':
+ dependencies:
+ '@codemirror/lang-css': 6.3.1
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/sass': 1.1.0
+
+ '@codemirror/lang-sql@6.10.0':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/lang-vue@0.1.3':
+ dependencies:
+ '@codemirror/lang-html': 6.4.11
+ '@codemirror/lang-javascript': 6.2.4
+ '@codemirror/language': 6.12.1
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/lang-wast@6.0.2':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/lang-xml@6.1.0':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+ '@lezer/xml': 1.0.6
+
+ '@codemirror/lang-yaml@6.1.2':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+ '@lezer/yaml': 1.0.4
+
+ '@codemirror/language-data@6.5.2':
+ dependencies:
+ '@codemirror/lang-angular': 0.1.4
+ '@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-jinja': 6.0.0
+ '@codemirror/lang-json': 6.0.2
+ '@codemirror/lang-less': 6.0.2
+ '@codemirror/lang-liquid': 6.3.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-vue': 0.1.3
+ '@codemirror/lang-wast': 6.0.2
+ '@codemirror/lang-xml': 6.1.0
+ '@codemirror/lang-yaml': 6.1.2
+ '@codemirror/language': 6.12.1
+ '@codemirror/legacy-modes': 6.5.2
+
+ '@codemirror/language@6.12.1':
+ dependencies:
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+ style-mod: 4.1.3
+
+ '@codemirror/legacy-modes@6.5.2':
+ dependencies:
+ '@codemirror/language': 6.12.1
+
+ '@codemirror/lint@6.9.4':
+ dependencies:
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ crelt: 1.0.6
+
+ '@codemirror/merge@6.12.0':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/highlight': 1.2.3
+ style-mod: 4.1.3
+
+ '@codemirror/state@6.5.4':
+ dependencies:
+ '@marijn/find-cluster-break': 1.0.2
+
+ '@codemirror/theme-one-dark@6.1.3':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.15
+ '@lezer/highlight': 1.2.3
+
+ '@codemirror/view@6.39.15':
+ dependencies:
+ '@codemirror/state': 6.5.4
+ crelt: 1.0.6
+ style-mod: 4.1.3
+ w3c-keyname: 2.2.8
+
'@develar/schema-utils@2.6.5':
dependencies:
ajv: 6.14.0
@@ -5541,6 +6172,24 @@ snapshots:
- bluebird
- supports-color
+ '@electron/rebuild@4.0.3':
+ dependencies:
+ '@malept/cross-spawn-promise': 2.0.0
+ debug: 4.4.3
+ detect-libc: 2.1.2
+ got: 11.8.6
+ graceful-fs: 4.2.11
+ node-abi: 4.26.0
+ node-api-version: 0.2.1
+ node-gyp: 11.5.0
+ ora: 5.4.1
+ read-binary-file-arch: 1.0.6
+ semver: 7.7.4
+ tar: 7.5.9
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - supports-color
+
'@electron/universal@1.5.1':
dependencies:
'@electron/asar': 3.4.1
@@ -5872,6 +6521,10 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
+ '@isaacs/fs-minipass@4.0.1':
+ dependencies:
+ minipass: 7.1.2
+
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.13':
@@ -5899,6 +6552,99 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@lezer/common@1.5.1': {}
+
+ '@lezer/cpp@1.1.5':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/css@1.3.1':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/go@1.0.1':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/highlight@1.2.3':
+ dependencies:
+ '@lezer/common': 1.5.1
+
+ '@lezer/html@1.3.13':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/java@1.1.3':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/javascript@1.5.4':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/json@1.0.3':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/lr@1.4.8':
+ dependencies:
+ '@lezer/common': 1.5.1
+
+ '@lezer/markdown@1.6.3':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+
+ '@lezer/php@1.0.5':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/python@1.1.18':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/rust@1.0.2':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/sass@1.1.0':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/xml@1.0.6':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@lezer/yaml@1.0.4':
+ dependencies:
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
'@lukeed/ms@2.0.2': {}
'@malept/cross-spawn-promise@1.1.1':
@@ -5918,6 +6664,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@marijn/find-cluster-break@1.0.2': {}
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@@ -5944,10 +6692,24 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
+ '@npmcli/agent@3.0.0':
+ dependencies:
+ agent-base: 7.1.4
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ lru-cache: 10.4.3
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
'@npmcli/fs@2.1.2':
dependencies:
'@gar/promisify': 1.1.3
- semver: 7.7.3
+ semver: 7.7.4
+
+ '@npmcli/fs@4.0.0':
+ dependencies:
+ semver: 7.7.4
'@npmcli/move-file@2.0.1':
dependencies:
@@ -6860,8 +7622,14 @@ snapshots:
'@xmldom/xmldom@0.8.11': {}
+ '@xterm/addon-fit@0.11.0': {}
+
+ '@xterm/xterm@6.0.0': {}
+
abbrev@1.1.1: {}
+ abbrev@3.0.1: {}
+
abstract-logging@2.0.1: {}
acorn-jsx@5.3.2(acorn@8.15.0):
@@ -7347,6 +8115,21 @@ snapshots:
transitivePeerDependencies:
- bluebird
+ cacache@19.0.1:
+ dependencies:
+ '@npmcli/fs': 4.0.0
+ fs-minipass: 3.0.3
+ glob: 10.5.0
+ lru-cache: 10.4.3
+ minipass: 7.1.2
+ minipass-collect: 2.0.1
+ minipass-flush: 1.0.5
+ minipass-pipeline: 1.2.4
+ p-map: 7.0.4
+ ssri: 12.0.0
+ tar: 7.5.9
+ unique-filename: 4.0.0
+
cacheable-lookup@5.0.4: {}
cacheable-request@7.0.4:
@@ -7421,6 +8204,8 @@ snapshots:
chownr@2.0.0: {}
+ chownr@3.0.0: {}
+
chromium-pickle-js@0.2.0: {}
ci-info@3.9.0: {}
@@ -7555,6 +8340,8 @@ snapshots:
buffer: 5.7.1
optional: true
+ crelt@1.0.6: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -7646,6 +8433,8 @@ snapshots:
didyoumean@1.2.2: {}
+ diff@8.0.3: {}
+
dir-compare@3.3.0:
dependencies:
buffer-equal: 1.0.1
@@ -8405,6 +9194,10 @@ snapshots:
dependencies:
minipass: 3.3.6
+ fs-minipass@3.0.3:
+ dependencies:
+ minipass: 7.1.2
+
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@@ -8898,6 +9691,8 @@ snapshots:
isexe@2.0.0: {}
+ isexe@3.1.5: {}
+
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -9166,6 +9961,22 @@ snapshots:
- bluebird
- supports-color
+ make-fetch-happen@14.0.3:
+ dependencies:
+ '@npmcli/agent': 3.0.0
+ cacache: 19.0.1
+ http-cache-semantics: 4.2.0
+ minipass: 7.1.2
+ minipass-fetch: 4.0.1
+ minipass-flush: 1.0.5
+ minipass-pipeline: 1.2.4
+ negotiator: 1.0.0
+ proc-log: 5.0.0
+ promise-retry: 2.0.1
+ ssri: 12.0.0
+ transitivePeerDependencies:
+ - supports-color
+
markdown-table@3.0.4: {}
matcher@3.0.0:
@@ -9570,6 +10381,10 @@ snapshots:
dependencies:
minipass: 3.3.6
+ minipass-collect@2.0.1:
+ dependencies:
+ minipass: 7.1.2
+
minipass-fetch@2.1.2:
dependencies:
minipass: 3.3.6
@@ -9578,6 +10393,14 @@ snapshots:
optionalDependencies:
encoding: 0.1.13
+ minipass-fetch@4.0.1:
+ dependencies:
+ minipass: 7.1.2
+ minipass-sized: 1.0.3
+ minizlib: 3.1.0
+ optionalDependencies:
+ encoding: 0.1.13
+
minipass-flush@1.0.5:
dependencies:
minipass: 3.3.6
@@ -9603,6 +10426,10 @@ snapshots:
minipass: 3.3.6
yallist: 4.0.0
+ minizlib@3.1.0:
+ dependencies:
+ minipass: 7.1.2
+
mkdirp@1.0.4: {}
ms@2.1.3: {}
@@ -9626,18 +10453,43 @@ snapshots:
negotiator@0.6.4: {}
+ negotiator@1.0.0: {}
+
neo-async@2.6.2: {}
node-abi@3.87.0:
dependencies:
- semver: 7.7.3
+ semver: 7.7.4
+
+ node-abi@4.26.0:
+ dependencies:
+ semver: 7.7.4
node-addon-api@1.7.2:
optional: true
+ node-addon-api@7.1.1: {}
+
node-api-version@0.2.1:
dependencies:
- semver: 7.7.3
+ semver: 7.7.4
+
+ node-diff3@3.2.0: {}
+
+ node-gyp@11.5.0:
+ dependencies:
+ env-paths: 2.2.1
+ exponential-backoff: 3.1.3
+ graceful-fs: 4.2.11
+ make-fetch-happen: 14.0.3
+ nopt: 8.1.0
+ proc-log: 5.0.0
+ semver: 7.7.4
+ tar: 7.5.9
+ tinyglobby: 0.2.15
+ which: 5.0.0
+ transitivePeerDependencies:
+ - supports-color
node-gyp@9.4.1:
dependencies:
@@ -9649,19 +10501,27 @@ snapshots:
nopt: 6.0.0
npmlog: 6.0.2
rimraf: 3.0.2
- semver: 7.7.3
+ semver: 7.7.4
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
- bluebird
- supports-color
+ node-pty@1.1.0:
+ dependencies:
+ node-addon-api: 7.1.1
+
node-releases@2.0.27: {}
nopt@6.0.0:
dependencies:
abbrev: 1.1.1
+ nopt@8.1.0:
+ dependencies:
+ abbrev: 3.0.1
+
normalize-path@3.0.0: {}
normalize-url@6.1.0: {}
@@ -9795,6 +10655,8 @@ snapshots:
dependencies:
aggregate-error: 3.1.0
+ p-map@7.0.4: {}
+
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
@@ -9929,6 +10791,8 @@ snapshots:
prettier@3.8.1: {}
+ proc-log@5.0.0: {}
+
process-nextick-args@2.0.1: {}
process-warning@4.0.1: {}
@@ -10402,6 +11266,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ socks-proxy-agent@8.0.5:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ socks: 2.8.7
+ transitivePeerDependencies:
+ - supports-color
+
socks@2.8.7:
dependencies:
ip-address: 10.1.0
@@ -10437,6 +11309,10 @@ snapshots:
cpu-features: 0.0.10
nan: 2.25.0
+ ssri@12.0.0:
+ dependencies:
+ minipass: 7.1.2
+
ssri@9.0.1:
dependencies:
minipass: 3.3.6
@@ -10562,6 +11438,8 @@ snapshots:
dependencies:
js-tokens: 9.0.1
+ style-mod@4.1.3: {}
+
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -10643,6 +11521,14 @@ snapshots:
mkdirp: 1.0.4
yallist: 4.0.0
+ tar@7.5.9:
+ dependencies:
+ '@isaacs/fs-minipass': 4.0.1
+ chownr: 3.0.0
+ minipass: 7.1.2
+ minizlib: 3.1.0
+ yallist: 5.0.0
+
temp-file@3.4.0:
dependencies:
async-exit-hook: 2.0.1
@@ -10820,10 +11706,18 @@ snapshots:
dependencies:
unique-slug: 3.0.0
+ unique-filename@4.0.0:
+ dependencies:
+ unique-slug: 5.0.0
+
unique-slug@3.0.0:
dependencies:
imurmurhash: 0.1.4
+ unique-slug@5.0.0:
+ dependencies:
+ imurmurhash: 0.1.4
+
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
@@ -10998,6 +11892,8 @@ snapshots:
- supports-color
- terser
+ w3c-keyname@2.2.8: {}
+
walk-up-path@4.0.0: {}
wcwidth@1.0.1:
@@ -11051,6 +11947,10 @@ snapshots:
dependencies:
isexe: 2.0.0
+ which@5.0.0:
+ dependencies:
+ isexe: 3.1.5
+
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -11092,6 +11992,8 @@ snapshots:
yallist@4.0.0: {}
+ yallist@5.0.0: {}
+
yaml@2.8.2: {}
yargs-parser@21.1.1: {}
diff --git a/resources/pricing.json b/resources/pricing.json
index 9687baaa..077d4681 100644
--- a/resources/pricing.json
+++ b/resources/pricing.json
@@ -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",
diff --git a/src/main/index.ts b/src/main/index.ts
index 9a4dd3b9..4547d15c 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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();
+/** Debounce per-inbox to avoid flooding during batch writes. */
+const inboxNotifyTimers = new Map>();
+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 {
+ 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 {
+ // 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');
}
diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts
new file mode 100644
index 00000000..5db69900
--- /dev/null
+++ b/src/main/ipc/cliInstaller.ts
@@ -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> {
+ 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> {
+ 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 };
+ }
+}
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts
index ba372729..711c6b7d 100644
--- a/src/main/ipc/handlers.ts
+++ b/src/main/ipc/handlers.ts
@@ -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;
- }
+ },
+ 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');
diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts
new file mode 100644
index 00000000..20c56752
--- /dev/null
+++ b/src/main/ipc/review.ts
@@ -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(
+ operation: string,
+ handler: () => Promise
+): Promise> {
+ 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> {
+ return wrapReviewHandler('getAgentChanges', () =>
+ getChangeExtractor().getAgentChanges(teamName, memberName)
+ );
+}
+
+async function handleGetTaskChanges(
+ _event: IpcMainInvokeEvent,
+ teamName: string,
+ taskId: string
+): Promise> {
+ return wrapReviewHandler('getTaskChanges', () =>
+ getChangeExtractor().getTaskChanges(teamName, taskId)
+ );
+}
+
+async function handleGetChangeStats(
+ _event: IpcMainInvokeEvent,
+ teamName: string,
+ memberName: string
+): Promise> {
+ return wrapReviewHandler('getChangeStats', () =>
+ getChangeExtractor().getChangeStats(teamName, memberName)
+ );
+}
+
+// --- Phase 2 Handlers ---
+
+async function handleCheckConflict(
+ _event: IpcMainInvokeEvent,
+ filePath: string,
+ expectedModified: string
+): Promise> {
+ 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> {
+ 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> {
+ 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> {
+ return wrapReviewHandler('previewReject', () =>
+ getApplier().previewReject(filePath, original, modified, hunkIndices, snippets)
+ );
+}
+
+async function handleApplyDecisions(
+ _event: IpcMainInvokeEvent,
+ request: ApplyReviewRequest
+): Promise> {
+ 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> {
+ return wrapReviewHandler('getFileContent', () =>
+ getContentResolver().getFileContent(teamName, memberName, filePath, snippets)
+ );
+}
+
+// --- Editable diff Handlers ---
+
+async function handleSaveEditedFile(
+ _event: IpcMainInvokeEvent,
+ filePath: string,
+ content: string
+): Promise> {
+ 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> {
+ 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;
+ fileDecisions: Record;
+ } | null>
+> {
+ return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey));
+}
+
+async function handleSaveDecisions(
+ _event: IpcMainInvokeEvent,
+ teamName: string,
+ scopeKey: string,
+ hunkDecisions: Record,
+ fileDecisions: Record
+): Promise> {
+ return wrapReviewHandler('saveDecisions', () =>
+ reviewDecisionStore.save(teamName, scopeKey, { hunkDecisions, fileDecisions })
+ );
+}
+
+async function handleClearDecisions(
+ _event: IpcMainInvokeEvent,
+ teamName: string,
+ scopeKey: string
+): Promise> {
+ return wrapReviewHandler('clearDecisions', () => reviewDecisionStore.clear(teamName, scopeKey));
+}
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index fec1ff32..df419c15 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -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> {
+ 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> {
+ 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> {
+ 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> {
+ 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> {
+ 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> {
+ 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 getTeamProvisioningService().getAliveTeams());
}
+async function handleLeadActivity(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown
+): Promise> {
+ 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> {
+ 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> {
+ 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,
diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts
new file mode 100644
index 00000000..784959c7
--- /dev/null
+++ b/src/main/ipc/terminal.ts
@@ -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> {
+ 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 };
+ }
+}
diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts
index 05f08390..2746fe35 100644
--- a/src/main/ipc/utility.ts
+++ b/src/main/ipc/utility.ts
@@ -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.
diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts
new file mode 100644
index 00000000..fc211670
--- /dev/null
+++ b/src/main/services/infrastructure/CliInstallerService.ts
@@ -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 {
+ 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 {
+ 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(url: string): Promise {
+ 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;
+}
+
+// =============================================================================
+// 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 {
+ 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 {
+ 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(`${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 {
+ 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((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 {
+ return new Promise((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 {
+ try {
+ await fsp.unlink(filePath);
+ } catch {
+ // Ignore — file may already be cleaned up
+ }
+ }
+}
diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts
index fb86201a..e6f91af2 100644
--- a/src/main/services/infrastructure/FileWatcher.ts
+++ b/src/main/services/infrastructure/FileWatcher.ts
@@ -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 = {
diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts
new file mode 100644
index 00000000..f03bd429
--- /dev/null
+++ b/src/main/services/infrastructure/PtyTerminalService.ts
@@ -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();
+ 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,
+ });
+
+ 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);
+ }
+ }
+}
diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts
index 52a8840b..0d69b3c2 100644
--- a/src/main/services/infrastructure/index.ts
+++ b/src/main/services/infrastructure/index.ts
@@ -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';
diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts
new file mode 100644
index 00000000..ba58bdc6
--- /dev/null
+++ b/src/main/services/team/ChangeExtractorService.ts
@@ -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();
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // Сначала считываем все записи в память для двух проходов
+ const entries: Record[] = [];
+
+ 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);
+ } 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();
+
+ 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).type !== 'tool_use'
+ ) {
+ continue;
+ }
+
+ const toolBlock = block as Record;
+ 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 | 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;
+ 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): unknown[] | null {
+ const message = entry.message as Record | 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 | null {
+ if (typeof entry.role === 'string') return entry.role;
+ const message = entry.message as Record | undefined;
+ if (message && typeof message.role === 'string') return message.role;
+ return null;
+ }
+
+ /** Собрать errored tool_use_ids из tool_result блоков */
+ private collectErroredToolUseIds(entries: Record[]): Set {
+ const erroredIds = new Set();
+
+ 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).tool_use_id;
+ if (typeof toolUseId === 'string') {
+ erroredIds.add(toolUseId);
+ }
+ }
+ }
+ }
+
+ // Также проверяем entry.message.content
+ const message = entry.message as Record | undefined;
+ if (message && Array.isArray(message.content)) {
+ for (const block of message.content) {
+ if (this.isErroredToolResult(block)) {
+ const toolUseId = (block as Record).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;
+ return obj.type === 'tool_result' && obj.is_error === true;
+ }
+
+ /** Агрегировать snippets в FileChangeSummary[] */
+ private aggregateByFile(snippets: SnippetDiff[], projectPath?: string): FileChangeSummary[] {
+ const fileMap = new Map();
+
+ 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): 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 {
+ const refs: LogFileRef[] = [];
+ const byMember = new Map();
+ 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,
+ projectPath?: string
+ ): Promise {
+ 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 {
+ 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 {
+ 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.'],
+ };
+ }
+}
diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts
index d8f6f580..a897975c 100644
--- a/src/main/services/team/ClaudeBinaryResolver.ts
+++ b/src/main/services/team/ClaudeBinaryResolver.ts
@@ -135,6 +135,14 @@ async function resolveFromExplicitPath(inputPath: string): Promise {
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'
diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts
new file mode 100644
index 00000000..4b1f86fb
--- /dev/null
+++ b/src/main/services/team/FileContentResolver.ts
@@ -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();
+ 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 {
+ 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> {
+ const results = new Map();
+
+ // 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 {
+ 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 {
+ 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;
+ if (entry.type !== 'file-history-snapshot') continue;
+
+ const snapshot = entry.snapshot as Record | undefined;
+ if (!snapshot) continue;
+
+ const trackedFileBackups = snapshot.trackedFileBackups as
+ | Record
+ | 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 {
+ 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,
+ });
+ }
+}
diff --git a/src/main/services/team/GitDiffFallback.ts b/src/main/services/team/GitDiffFallback.ts
new file mode 100644
index 00000000..bfe2a3d6
--- /dev/null
+++ b/src/main/services/team/GitDiffFallback.ts
@@ -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();
+
+ /**
+ * Get file contents at a specific commit.
+ * Used when file-history-snapshot is unavailable.
+ */
+ async getFileAtCommit(
+ projectPath: string,
+ filePath: string,
+ commitHash: string
+ ): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+ }
+}
diff --git a/src/main/services/team/HunkSnippetMatcher.ts b/src/main/services/team/HunkSnippetMatcher.ts
new file mode 100644
index 00000000..5aa9d04c
--- /dev/null
+++ b/src/main/services/team/HunkSnippetMatcher.ts
@@ -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> {
+ 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>();
+
+ for (const hunkIdx of hunkIndices) {
+ if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
+ const hunk = patch.hunks[hunkIdx];
+ const snippetSet = new Set();
+
+ // 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;
+ }
+}
diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts
index b1215a22..6443436b 100644
--- a/src/main/services/team/MemberStatsComputer.ts
+++ b/src/main/services/team/MemberStatsComputer.ts
@@ -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();
+ const perFileStats: Record = {};
const toolUsage: Record = {};
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;
toolUsage: Record;
inputTokens: number;
outputTokens: number;
@@ -88,6 +109,7 @@ export class MemberStatsComputer {
let linesAdded = 0;
let linesRemoved = 0;
const filesTouchedSet = new Set();
+ const perFileStats: Record = {};
const toolUsage: Record = {};
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();
+
+ 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];
diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts
new file mode 100644
index 00000000..1b47be59
--- /dev/null
+++ b/src/main/services/team/ReviewApplierService.ts
@@ -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 {
+ 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 {
+ // 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 {
+ // 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()
+ ): Promise {
+ 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();
+ 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,
+ };
+}
diff --git a/src/main/services/team/ReviewDecisionStore.ts b/src/main/services/team/ReviewDecisionStore.ts
new file mode 100644
index 00000000..6efcd708
--- /dev/null
+++ b/src/main/services/team/ReviewDecisionStore.ts
@@ -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;
+ fileDecisions: Record;
+ 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;
+ fileDecisions: Record;
+ } | 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;
+
+ const hunkDecisions: Record =
+ data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {};
+ const fileDecisions: Record =
+ data.fileDecisions && typeof data.fileDecisions === 'object' ? data.fileDecisions : {};
+
+ return { hunkDecisions, fileDecisions };
+ }
+
+ async save(
+ teamName: string,
+ scopeKey: string,
+ data: {
+ hunkDecisions: Record;
+ fileDecisions: Record;
+ }
+ ): Promise {
+ 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 {
+ 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)}`
+ );
+ }
+ }
+ }
+}
diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts
new file mode 100644
index 00000000..0180f803
--- /dev/null
+++ b/src/main/services/team/TaskBoundaryParser.ts
@@ -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();
+ private readonly CACHE_TTL = 60 * 1000; // 60s
+
+ /** Парсинг JSONL файла для обнаружения границ задач */
+ async parseBoundaries(filePath: string): Promise {
+ // 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();
+ 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;
+ 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;
+ 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 | 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 {
+ 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): unknown[] | null {
+ const message = entry.message as Record | 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;
+ 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 | 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;
+ 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 | 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,
+ totalLines: number
+ ): TaskChangeScope[] {
+ // Группируем по taskId
+ const byTask = new Map();
+ 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();
+
+ 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;
+ }
+}
diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts
index b4579c5a..eb0a0f01 100644
--- a/src/main/services/team/TeamAgentToolsInstaller.ts
+++ b/src/main/services/team/TeamAgentToolsInstaller.ts
@@ -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 ');
+
+ 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