From 61c62741c32288075b52169dace75eb1b6ad5e27 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 24 Feb 2026 20:56:13 +0200 Subject: [PATCH] feat: enhance diff view with new features and improvements - Added 'fallback' confidence level to TaskChangeSet for Phase 3 Tier 4. - Updated line counting logic in ChangeExtractorService to use `jsdiff.diffLines` for accurate added/removed line counts. - Introduced new IPC handlers for agent and task changes, improving the review process. - Enhanced error handling in review handlers with a local wrapReviewHandler function. - Updated documentation to reflect changes in task scoping and content extraction methods. - Improved local storage management for viewed files, ensuring compatibility with new formats. - Added agent block usage policy to TeamProvisioningService for better message formatting control. --- .../diff-view/phase-1-read-only-diff.md | 191 ++++++++- .../diff-view/phase-2-accept-reject.md | 370 ++++++++++++++---- .../diff-view/phase-3-per-task-scoping.md | 265 ++++++++++--- .../diff-view/phase-4-enhanced-features.md | 189 +++++++-- .../services/team/TeamProvisioningService.ts | 88 ++++- .../components/chat/UserChatGroup.tsx | 13 +- .../chat/items/TeammateMessageItem.tsx | 8 +- .../components/dashboard/DashboardView.tsx | 36 +- .../components/sidebar/GlobalTaskList.tsx | 2 +- .../components/sidebar/SidebarTaskItem.tsx | 46 ++- .../components/team/TeamDetailView.tsx | 3 +- .../components/team/activity/ActivityItem.tsx | 14 +- .../team/dialogs/TaskCommentsSection.tsx | 16 +- .../team/members/MemberExecutionLog.tsx | 4 +- .../store/slices/conversationSlice.ts | 5 +- src/renderer/utils/taskGrouping.ts | 72 ++-- src/shared/constants/agentBlocks.ts | 7 + 17 files changed, 1052 insertions(+), 277 deletions(-) diff --git a/docs/iterations/diff-view/phase-1-read-only-diff.md b/docs/iterations/diff-view/phase-1-read-only-diff.md index aed10770..35043de3 100644 --- a/docs/iterations/diff-view/phase-1-read-only-diff.md +++ b/docs/iterations/diff-view/phase-1-read-only-diff.md @@ -58,7 +58,7 @@ export interface TaskChangeSet { totalLinesAdded: number; totalLinesRemoved: number; totalFiles: number; - confidence: 'high' | 'medium' | 'low'; + confidence: 'high' | 'medium' | 'low' | 'fallback'; // 'fallback' добавлен для Phase 3 Tier 4 computedAt: string; } @@ -110,7 +110,14 @@ export class ChangeExtractorService { ``` 5. **Пропуск ошибок**: Следующий за tool_use блок `tool_result` с `is_error: true` → пропускаем этот tool_use 6. **Фильтрация proxy_ префикса**: Имена инструментов приходят как `proxy_Edit` — нужно strip prefix (паттерн из MemberStatsComputer) -7. **Подсчёт строк**: `linesAdded = newString.split('\n').length - oldString.split('\n').length` (для добавленных), аналогично для removed +7. **Подсчёт строк** (через `jsdiff.diffLines`): + ```typescript + import { diffLines } from 'diff'; + const changes = diffLines(oldString, newString); + const linesAdded = changes.filter(c => c.added).reduce((sum, c) => sum + (c.count ?? 0), 0); + const linesRemoved = changes.filter(c => c.removed).reduce((sum, c) => sum + (c.count ?? 0), 0); + ``` + **НЕ использовать** `newString.split('\n').length - oldString.split('\n').length` — это даёт "net difference", а не отдельные added/removed. Может давать отрицательные числа. **Task scoping (для `getTaskChanges`):** @@ -142,14 +149,32 @@ export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats'; ```typescript import { IpcMain, IpcMainInvokeEvent } from 'electron'; -import { IpcResult } from '@shared/types/api'; +import type { IpcResult } from '@shared/types'; // IpcResult живёт в @shared/types/ipc.ts, barrel через @shared/types import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import { REVIEW_GET_AGENT_CHANGES, REVIEW_GET_TASK_CHANGES, REVIEW_GET_CHANGE_STATS } from '@preload/constants/ipcChannels'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('IPC:review'); + +// --- Module-level state (паттерн из teams.ts) --- let changeExtractor: ChangeExtractorService | null = null; -export function initializeReviewHandlers(service: ChangeExtractorService): void { - changeExtractor = service; +function getChangeExtractor(): ChangeExtractorService { + if (!changeExtractor) throw new Error('Review handlers not initialized'); + return changeExtractor; +} + +// --- Forward-compatible config object (Phase 2/3/4 добавят новые сервисы) --- + +interface ReviewHandlerDeps { + extractor: ChangeExtractorService; + // Phase 2 добавит: applier?: ReviewApplierService; contentResolver?: FileContentResolver; + // Phase 4 добавит: gitFallback?: GitDiffFallback; +} + +export function initializeReviewHandlers(deps: ReviewHandlerDeps): void { + changeExtractor = deps.extractor; } export function registerReviewHandlers(ipcMain: IpcMain): void { @@ -164,19 +189,140 @@ export function removeReviewHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS); } -// Handlers follow wrapTeamHandler pattern from teams.ts +// --- Local wrapReviewHandler (копия wrapTeamHandler из teams.ts, НЕ экспортируется) --- + +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 }; + } +} + +// --- Handlers --- + +async function handleGetAgentChanges( + _event: IpcMainInvokeEvent, + teamName: string, + memberName: string +): Promise> { + return wrapReviewHandler('getAgentChanges', () => + getChangeExtractor().getAgentChanges(teamName, memberName) + ); +} + +// ... аналогично handleGetTaskChanges, handleGetChangeStats ``` ### 5. Регистрация в main process -В `src/main/index.ts` (или где инициализируются IPC): -- Создать `ChangeExtractorService` с зависимостью `TeamMemberLogsFinder` -- Вызвать `initializeReviewHandlers(changeExtractor)` -- Вызвать `registerReviewHandlers(ipcMain)` после team handlers +**Файл: `src/main/ipc/handlers.ts`** — единственное место регистрации ВСЕХ IPC handlers. -### 6. Preload bridge: `src/preload/index.ts` (MODIFY) +**Шаг 1: Создание сервиса** — в `src/main/index.ts`, функция `initializeServices()` (после строки ~305 где создаются team services): +```typescript +// После создания teamMemberLogsFinder и memberStatsComputer: +const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder); +``` -Добавить в `electronAPI`: +**Шаг 2: Инициализация** — в `src/main/ipc/handlers.ts`, функция `initializeIpcHandlers()`. + +**ВАЖНО**: `initializeIpcHandlers()` использует ПОЗИЦИОННЫЕ параметры (9 штук, строки 76-92). +НЕ менять сигнатуру на объект! Вместо этого добавить `changeExtractor` как 10-й позиционный параметр: + +```typescript +// handlers.ts — расширение сигнатуры: +export function initializeIpcHandlers( + registry: ServiceContextRegistry, + updater: UpdaterService, + sshManager: SshConnectionManager, + teamDataService: TeamDataService, + teamProvisioningService: TeamProvisioningService, + teamMemberLogsFinder: TeamMemberLogsFinder, + memberStatsComputer: MemberStatsComputer, + contextCallbacks: { ... }, + httpServerDeps?: { ... }, + changeExtractor?: ChangeExtractorService // ← Phase 1 addition (optional для backward compat) +): void { + // ... existing initialization ... + if (changeExtractor) { + initializeReviewHandlers({ extractor: changeExtractor }); + } +``` + +```typescript +// index.ts — добавить 10-й аргумент при вызове: +initializeIpcHandlers( + contextRegistry, + updaterService, + sshConnectionManager, + teamDataService, + teamProvisioningService, + teamMemberLogsFinder, + memberStatsComputer, + contextCallbacks, + httpServerDeps, + changeExtractor // ← Phase 1 +); +``` + +**Шаг 3: Регистрация** — в `src/main/ipc/handlers.ts`, после `registerTeamHandlers(ipcMain)` (строка ~130): +```typescript +registerReviewHandlers(ipcMain); +``` + +**Шаг 4: Cleanup** — в `src/main/ipc/handlers.ts`, функция `removeIpcHandlers()` (после `removeTeamHandlers(ipcMain)`, строка ~155): +```typescript +removeReviewHandlers(ipcMain); +``` + +**Шаг 5: Import** — в `src/main/ipc/handlers.ts`, добавить import: +```typescript +import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review'; +``` + +### 6. Preload bridge + ElectronAPI типы + +#### 6a. Типы: `src/shared/types/api.ts` (MODIFY) + +**ВАЖНО**: `ElectronAPI` интерфейс (строки ~406-519) типизирует `window.electronAPI`. +Без добавления `review` — TypeScript не пропустит `api.review.*` вызовы. + +```typescript +// В src/shared/types/api.ts добавить: + +import type { AgentChangeSet, TaskChangeSet, ChangeStats } from './review'; + +export interface ReviewAPI { + getAgentChanges: (teamName: string, memberName: string) => Promise; + getTaskChanges: (teamName: string, taskId: string) => Promise; + getChangeStats: (teamName: string, memberName: string) => Promise; +} + +// В ElectronAPI интерфейс добавить поле: +export interface ElectronAPI { + // ... existing fields ... + review: ReviewAPI; +} +``` + +#### 6b. HttpAPIClient: `src/renderer/api/httpClient.ts` (MODIFY) + +Для browser mode (SSH/remote) нужны заглушки: +```typescript +// В HttpAPIClient class добавить: +review = { + getAgentChanges: async () => { throw new Error('Review not available in browser mode'); }, + getTaskChanges: async () => { throw new Error('Review not available in browser mode'); }, + getChangeStats: async () => { throw new Error('Review not available in browser mode'); }, +}; +``` + +#### 6c. Preload: `src/preload/index.ts` (MODIFY) + +Добавить в `electronAPI` объект: ```typescript review: { getAgentChanges: (teamName: string, memberName: string) => @@ -216,6 +362,17 @@ export interface ChangeReviewSlice { Зарегистрировать в `src/renderer/store/index.ts` как новый slice. +**ВАЖНО**: Также обновить `src/renderer/store/types.ts`: +```typescript +import type { ChangeReviewSlice } from './slices/changeReviewSlice'; + +export type AppState = ProjectSlice & + // ... existing slices ... + UpdateSlice & + ChangeReviewSlice; // ← Phase 1 addition +``` +Без этого `useStore().fetchAgentChanges` не будет доступен в TypeScript. + ### 8. Компоненты #### `src/renderer/components/team/review/ChangeReviewDialog.tsx` (NEW) @@ -266,21 +423,25 @@ export interface ChangeReviewSlice { | Файл | Тип | ~LOC | |------|-----|---:| | `src/shared/types/review.ts` | NEW | 80 | +| `src/shared/types/api.ts` | MODIFY | +15 (ReviewAPI interface + ElectronAPI field) | | `src/main/services/team/ChangeExtractorService.ts` | NEW | 350 | -| `src/main/ipc/review.ts` | NEW | 80 | +| `src/main/ipc/review.ts` | NEW | 100 (с wrapReviewHandler) | +| `src/main/ipc/handlers.ts` | MODIFY | +10 (import + init + register + remove) | | `src/main/services/team/index.ts` | MODIFY | +1 | | `src/main/index.ts` | MODIFY | +10 | | `src/preload/constants/ipcChannels.ts` | MODIFY | +3 | | `src/preload/index.ts` | MODIFY | +10 | +| `src/renderer/api/httpClient.ts` | MODIFY | +8 (review stubs) | | `src/renderer/store/slices/changeReviewSlice.ts` | NEW | 100 | | `src/renderer/store/index.ts` | MODIFY | +5 | +| `src/renderer/store/types.ts` | MODIFY | +2 (import + AppState intersection) | | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | NEW | 150 | | `src/renderer/components/team/review/ReviewFileTree.tsx` | NEW | 180 | -| `src/renderer/components/team/review/ReviewDiffContent.tsx` | NEW | 250 | +| `src/renderer/components/team/review/ReviewDiffContent.tsx` | NEW | 250 (throwaway — заменяется в Phase 2 на CodeMirror) | | `src/renderer/components/team/review/ChangeStatsBadge.tsx` | NEW | 40 | | `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +30 | | `src/renderer/components/team/TeamDetailView.tsx` | MODIFY | +40 | -| **Итого** | 8 NEW + 7 MODIFY | ~1,330 | +| **Итого** | 8 NEW + 10 MODIFY | ~1,430 | --- diff --git a/docs/iterations/diff-view/phase-2-accept-reject.md b/docs/iterations/diff-view/phase-2-accept-reject.md index fd7d01fa..f6cedb32 100644 --- a/docs/iterations/diff-view/phase-2-accept-reject.md +++ b/docs/iterations/diff-view/phase-2-accept-reject.md @@ -9,8 +9,8 @@ pnpm add @codemirror/merge @codemirror/state @codemirror/view pnpm add @codemirror/lang-javascript @codemirror/lang-python @codemirror/lang-json pnpm add @codemirror/lang-css @codemirror/lang-html @codemirror/lang-xml pnpm add @codemirror/theme-one-dark -pnpm add diff # jsdiff v8 — applyPatch, reversePatch -pnpm add node-diff3 # Three-way merge для конфликтов +pnpm add diff # jsdiff v8 — structuredPatch, applyPatch (НЕТ reversePatch!) +pnpm add node-diff3 # Three-way merge для конфликтов (diff3Merge) ``` **Примечание**: `react-codemirror-merge` НЕ используем — пишем свой React wrapper для полного контроля над lifecycle и event handling. @@ -79,7 +79,8 @@ export interface FileChangeWithContent extends FileChangeSummary { /** Полное содержимое файла ПОСЛЕ изменений (для CodeMirror modified) */ modifiedFullContent: string | null; /** Источник original content */ - contentSource: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'unavailable'; + contentSource: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'git-fallback' | 'unavailable'; + // 'git-fallback' добавлен для Phase 4 Git Fallback feature } ``` @@ -116,7 +117,7 @@ export class FileContentResolver { ): Promise<{ original: string | null; modified: string | null; - source: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'unavailable'; + source: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'git-fallback' | 'unavailable'; }>; /** @@ -235,36 +236,122 @@ export class ReviewApplierService { **Reject algorithm детально:** +У нас есть два подхода. **PRIMARY** — snippet-level replace (простой и надёжный). **FALLBACK** — hunk-level inverse patch (для случаев когда snippet-chain неполный). + +**PRIMARY: Snippet-level replace (рекомендуемый)** + ```typescript -// Шаг 1: Вычислить structured patch -const patch = Diff.structuredPatch('file', 'file', original, modified); -// patch.hunks = [ { oldStart, oldLines, newStart, newLines, lines: ['+', '-', ' '] } ] +// Простейший подход: у нас уже есть snippets с (oldString, newString) +// Reject = заменить newString обратно на oldString +async rejectHunks( + filePath: string, + original: string, + modified: string, + hunkIndicesToReject: number[], + snippets: SnippetDiff[] +): Promise { + // 1. Прочитать текущий файл с диска + let content = await readFile(filePath, 'utf8'); -// Шаг 2: Отфильтровать только rejected hunks -const rejectedPatch = { - ...patch, - hunks: patch.hunks.filter((_, idx) => hunkIndicesToReject.includes(idx)) -}; + // 2. Проверить: файл не изменён с момента agent changes? + if (content !== modified) { + // Конфликт — файл был модифицирован + return this.resolveWithThreeWayMerge(original, content, modified, hunkIndicesToReject, snippets); + } -// Шаг 3: Reverse patch (откат) -// jsdiff.applyPatch НЕ имеет reversed: true! -// Нужно вручную инвертировать: '+' → '-', '-' → '+', swap oldStart↔newStart -const inversePatch = invertPatch(rejectedPatch); + // 3. Применить snippet-level replace для rejected hunks + // + // R2 FIX: Используем ПОЗИЦИОННЫЙ reverse (не хронологический!): + // - Сначала находим ВСЕ позиции через indexOf + // - Сортируем по позиции УБЫВАНИЯ (от конца файла к началу) + // - Применяем замены — каждая не сдвигает позиции предыдущих + // + // Также: если newString встречается в файле несколько раз, + // используем позицию ближайшую к ожидаемой (на основе snippet order). -// Шаг 4: Применить к modified content -const result = Diff.applyPatch(modified, inversePatch); -if (result === false) { - // Patch не применился — конфликт - return threeWayMerge(original, currentDisk, targetContent); + const rejectedSnippets = hunkIndicesToReject + .map(idx => snippets[idx]) + .filter(Boolean); + + // Найти позиции ПЕРЕД заменами + const positioned: Array<{ snippet: SnippetDiff; offset: number }> = []; + for (const snippet of rejectedSnippets) { + if (snippet.type === 'write-new') continue; // Обрабатывается через rejectFile() + + const offset = content.indexOf(snippet.newString); + if (offset === -1) { + // Snippet не найден — fallback на hunk-level + return this.rejectHunksFallback(filePath, original, modified, hunkIndicesToReject); + } + + // Проверка уникальности: если есть второе вхождение, это опасно + const secondOccurrence = content.indexOf(snippet.newString, offset + 1); + if (secondOccurrence !== -1 && snippet.newString.length < 20) { + // Короткий неуникальный snippet — fallback (безопаснее) + return this.rejectHunksFallback(filePath, original, modified, hunkIndicesToReject); + } + + positioned.push({ snippet, offset }); + } + + // Сортировка по позиции УБЫВАНИЯ (от конца к началу) + positioned.sort((a, b) => b.offset - a.offset); + + for (const { snippet, offset } of positioned) { + content = content.slice(0, offset) + snippet.oldString + content.slice(offset + snippet.newString.length); + } + + // 4. Записать результат + await writeFile(filePath, content, 'utf8'); + return { success: true, newContent: content, hadConflicts: false }; } ``` -**Инвертирование patch:** +**FALLBACK: Hunk-level inverse patch (когда snippets неполные)** + +```typescript +// Используется когда snippet.newString не найден в файле (файл изменён после agent) +private async rejectHunksFallback( + filePath: string, + original: string, + modified: string, + hunkIndicesToReject: number[] +): Promise { + // Шаг 1: Вычислить structured patch + const patch = Diff.structuredPatch('file', 'file', original, modified); + + // Шаг 2: Отфильтровать только rejected hunks + const rejectedPatch = { + ...patch, + hunks: patch.hunks.filter((_, idx) => hunkIndicesToReject.includes(idx)) + }; + + // Шаг 3: Инвертировать patch вручную (jsdiff НЕ имеет reversed option!) + const inversePatch = invertPatch(rejectedPatch); + + // Шаг 4: Применить к modified content + const result = Diff.applyPatch(modified, inversePatch); + if (result === false) { + // Patch не применился — three-way merge + const currentDisk = await readFile(filePath, 'utf8'); + return this.resolveWithThreeWayMerge(original, currentDisk, modified, hunkIndicesToReject, []); + } + + await writeFile(filePath, result, 'utf8'); + return { success: true, newContent: result, hadConflicts: false }; +} +``` + +**Инвертирование patch (verified jsdiff API):** ```typescript function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff { return { ...patch, + oldFileName: patch.newFileName, + newFileName: patch.oldFileName, + oldHeader: patch.newHeader ?? '', + newHeader: patch.oldHeader ?? '', hunks: patch.hunks.map(hunk => ({ oldStart: hunk.newStart, oldLines: hunk.newLines, @@ -273,27 +360,34 @@ function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff { lines: hunk.lines.map(line => { if (line.startsWith('+')) return '-' + line.slice(1); if (line.startsWith('-')) return '+' + line.slice(1); - return line; // context lines unchanged + return line; // context lines (prefix ' ') — без изменений }) })) }; } ``` -**Three-way merge (при конфликтах):** +**Three-way merge (при конфликтах) — verified node-diff3 API:** ```typescript import { diff3Merge } from 'node-diff3'; +// VERIFIED API: diff3Merge(a, o, b, options?) +// a = "ours" (changed version A) +// o = "original" (base) +// b = "theirs" (changed version B) +// Принимает string[] (массив строк) ИЛИ строки +// Возвращает: Array<{ ok: string[] } | { conflict: { a: string[], o: string[], b: string[] } }> + function threeWayMerge( base: string, // Original content before agent changes ours: string, // Current file on disk (user's version) theirs: string // What we want after reject ): { content: string; hasConflicts: boolean } { const result = diff3Merge( - ours.split('\n'), - base.split('\n'), - theirs.split('\n') + ours.split('\n'), // a = current disk + base.split('\n'), // o = original (base) + theirs.split('\n') // b = target after reject ); let hasConflicts = false; @@ -302,12 +396,14 @@ function threeWayMerge( for (const part of result) { if ('ok' in part) { lines.push(...part.ok); - } else { + } else if ('conflict' in part) { hasConflicts = true; lines.push('<<<<<<< Current (yours)'); - lines.push(...(part.conflict?.a ?? [])); + lines.push(...(part.conflict.a ?? [])); + lines.push('||||||| Original'); + lines.push(...(part.conflict.o ?? [])); // node-diff3 также возвращает .o (original) lines.push('======='); - lines.push(...(part.conflict?.b ?? [])); + lines.push(...(part.conflict.b ?? [])); lines.push('>>>>>>> Reverted (rejected changes)'); } } @@ -330,20 +426,72 @@ export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent'; ### 5. IPC хендлеры: `src/main/ipc/review.ts` (MODIFY — расширение Phase 1) +**Регистрация**: В `src/main/index.ts` `initializeServices()` создать новые сервисы: ```typescript -// Добавляем к Phase 1 хендлерам +const fileContentResolver = new FileContentResolver(teamMemberLogsFinder); +const reviewApplier = new ReviewApplierService(); +``` + +Обновить вызов `initializeReviewHandlers()` — Phase 1 использует объект-конфиг `ReviewHandlerDeps`, Phase 2 добавляет optional fields: +```typescript +// index.ts — Phase 2 расширение (вместо только { extractor: changeExtractor }): +initializeReviewHandlers({ + extractor: changeExtractor, + applier: reviewApplier, + contentResolver: fileContentResolver, +}); +``` +`registerReviewHandlers()` и `removeReviewHandlers()` уже зарегистрированы в Phase 1. + +**ВАЖНО**: Обновить `ReviewAPI` в `src/shared/types/api.ts` — добавить Phase 2 методы: +```typescript +export interface ReviewAPI { + // Phase 1 + getAgentChanges: (...) => Promise; + getTaskChanges: (...) => Promise; + getChangeStats: (...) => Promise; + // Phase 2 + checkConflict: (filePath: string, expectedModified: string) => Promise; + rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise; + rejectFile: (filePath: string, original: string, modified: string) => Promise; + previewReject: (...) => Promise<{ preview: string; hasConflicts: boolean }>; + applyDecisions: (request: ApplyReviewRequest) => Promise; + getFileContent: (teamName: string, memberName: string, filePath: string) => Promise; +} +``` + +Также обновить `HttpAPIClient` — добавить стубы для Phase 2 методов. + +```typescript +// Расширяем Phase 1 хендлеры let reviewApplier: ReviewApplierService | null = null; let fileContentResolver: FileContentResolver | null = null; -export function initializeReviewHandlers( - extractor: ChangeExtractorService, - applier: ReviewApplierService, - contentResolver: FileContentResolver -): void { - changeExtractor = extractor; - reviewApplier = applier; - fileContentResolver = contentResolver; +// Phase 2: Расширяем ReviewHandlerDeps из Phase 1 (объект-конфиг — forward compatible) +// Phase 1 определил: interface ReviewHandlerDeps { extractor: ChangeExtractorService; ... } +// Phase 2 добавляет optional fields (НЕ ломает Phase 1 вызов): +interface ReviewHandlerDeps { + extractor: ChangeExtractorService; + applier?: ReviewApplierService; + contentResolver?: FileContentResolver; + // Phase 4 добавит: gitFallback?: GitDiffFallback; +} + +export function initializeReviewHandlers(deps: ReviewHandlerDeps): void { + changeExtractor = deps.extractor; + reviewApplier = deps.applier ?? null; + fileContentResolver = deps.contentResolver ?? null; +} + +// Guard helpers +function getApplier(): ReviewApplierService { + if (!reviewApplier) throw new Error('ReviewApplierService not initialized (Phase 2 required)'); + return reviewApplier; +} +function getContentResolver(): FileContentResolver { + if (!fileContentResolver) throw new Error('FileContentResolver not initialized (Phase 2 required)'); + return fileContentResolver; } // Регистрация Phase 2 хендлеров @@ -368,7 +516,7 @@ async function handleGetFileContent( memberName: string, filePath: string ): Promise> { - return wrapHandler('review:getFileContent', async () => { + return wrapReviewHandler('review:getFileContent', async () => { const resolver = getContentResolver(); const result = await resolver.resolveFileContent(teamName, memberName, filePath); return result; @@ -380,11 +528,14 @@ async function handleRejectHunks( filePath: string, original: string, modified: string, - hunkIndices: number[] + hunkIndices: number[], + snippets: SnippetDiff[] // R1 fix: renderer MUST передать snippets ): Promise> { - return wrapHandler('review:rejectHunks', async () => { + // Security fix: валидация что filePath внутри проекта + // TODO: получить projectPath из team config и проверить path.resolve(filePath).startsWith(projectPath) + return wrapReviewHandler('review:rejectHunks', async () => { const applier = getApplier(); - return await applier.rejectHunks(filePath, original, modified, hunkIndices); + return await applier.rejectHunks(filePath, original, modified, hunkIndices, snippets); }); } @@ -392,7 +543,7 @@ async function handleApplyDecisions( _event: IpcMainInvokeEvent, request: ApplyReviewRequest ): Promise> { - return wrapHandler('review:applyDecisions', async () => { + return wrapReviewHandler('review:applyDecisions', async () => { const applier = getApplier(); const resolver = getContentResolver(); @@ -424,8 +575,8 @@ review: { // Phase 2 checkConflict: (filePath: string, expectedModified: string) => invokeIpcWithResult(REVIEW_CHECK_CONFLICT, filePath, expectedModified), - rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[]) => - invokeIpcWithResult(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices), + rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => + invokeIpcWithResult(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices, snippets), rejectFile: (filePath: string, original: string, modified: string) => invokeIpcWithResult(REVIEW_REJECT_FILE, filePath, original, modified), previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[]) => @@ -592,9 +743,9 @@ function mapReviewError(error: unknown): string { ```typescript import { useRef, useEffect, useMemo } from 'react'; -import { EditorView, ViewUpdate } from '@codemirror/view'; -import { EditorState } from '@codemirror/state'; -import { unifiedMergeView } from '@codemirror/merge'; +import { EditorView, keymap } from '@codemirror/view'; +import { EditorState, Transaction } from '@codemirror/state'; +import { unifiedMergeView, goToNextChunk, goToPreviousChunk } from '@codemirror/merge'; import { javascript } from '@codemirror/lang-javascript'; import { python } from '@codemirror/lang-python'; import { json } from '@codemirror/lang-json'; @@ -681,52 +832,101 @@ export function CodeMirrorDiffView({ } ``` -3. **Merge controls (accept/reject кнопки)**: +3. **Merge controls (accept/reject кнопки) — VERIFIED API:** + ```typescript - // mergeControls принимает callback factory - // type = 'accept' | 'reject', action = closure для применения + // VERIFIED: mergeControls сигнатура: + // (type: "reject" | "accept", action: (e: MouseEvent) => void) => HTMLElement + // + // ВАЖНО: action — это callback с MouseEvent параметром! + // Кнопки должны использовать onmousedown (не onclick) — это паттерн CM. + mergeControls: showMergeControls - ? (type, action) => { + ? (type: 'reject' | 'accept', action: (e: MouseEvent) => void) => { const btn = document.createElement('button'); btn.className = type === 'accept' ? 'cm-merge-accept-btn' : 'cm-merge-reject-btn'; btn.textContent = type === 'accept' ? 'Accept' : 'Reject'; btn.title = type === 'accept' - ? 'Keep this change (Ctrl+Shift+A)' - : 'Revert this change (Ctrl+Shift+R)'; - btn.onclick = (e) => { - e.stopPropagation(); - action(); // CM applies the change internally - }; + ? 'Keep this change' + : 'Revert this change'; + btn.onmousedown = action; // ВАЖНО: onmousedown, НЕ onclick! return btn; } : undefined, ``` -4. **Event tracking для hunk index**: +4. **Event tracking для accept/reject — VERIFIED API:** + ```typescript - // CodeMirror merge fires user events 'accept' and 'revert' - // НО не сообщает hunk index напрямую! - // Решение: Отслеживать через transaction.changes и chunk positions + // VERIFIED: CodeMirror merge помечает transactions через annotation: + // - Accept: Transaction.userEvent = "accept" + // - Reject: Transaction.userEvent = "revert" + // + // НЕ используем tr.isUserEvent() — используем tr.annotation()! - let hunkCounter = 0; - - EditorView.updateListener.of((update: ViewUpdate) => { + EditorView.updateListener.of((update) => { for (const tr of update.transactions) { - if (tr.isUserEvent('accept')) { - onHunkAccepted?.(hunkCounter); - hunkCounter++; + const userEvent = tr.annotation(Transaction.userEvent); + if (userEvent === 'accept') { + // Определяем hunk index по позиции cursor ПЕРЕД транзакцией + const pos = tr.startState.selection.main.head; + const hunkIndex = computeHunkIndexAtPos(tr.startState, pos); + onHunkAccepted?.(hunkIndex); } - if (tr.isUserEvent('revert')) { - onHunkRejected?.(hunkCounter); - hunkCounter++; + if (userEvent === 'revert') { + const pos = tr.startState.selection.main.head; + const hunkIndex = computeHunkIndexAtPos(tr.startState, pos); + onHunkRejected?.(hunkIndex); } } }); ``` - **ВАЖНО**: Hunk index tracking через counter НЕ надёжен при non-sequential clicks. Альтернатива — вычислять hunk index по `transaction.changes.newLength` и маппить на chunk ranges. Это Phase 2 implementation detail. + **ВАЖНО: Chunk positions — NO PUBLIC API!** + + `@codemirror/merge` НЕ экспортирует публичный API для получения позиций chunks. + Нужно вычислять hunk index самостоятельно через diff algorithm: + + ```typescript + // Вычисляем позиции hunks через jsdiff (уже установлен) + import { structuredPatch } from 'diff'; + + function computeHunkRanges(original: string, modified: string) { + const patch = structuredPatch('', '', original, modified); + // patch.hunks содержит newStart/newLines для каждого hunk + return patch.hunks.map((h, idx) => ({ + index: idx, + fromLine: h.newStart, + toLine: h.newStart + h.newLines, + })); + } + + function computeHunkIndexAtPos(state: EditorState, pos: number): number { + const line = state.doc.lineAt(pos).number; + const ranges = hunkRangesRef.current; // Кешируем при создании editor + for (const r of ranges) { + if (line >= r.fromLine && line <= r.toLine) return r.index; + } + return -1; // Not in a hunk + } + ``` + +5. **Keyboard navigation через StateCommand — VERIFIED API:** + + ```typescript + // goToNextChunk и goToPreviousChunk — это StateCommand объекты. + // Используются через keymap ИЛИ вызов .run(view): + + keymap.of([ + { key: 'Ctrl-Alt-ArrowDown', run: goToNextChunk }, + { key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk }, + ]), + + // Программный вызов: + // goToNextChunk(editorRef.current!) // returns boolean + ``` 5. **Тема (CSS variables integration)**: ```typescript @@ -785,18 +985,37 @@ export function CodeMirrorDiffView({ }, { dark: true }); ``` -6. **Extensions assembly**: +6. **Extensions assembly — VERIFIED unifiedMergeView config:** ```typescript + // VERIFIED: полная сигнатура unifiedMergeView config: + // { + // original: Text | string, // Required + // highlightChanges?: boolean, // Default: true + // gutter?: boolean, // Default: true + // syntaxHighlightDeletions?: boolean, // Default: true + // mergeControls?: boolean | ((type, action) => HTMLElement), + // diffConfig?: DiffConfig, + // collapseUnchanged?: { margin?: number, minSize?: number }, + // } + const extensions = useMemo(() => [ readOnly ? EditorState.readOnly.of(true) : [], readOnly ? EditorView.editable.of(false) : [], getLanguageExtension(fileName), customTheme, + keymap.of([ + { key: 'Ctrl-Alt-ArrowDown', run: goToNextChunk }, + { key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk }, + ]), unifiedMergeView({ original, mergeControls: showMergeControls ? mergeControlsFactory : undefined, - collapseUnchanged: collapseUnchanged ? { margin: collapseMargin } : undefined, + highlightChanges: true, + gutter: true, syntaxHighlightDeletions: true, + collapseUnchanged: collapseUnchanged + ? { margin: collapseMargin, minSize: 4 } + : undefined, }), updateListener, ].flat(), [original, modified, fileName, showMergeControls, collapseUnchanged]); @@ -946,6 +1165,7 @@ function getFileStatusIcon(filePath: string, hunkDecisions: Record(); - private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин + private cache = new Map(); + private readonly CACHE_TTL = 60 * 1000; // 1 мин (не 3 — JSONL файлы меняются часто при активной работе) /** * Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры. @@ -225,9 +228,12 @@ private extractTeamctlBoundaries( ```typescript async parseBoundaries(filePath: string): Promise { - // Check cache + // Check cache (S2 fix: проверяем И TTL И mtime файла) + const stat = await import('fs/promises').then(f => f.stat(filePath)); const cached = this.cache.get(filePath); - if (cached && cached.expiresAt > Date.now()) return cached.data; + if (cached && cached.mtime === stat.mtimeMs && cached.expiresAt > Date.now()) { + return cached.data; + } const boundaries: TaskBoundary[] = []; const allToolUsesByLine = new Map(); @@ -300,7 +306,7 @@ async parseBoundaries(filePath: string): Promise { detectedMechanism, }; - this.cache.set(filePath, { data: result, expiresAt: Date.now() + this.CACHE_TTL }); + this.cache.set(filePath, { data: result, mtime: stat.mtimeMs, expiresAt: Date.now() + this.CACHE_TTL }); return result; } ``` @@ -395,18 +401,25 @@ private computeScopes( } ``` -**extractContent helper (паттерн из MemberStatsComputer):** +**extractContent helper (паттерн из MemberStatsComputer + FIXED для всех JSONL форматов):** ```typescript -// Subagent JSONL: entry.message.content (массив блоков) -// Main JSONL: entry.content (массив блоков) +// JSONL файлы содержат СМЕШАННЫЕ форматы entries: +// 1. Subagent assistant: entry.message.content = ContentBlock[] +// 2. Main assistant: entry.content = ContentBlock[] +// 3. tool_result entries: entry.content = string | ContentBlock[] +// 4. Meta entries (file-history-snapshot): нет content с tool_use +// +// TaskUpdate блоки находятся ТОЛЬКО в assistant entries (1, 2), +// поэтому проверяем оба формата. + private extractContent(entry: Record): unknown[] | null { - // Subagent format + // Subagent format: entry.message.content const message = entry.message as Record | undefined; if (message && Array.isArray(message.content)) { return message.content; } - // Main format fallback + // Main format: entry.content (array) if (Array.isArray(entry.content)) { return entry.content; } @@ -416,38 +429,155 @@ private extractContent(entry: Record): unknown[] | null { ### 3. Модификация: `src/main/services/team/ChangeExtractorService.ts` (MODIFY) -Phase 1 создал `getTaskChanges()` с keyword-based scoping. Phase 3 заменяет на structure-based: +Phase 1 создал `getTaskChanges()` с keyword-based scoping. Phase 3 заменяет на structure-based. + +**CRITICAL FIX (C6+C7): `MemberLogSummary` НЕ имеет поля `filePath`!** + +Реальный тип `MemberLogSummary` — это union `MemberSubagentLogSummary | MemberLeadSessionLogSummary`. +Базовые поля: `kind`, `sessionId`, `projectId`, `description`, `memberName` (string | null), `startTime`, `durationMs`, `messageCount`, `isOngoing`. +**НЕТ поля `filePath`!** + +Также метод `getAllSessionLogs()` **НЕ существует** в `TeamMemberLogsFinder`. + +**Решение**: Ввести внутренний тип `LogFileRef` и новый метод `resolveLogPaths()`: ```typescript -// Phase 1 (заменяем) -async getTaskChanges(teamName: string, taskId: string): Promise { - // Keyword search через logsFinder.findLogsForTask() +/** Внутренний тип — НЕ экспортируется из модуля */ +interface LogFileRef { + filePath: string; + memberName: string; // always string (fallback: 'unknown') } -// Phase 3 (новая реализация) +/** + * Конвертирует MemberLogSummary → LogFileRef. + * Используем существующий findMemberLogPaths() для получения файловых путей. + */ +private async resolveLogFileRefs( + teamName: string, + logs: MemberLogSummary[] +): Promise { + const refs: LogFileRef[] = []; + // Группируем по memberName для batch resolve + 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); + // Match paths to logs by sessionId + 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; +} +``` + +**Constructor (Phase 3 добавляет TaskBoundaryParser зависимость):** + +```typescript +export class ChangeExtractorService { + private cache = new Map(); + private readonly CACHE_TTL = 60 * 1000; // 1 мин (совпадает с TaskBoundaryParser) + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly boundaryParser: TaskBoundaryParser // Phase 3 addition + ) {} + + // ... Phase 1 methods (getAgentChanges, getChangeStats) remain unchanged ... +} +``` + +**Методы extractFilteredChanges и extractAllChanges (используют LogFileRef, НЕ MemberLogSummary):** + +```typescript +/** + * Извлечь изменения ТОЛЬКО для указанных tool_use IDs. + * Парсит JSONL, находит Edit/Write/MultiEdit блоки, фильтрует по allowedIds. + * + * IMPORTANT: принимает LogFileRef[] (НЕ MemberLogSummary[]) — у MemberLogSummary нет filePath! + */ +private async extractFilteredChanges( + logRefs: LogFileRef[], + allowedToolUseIds: Set +): Promise { + const fileMap = new Map(); + const shouldFilter = allowedToolUseIds.size > 0; + + for (const ref of logRefs) { + const stream = createReadStream(ref.filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + try { + for await (const line of rl) { + // ... parse entry, extract content, find tool_use blocks ... + // if (shouldFilter && !allowedToolUseIds.has(block.id)) continue; + // → add to fileMap + } + } finally { + rl.close(); + stream.destroy(); + } + } + + return [...fileMap.values()]; +} + +/** + * Извлечь ВСЕ изменения из одного JSONL файла (без фильтрации). + * Используется для single-task sessions и Tier 4 fallback. + */ +private async extractAllChanges(filePath: string, memberName: string = 'unknown'): Promise { + return this.extractFilteredChanges( + [{ filePath, memberName }], + new Set() // empty set + shouldFilter=false → accept all + ); +} +``` + +**Phase 3 (новая реализация getTaskChanges):** + +```typescript async getTaskChanges(teamName: string, taskId: string): Promise { - // 1. Найти JSONL файлы через logsFinder + // 1. Найти MemberLogSummary через logsFinder (реальный API) const logs = await this.logsFinder.findLogsForTask(teamName, taskId); - // 2. Для каждого JSONL — парсить boundaries через TaskBoundaryParser + // 2. Конвертировать в LogFileRef (MemberLogSummary НЕ имеет filePath!) + const logRefs = await this.resolveLogFileRefs(teamName, logs); + if (logRefs.length === 0) { + return this.emptyTaskChangeSet(teamName, taskId); + } + + // 3. Для каждого JSONL — парсить boundaries через TaskBoundaryParser const allScopes: TaskChangeScope[] = []; - for (const log of logs) { - const boundaries = await this.boundaryParser.parseBoundaries(log.filePath); + for (const ref of logRefs) { + const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath); const scope = boundaries.scopes.find(s => s.taskId === taskId); if (scope) { - scope.memberName = log.memberName; + scope.memberName = ref.memberName; allScopes.push(scope); } } - // 3. Если нет structural scopes → fallback на single-task assumption + // 4. Если нет structural scopes → fallback на single-task assumption if (allScopes.length === 0) { - return this.fallbackSingleTaskScope(teamName, taskId, logs); + return this.fallbackSingleTaskScope(teamName, taskId, logRefs); } - // 4. Фильтровать snippets по tool_use IDs из scope + // 5. Фильтровать snippets по tool_use IDs из scope const allowedToolUseIds = new Set(allScopes.flatMap(s => s.toolUseIds)); - const files = await this.extractFilteredChanges(logs, allowedToolUseIds); + const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds); // 5. Compute confidence (worst case across all scopes) const worstTier = Math.max(...allScopes.map(s => s.confidence.tier)); @@ -478,17 +608,17 @@ async getTaskChanges(teamName: string, taskId: string): Promise private async fallbackSingleTaskScope( teamName: string, taskId: string, - logs: MemberLogSummary[] + logRefs: LogFileRef[] // C6 fix: LogFileRef вместо MemberLogSummary ): Promise { // Проверяем: если agent работал только над одной задачей, // ВСЕ изменения в сессии = изменения этой задачи - for (const log of logs) { - const boundaries = await this.boundaryParser.parseBoundaries(log.filePath); + for (const ref of logRefs) { + const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath); if (boundaries.isSingleTaskSession) { // Весь файл = одна задача → extract все changes - const files = await this.extractAllChanges(log.filePath); + const files = await this.extractAllChanges(ref.filePath, ref.memberName); return { teamName, taskId, @@ -500,7 +630,7 @@ private async fallbackSingleTaskScope( computedAt: new Date().toISOString(), scope: { taskId, - memberName: log.memberName, + memberName: ref.memberName, startLine: 1, endLine: Infinity, startTimestamp: '', @@ -515,7 +645,8 @@ private async fallbackSingleTaskScope( } // No single-task session found → Tier 4 fallback - const files = await this.extractAllChanges(logs[0]?.filePath ?? ''); + const firstRef = logRefs[0]; + const files = firstRef ? await this.extractAllChanges(firstRef.filePath, firstRef.memberName) : []; return { teamName, taskId, @@ -527,7 +658,7 @@ private async fallbackSingleTaskScope( computedAt: new Date().toISOString(), scope: { taskId, - memberName: logs[0]?.memberName ?? 'unknown', + memberName: firstRef?.memberName ?? 'unknown', startLine: 1, endLine: Infinity, startTimestamp: '', @@ -539,6 +670,21 @@ private async fallbackSingleTaskScope( warnings: ['Could not determine task boundaries. Showing all changes from this session.'], }; } + +/** Empty result when no logs found at all */ +private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 { + return { + teamName, taskId, files: [], + totalLinesAdded: 0, totalLinesRemoved: 0, totalFiles: 0, + confidence: 'low', computedAt: new Date().toISOString(), + scope: { + taskId, memberName: 'unknown', startLine: 0, endLine: 0, + startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for this task.' }, + }, + warnings: ['No log files found for this task.'], + }; +} ``` ### 4. Модификация: `src/main/services/team/TeamMemberLogsFinder.ts` (MODIFY) @@ -554,7 +700,9 @@ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - const pattern = new RegExp(`"taskId"\\s*:\\s*"${taskId}"`); + // H5 fix: экранируем taskId для безопасного использования в regex + const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`"taskId"\\s*:\\s*"${escapedTaskId}"`); for await (const line of rl) { if (line.includes('TaskUpdate') && pattern.test(line)) { @@ -575,38 +723,16 @@ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise { } ``` -**Оптимизация `findLogsForTask()`:** +**НЕ модифицируем `findLogsForTask()`** — он уже работает корректно через `fileMentionsTaskId()` (keyword search по JSONL). `hasTaskUpdateMarker()` может использоваться **параллельно** как fast-path проверка ДО полного парсинга, но НЕ заменяет `findLogsForTask()`. -Текущий метод использует `fileMentionsTaskId()` — keyword search. Phase 3 добавляет приоритетный path: +**CRITICAL: `getAllSessionLogs()` НЕ существует!** Реальный `findLogsForTask()` (строки 110-197 TeamMemberLogsFinder.ts) уже итерирует по всем сессиям через `discoverProjectSessions()` и `fileMentionsTaskId()`. Метод возвращает `MemberLogSummary[]`. + +`hasTaskUpdateMarker()` — это вспомогательный метод для `TaskBoundaryParser`, НЕ для замены findLogsForTask. ```typescript -async findLogsForTask( - teamName: string, - taskId: string, - options?: { owner?: string; status?: string } -): Promise { - // Phase 3: Сначала пробуем structural markers (быстрее и точнее) - const allLogs = await this.getAllSessionLogs(teamName); - const results: MemberLogSummary[] = []; - - for (const log of allLogs) { - // Fast path: check for TaskUpdate markers - const hasMarker = await this.hasTaskUpdateMarker(log.filePath, taskId); - if (hasMarker) { - results.push(log); - continue; - } - - // Fallback: keyword search (Phase 1 behaviour) - if (await this.fileMentionsTaskId(log.filePath, taskId)) { - results.push(log); - } - } - - return results.sort((a, b) => - (b.startTime ?? '').localeCompare(a.startTime ?? '') - ); -} +// hasTaskUpdateMarker() может вызываться напрямую из TaskBoundaryParser или из renderer +// для быстрой проверки "поддерживает ли сессия structural scoping" +// Но findLogsForTask() НЕ МЕНЯЕТСЯ. ``` ### 5. IPC (без изменений) @@ -704,11 +830,21 @@ export function ScopeWarningBanner({ warnings, confidence, onDismiss }: ScopeWar #### `ChangeReviewDialog.tsx` (MODIFY) -Добавляем scope information в header: +Добавляем scope information в header. + +**Type guard (H2 fix — cross-phase compatibility):** +```typescript +// Phase 2 компоненты используют TaskChangeSet (Phase 1 тип). +// Phase 3 расширяет до TaskChangeSetV2 с полем .scope. +// ВСЕГДА проверять наличие .scope через 'in' guard: +function isTaskChangeSetV2(cs: TaskChangeSet): cs is TaskChangeSetV2 { + return 'scope' in cs; +} +``` ```typescript // В header диалога (рядом с title) -{mode === 'task' && activeChangeSet && 'scope' in activeChangeSet && ( +{mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
{activeChangeSet.warnings.length > 0 && ( @@ -823,6 +959,7 @@ JSONL Timeline: | `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +150 | | `src/main/services/team/TeamMemberLogsFinder.ts` | MODIFY | +40 | | `src/main/services/team/index.ts` | MODIFY | +1 | +| `src/main/index.ts` | MODIFY | +5 (создать TaskBoundaryParser, передать в ChangeExtractorService) | | `src/renderer/components/team/review/ConfidenceBadge.tsx` | NEW | 45 | | `src/renderer/components/team/review/ScopeWarningBanner.tsx` | NEW | 50 | | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 | diff --git a/docs/iterations/diff-view/phase-4-enhanced-features.md b/docs/iterations/diff-view/phase-4-enhanced-features.md index 5bd7fdb3..6681ed90 100644 --- a/docs/iterations/diff-view/phase-4-enhanced-features.md +++ b/docs/iterations/diff-view/phase-4-enhanced-features.md @@ -49,18 +49,28 @@ export function useDiffNavigation( | Key | Action | Context | |-----|--------|---------| -| `j` или `↓` | Next hunk | Diff view focused | -| `k` или `↑` | Previous hunk | Diff view focused | -| `n` | Next file | Any | -| `p` или `Shift+N` | Previous file | Any | -| `a` | Accept current hunk | Hunk focused | -| `x` | Reject current hunk | Hunk focused | -| `Shift+A` | Accept all hunks in file | File selected | -| `Shift+X` | Reject all hunks in file | File selected | -| `Enter` | Toggle hunk collapse | Hunk focused | -| `Escape` | Close diff dialog | Any | +| `j` или `↓` | Next hunk | Diff dialog open | +| `k` или `↑` | Previous hunk | Diff dialog open | +| `n` | Next file | Diff dialog open | +| `p` или `Shift+N` | Previous file | Diff dialog open | +| `a` | Accept current hunk | Diff dialog open | +| `x` | Reject current hunk | Diff dialog open | +| `Shift+A` | Accept all hunks in file | Diff dialog open | +| `Shift+X` | Reject all hunks in file | Diff dialog open | +| `Enter` | Toggle hunk collapse | Diff dialog open | +| `?` | Show shortcuts help | Diff dialog open | +| `Escape` | Close diff dialog | Diff dialog open | -**Реализация через existing pattern (useKeyboardShortcuts.ts):** +**ВАЖНО (H1 fix): Конфликт с useKeyboardShortcuts!** + +Существующий `useKeyboardShortcuts.ts` уже занимает `Cmd+Shift+K` (цикл contexts). +Все Phase 4 shortcuts работают **ТОЛЬКО внутри открытого ChangeReviewDialog** (модальный контекст). +Это предотвращает конфликты — глобальные shortcuts не срабатывают когда dialog открыт. + +НЕ добавляем shortcuts в `useKeyboardShortcuts.ts` — вместо этого регистрируем +локальный handler внутри `useDiffNavigation` hook с guard `if (!isDialogOpen) return`. + +**Реализация через МОДАЛЬНЫЙ контекст (НЕ useKeyboardShortcuts.ts):** ```typescript useEffect(() => { @@ -122,18 +132,20 @@ useEffect(() => { }, [isDialogOpen, currentHunkIndex, selectedFilePath]); ``` -**Scroll-to-hunk через CodeMirror API:** +**Scroll-to-hunk через CodeMirror API — VERIFIED:** ```typescript -// @codemirror/merge предоставляет goToNextChunk / goToPreviousChunk +// VERIFIED: goToNextChunk и goToPreviousChunk — это StateCommand объекты. +// Вызываются через .run(view) или через keymap: import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge'; function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev') { if (direction === 'next') { - goToNextChunk(editorView); + goToNextChunk.run(editorView); // StateCommand.run() — НЕ прямой вызов! } else { - goToPreviousChunk(editorView); + goToPreviousChunk.run(editorView); } + // Возвращает boolean: true если нашёл chunk, false если конец/начало } ``` @@ -165,44 +177,104 @@ interface KeyboardShortcutsHelpProps { ```typescript const STORAGE_PREFIX = 'diff-viewed'; +const MAX_ENTRIES_PER_SCOPE = 5000; // M2 fix: cap per-scope entries +const MAX_TOTAL_ENTRIES = 50; // M2 fix: max number of scope keys in storage /** - * Ключ = `diff-viewed:{teamName}:{taskOrMemberKey}`. - * Значение = JSON array of viewed file paths. + * Ключ = `diff-viewed:{teamName}:{scopeKey}`. + * Значение = JSON object `{ files: string[], updatedAt: string }`. + * + * R3 FIX: Формат хранения — ВСЕГДА объект { files, updatedAt }, НЕ плоский string[]. + * Это обеспечивает совместимость get/set/cleanup. + * + * M2 fix: scopeKey включает version hash (computedAt) для инвалидации + * при перевычислении changeSet. */ + +interface ViewedStorageEntry { + files: string[]; + updatedAt: string; +} + function getStorageKey(teamName: string, scopeKey: string): string { return `${STORAGE_PREFIX}:${teamName}:${scopeKey}`; } +function parseEntry(raw: string | null): ViewedStorageEntry | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + // R3 FIX: Миграция из старого формата (plain string[]) → новый формат + if (Array.isArray(parsed)) { + return { files: parsed, updatedAt: new Date(0).toISOString() }; + } + if (parsed && Array.isArray(parsed.files)) { + return parsed as ViewedStorageEntry; + } + return null; + } catch { + return null; + } +} + +function saveEntry(teamName: string, scopeKey: string, entry: ViewedStorageEntry): void { + localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry)); +} + +/** M2 fix: Cleanup старых entries при переполнении */ +export function cleanupOldViewedEntries(): void { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(STORAGE_PREFIX)) keys.push(key); + } + // Если слишком много — удаляем самые старые по updatedAt + if (keys.length > MAX_TOTAL_ENTRIES) { + const sorted = keys + .map(k => ({ key: k, entry: parseEntry(localStorage.getItem(k)) })) + .sort((a, b) => (a.entry?.updatedAt ?? '').localeCompare(b.entry?.updatedAt ?? '')); + for (let i = 0; i < sorted.length - MAX_TOTAL_ENTRIES; i++) { + localStorage.removeItem(sorted[i].key); + } + } +} + /** Получить Set просмотренных файлов */ export function getViewedFiles(teamName: string, scopeKey: string): Set { - try { - const raw = localStorage.getItem(getStorageKey(teamName, scopeKey)); - if (!raw) return new Set(); - const arr = JSON.parse(raw) as string[]; - return new Set(arr); - } catch { - return new Set(); - } + const entry = parseEntry(localStorage.getItem(getStorageKey(teamName, scopeKey))); + return entry ? new Set(entry.files) : new Set(); } /** Отметить файл как просмотренный */ export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void { const set = getViewedFiles(teamName, scopeKey); set.add(filePath); - localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify([...set])); + saveEntry(teamName, scopeKey, { + files: [...set], + updatedAt: new Date().toISOString(), + }); } /** Отметить файл как НЕ просмотренный */ export function unmarkFileViewed(teamName: string, scopeKey: string, filePath: string): void { const set = getViewedFiles(teamName, scopeKey); set.delete(filePath); - localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify([...set])); + if (set.size === 0) { + localStorage.removeItem(getStorageKey(teamName, scopeKey)); + return; + } + saveEntry(teamName, scopeKey, { + files: [...set], + updatedAt: new Date().toISOString(), + }); } /** Отметить все файлы как просмотренные */ export function markAllViewed(teamName: string, scopeKey: string, filePaths: string[]): void { - localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(filePaths)); + saveEntry(teamName, scopeKey, { + files: filePaths, + updatedAt: new Date().toISOString(), + }); } /** Сбросить все отметки */ @@ -463,6 +535,9 @@ import { promisify } from 'util'; const execFileAsync = promisify(execFile); export class GitDiffFallback { + // M3 fix: кеш isGitRepo результатов — один exec per projectPath за сессию + private gitRepoCache = new Map(); + /** * Получить содержимое файла из конкретного коммита. * Используется когда file-history-snapshot недоступен. @@ -557,14 +632,20 @@ export class GitDiffFallback { /** * Проверить: является ли projectPath git repo. + * M3 fix: результат кешируется per projectPath (один exec за сессию). */ async isGitRepo(projectPath: string): Promise { + if (this.gitRepoCache.has(projectPath)) { + return this.gitRepoCache.get(projectPath)!; + } try { await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath, }); + this.gitRepoCache.set(projectPath, true); return true; } catch { + this.gitRepoCache.set(projectPath, false); return false; } } @@ -631,6 +712,53 @@ private async tryGitFallback(filePath: string): Promise<...> { } ``` +#### Интеграция в `src/main/index.ts` (MODIFY) + +```typescript +// В initializeIpcHandlers(): +// GitDiffFallback создаётся здесь и передаётся в review handlers +import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; + +const gitDiffFallback = new GitDiffFallback(); + +// Передать gitFallback в ReviewHandlerDeps (через 10-й позиционный параметр): +// initializeReviewHandlers принимает deps — добавляем gitFallback +// (ReviewHandlerDeps.gitFallback? добавлен в Phase 4) +``` + +**ВАЖНО**: `ReviewHandlerDeps` расширяется в Phase 4: +```typescript +// В review.ts +interface ReviewHandlerDeps { + extractor: ChangeExtractorService; + applier?: ChangeApplierService; // Phase 2 + contentResolver?: FileContentResolver; // Phase 2 + gitFallback?: GitDiffFallback; // Phase 4 +} +``` + +`FileContentResolver` конструктор также расширяется для принятия optional `GitDiffFallback`: +```typescript +// В FileContentResolver.ts +constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly gitFallback?: GitDiffFallback, // Phase 4 optional +) {} +``` + +#### Обновление `src/shared/types/api.ts` и `src/renderer/api/httpClient.ts` (MODIFY) + +Добавить Phase 4 метод в `ReviewAPI` interface: +```typescript +// В ReviewAPI (api.ts): +getGitFileLog: (projectPath: string, filePath: string) => + Promise>; + +// В HttpAPIClient (httpClient.ts): +getGitFileLog: async (projectPath: string, filePath: string) => + window.electronAPI.review.getGitFileLog(projectPath, filePath), +``` + #### IPC: `src/preload/constants/ipcChannels.ts` (MODIFY) ```typescript @@ -724,10 +852,13 @@ return ( | `src/preload/constants/ipcChannels.ts` | MODIFY | +1 | | `src/preload/index.ts` | MODIFY | +5 | | `src/main/services/team/index.ts` | MODIFY | +1 | +| `src/main/index.ts` | MODIFY | +5 | +| `src/shared/types/api.ts` | MODIFY | +5 | +| `src/renderer/api/httpClient.ts` | MODIFY | +5 | | **Feature 5: Auto-Viewed** | | | | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +25 | | `src/renderer/components/team/review/ReviewToolbar.tsx` | MODIFY | +15 | -| **Итого** | 7 NEW + 14 MODIFY | ~960 | +| **Итого** | 7 NEW + 17 MODIFY | ~975 | --- diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ee04cc92..5c165244 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -9,6 +9,7 @@ import { getTasksBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; +import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { createLogger } from '@shared/utils/logger'; import { execFile, spawn } from 'child_process'; @@ -253,6 +254,12 @@ async function ensureCwdExists(cwd: string): Promise { } } +function wrapInAgentBlock(text: string): string { + const trimmed = text.trim(); + if (trimmed.length === 0) return ''; + return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; +} + function buildMembersPrompt(members: TeamCreateRequest['members']): string { return members .map((member) => { @@ -263,7 +270,7 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string { } function buildTaskStatusProtocol(teamName: string): string { - return `MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: + return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 1. Use this command to mark task started: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start 2. Use this command to mark task completed BEFORE sending your final reply: @@ -286,7 +293,43 @@ function buildTaskStatusProtocol(teamName: string): string { - Typical flow: a) Owner finishes work on #X → task complete #X b) Reviewer accepts → review approve #X -Failure to follow this protocol means the task board will show incorrect status.`; +Failure to follow this protocol means the task board will show incorrect status.`); +} + +function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string { + return wrapInAgentBlock( + [ + `Internal task board tooling (teamctl.js):`, + `- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`, + ``, + `Task board operations — use teamctl.js via Bash:`, + `- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}"`, + `- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status `, + ``, + `Notification policy:`, + `- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`, + ].join('\n') + ); +} + +function buildAgentBlockUsagePolicy(): string { + return `Agent-only formatting policy (applies to ALL messages you write): +- Humans can see teammate inbox messages and coordination text in the UI. +- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks. +- Any internal operational instructions about tooling/scripts MUST be hidden inside an agent-only block, including: + - how to use internal scripts (e.g. teamctl.js), exact CLI commands, flags (e.g. --notify) + - review command phrases like "review approve " / "review request-changes " + - internal file paths under ~/.claude/ (tools, teams, tasks, kanban state, etc.) + - meta coordination lines like "All teammates are online and have received their assignments via --notify." +- Use an agent-only fenced block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE): + - AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN} + - AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE} + - IMPORTANT: the fence lines must start at the beginning of the line (no indentation). +- Example (copy/paste exactly, no indentation): +${AGENT_BLOCK_OPEN} +(internal instructions: commands, script usage, paths, etc.) +${AGENT_BLOCK_CLOSE} +- Put ONLY the internal instructions inside the agent-only block.`; } function getSystemLocale(): string { @@ -311,12 +354,14 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { const members = buildMembersPrompt(request.members); const taskProtocol = buildTaskStatusProtocol(request.teamName); const languageInstruction = getAgentLanguageInstruction(); + const agentBlockPolicy = buildAgentBlockUsagePolicy(); const userPromptBlock = request.prompt?.trim() ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` : ''; const leadName = request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; + const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName); return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. You are "${leadName}", the team lead. @@ -333,17 +378,18 @@ Constraints: - Keep assistant text minimal. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. -- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task). +- Use the team task board for assigned/substantial work. - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +${teamCtlOps} + Communication protocol (CRITICAL — you are running headless, no one sees your text output): - When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. - Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. - Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). -Task board operations — use teamctl.js via Bash: -- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" -- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status +Message formatting: +${agentBlockPolicy} Steps (execute in this exact order): @@ -359,12 +405,13 @@ Steps (execute in this exact order): ${languageInstruction} Introduce yourself briefly (name and role) and confirm you are ready. Then wait for task assignments. + Include the following agent-only instructions verbatim in the prompt: - ${taskProtocol} +${taskProtocol} -3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations"). +3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. - Prefer fewer, broader tasks over many micro-tasks. - - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. + - Avoid duplicate notifications for the same assignment. 4) After all steps, output a short summary. @@ -383,8 +430,10 @@ function buildLaunchPrompt( : ''; const taskProtocol = buildTaskStatusProtocol(request.teamName); const languageInstruction = getAgentLanguageInstruction(); + const agentBlockPolicy = buildAgentBlockUsagePolicy(); const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; + const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName); return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. You are "${leadName}", the team lead. @@ -401,17 +450,18 @@ Constraints: - Keep assistant text minimal. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. -- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task). +- Use the team task board for assigned/substantial work. - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +${teamCtlOps} + Communication protocol (CRITICAL — you are running headless, no one sees your text output): - When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. - Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. - Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). -Task board operations — use teamctl.js via Bash: -- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" -- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status +Message formatting: +${agentBlockPolicy} Steps (execute in this exact order): @@ -428,12 +478,13 @@ Steps (execute in this exact order): ${languageInstruction} The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready. Then resume any pending work you own (if any) and wait for new assignments. + Include the following agent-only instructions verbatim in the prompt: - ${taskProtocol} +${taskProtocol} -4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations"). +4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. - Prefer fewer, broader tasks over many micro-tasks. - - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. + - Avoid duplicate notifications for the same assignment. 5) After all steps, output a short summary. @@ -1292,7 +1343,10 @@ export class TeamProvisioningService { const message = [ `You have new inbox messages addressed to you (team lead "${leadName}").`, `Process them in order (oldest first).`, - `If action is required, delegate via task creation (teamctl.js --notify) or SendMessage, and keep responses minimal.`, + `If action is required, delegate via task creation or SendMessage, and keep responses minimal.`, + AGENT_BLOCK_OPEN, + `Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`, + AGENT_BLOCK_CLOSE, ``, `Messages:`, ...batch.flatMap((m, idx) => { diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index cb53fe44..9fbaf53b 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -5,6 +5,7 @@ import { api } from '@renderer/api'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; import { format } from 'date-fns'; import { User } from 'lucide-react'; @@ -343,7 +344,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. const hasImages = content.images.length > 0; // Use rawText to preserve /commands inline const textContent = content.rawText ?? content.text ?? ''; - const isLongContent = textContent.length > 500; + const stripped = useMemo(() => stripAgentBlocks(textContent), [textContent]); + const isLongContent = stripped.length > 500; // Extract @path mentions from text const pathMentions = useMemo(() => { @@ -416,8 +418,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. const isExpanded = isManuallyExpanded || shouldAutoExpand; // Determine display text - const displayText = - isLongContent && !isExpanded ? textContent.slice(0, 500) + '...' : textContent; + const displayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped; return (
@@ -433,8 +434,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
- {/* Content - polished bubble with subtle depth */} - {textContent && ( + {/* Content - polished bubble with subtle depth (hide when only agent blocks) */} + {stripped && (
): React. boxShadow: 'var(--chat-user-shadow)', }} > - +
= ({ [teammateMessage.replyToSummary] ); + const displayContent = useMemo( + () => stripAgentBlocks(teammateMessage.content), + [teammateMessage.content] + ); + // Noise: minimal inline row (no card, no expand) if (noiseLabel) { return ( @@ -215,7 +221,7 @@ export const TeammateMessageItem: React.FC = ({ {/* Expanded content */} {isExpanded && (
- +
)}
diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 76052de7..80023aba 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -105,6 +105,7 @@ interface RepositoryCardProps { onClick: () => void; isHighlighted?: boolean; taskCounts?: TaskStatusCounts; + tasksLoading?: boolean; } const RepositoryCard = ({ @@ -112,6 +113,7 @@ const RepositoryCard = ({ onClick, isHighlighted, taskCounts, + tasksLoading, }: Readonly): React.JSX.Element => { const lastActivity = repo.mostRecentSession ? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true }) @@ -133,16 +135,16 @@ const RepositoryCard = ({ : 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised' } `} > - {/* Icon with subtle border */} -
- + {/* Icon + Project name */} +
+
+ +
+

+ {repo.name} +

- {/* Project name */} -

- {repo.name} -

- {/* Project path - monospace, muted */}

{formattedPath}

@@ -181,7 +183,15 @@ const RepositoryCard = ({
{/* Tasks progress bar */} - {taskCounts && + {tasksLoading ? ( +
+
+
+
+
+
+ ) : ( + taskCounts && (() => { const pending = taskCounts.pending ?? 0; const inProgress = taskCounts.inProgress ?? 0; @@ -212,7 +222,8 @@ const RepositoryCard = ({
); - })()} + })() + )} ); }; @@ -348,6 +359,7 @@ const ProjectsGrid = ({ fetchRepositoryGroups, selectRepository, globalTasks, + globalTasksLoading, fetchAllTasks, openTeamsTab, } = useStore( @@ -357,6 +369,7 @@ const ProjectsGrid = ({ fetchRepositoryGroups: s.fetchRepositoryGroups, selectRepository: s.selectRepository, globalTasks: s.globalTasks, + globalTasksLoading: s.globalTasksLoading, fetchAllTasks: s.fetchAllTasks, openTeamsTab: s.openTeamsTab, })) @@ -497,7 +510,8 @@ const ProjectsGrid = ({ openTeamsTab(); }} isHighlighted={!!searchQuery.trim()} - taskCounts={counts} + taskCounts={globalTasksLoading ? undefined : counts} + tasksLoading={globalTasksLoading} /> ); })} diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index b5b86012..e264703b 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -296,7 +296,7 @@ export const GlobalTaskList = ({ Team: {task.teamDisplayName}
)} - +
); })} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index fae821df..30e0f28e 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -23,11 +23,36 @@ function formatTaskDate(dateStr: string | undefined): string | null { return format(d, 'MMM d, yyyy'); } -interface SidebarTaskItemProps { - task: GlobalTask; +function formatUpdatedLabel(task: GlobalTask): string | null { + const updatedStr = task.updatedAt; + if (!updatedStr) return null; + const updated = new Date(updatedStr); + if (isNaN(updated.getTime())) return null; + + // Don't show "updated" if there's no createdAt to compare, or times are within 60s + const createdStr = task.createdAt; + if (createdStr) { + const created = new Date(createdStr); + if (!isNaN(created.getTime()) && Math.abs(updated.getTime() - created.getTime()) < 60_000) { + return null; + } + } + + if (isToday(updated)) return `upd ${format(updated, 'HH:mm')}`; + if (isYesterday(updated)) return 'upd yesterday'; + if (isThisYear(updated)) return `upd ${format(updated, 'MMM d')}`; + return `upd ${format(updated, 'MMM d, yyyy')}`; } -export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => { +interface SidebarTaskItemProps { + task: GlobalTask; + hideTeamName?: boolean; +} + +export const SidebarTaskItem = ({ + task, + hideTeamName, +}: SidebarTaskItemProps): React.JSX.Element => { const openTeamTab = useStore((s) => s.openTeamTab); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const cfg = @@ -37,7 +62,8 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) : (statusConfig[task.status] ?? statusConfig.pending); const StatusIcon = cfg.icon; - const dateLabel = formatTaskDate(task.updatedAt ?? task.createdAt); + const updatedLabel = formatUpdatedLabel(task); + const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); return (