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.
This commit is contained in:
iliya 2026-02-24 20:56:13 +02:00
parent 051a727eb2
commit 61c62741c3
17 changed files with 1052 additions and 277 deletions

View file

@ -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<T>(operation: string, handler: () => Promise<T>): Promise<IpcResult<T>> {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Review handler error [${operation}]:`, message);
return { success: false, error: message };
}
}
// --- Handlers ---
async function handleGetAgentChanges(
_event: IpcMainInvokeEvent,
teamName: string,
memberName: string
): Promise<IpcResult<AgentChangeSet>> {
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<AgentChangeSet>;
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSet>;
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
}
// В 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 |
---

View file

@ -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<RejectResult> {
// 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<RejectResult> {
// Шаг 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<AgentChangeSet>;
getTaskChanges: (...) => Promise<TaskChangeSet>;
getChangeStats: (...) => Promise<ChangeStats>;
// Phase 2
checkConflict: (filePath: string, expectedModified: string) => Promise<ConflictCheckResult>;
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise<RejectResult>;
rejectFile: (filePath: string, original: string, modified: string) => Promise<RejectResult>;
previewReject: (...) => Promise<{ preview: string; hasConflicts: boolean }>;
applyDecisions: (request: ApplyReviewRequest) => Promise<ApplyReviewResult>;
getFileContent: (teamName: string, memberName: string, filePath: string) => Promise<FileChangeWithContent>;
}
```
Также обновить `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<IpcResult<FileChangeWithContent>> {
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<IpcResult<RejectResult>> {
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<IpcResult<ApplyReviewResult>> {
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<ConflictCheckResult>(REVIEW_CHECK_CONFLICT, filePath, expectedModified),
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[]) =>
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices),
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) =>
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices, snippets),
rejectFile: (filePath: string, original: string, modified: string) =>
invokeIpcWithResult<RejectResult>(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<string, HunkD
8. **Multiple agents edited same file** — каждый agent показывается отдельно, reject применяется к конкретному agent's changes
9. **Content source = 'unavailable'** — показываем snippet-only view (Phase 1 fallback) с warning: "Full file content unavailable. Showing snippet diffs only."
10. **Accept без Apply** — decisions хранятся в Zustand (in-memory), пропадают при закрытии dialog. Это by design: accept = "я посмотрел и ОК", reject + Apply = "откатить изменения"
11. **App restart между view и apply**`ApplyReviewRequest` не содержит original/modified content. Если app перезагрузить → `FileContentResolver` переобчислит content из JSONL (кешируется на 3 мин). Worst case: file-history backup + snippet chain дадут тот же результат. Если файл на диске изменился → conflict detection сработает корректно
## Тестирование

View file

@ -64,7 +64,10 @@ export interface TaskBoundariesResult {
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none';
}
/** Расширенный TaskChangeSet с confidence деталями */
/** Расширенный TaskChangeSet с confidence деталями.
* TaskChangeSet определён в Phase 1 (review.ts) — backwards compatible extension.
* Все Phase 1 поля (teamName, taskId, files, confidence, computedAt) сохраняются.
*/
export interface TaskChangeSetV2 extends TaskChangeSet {
scope: TaskChangeScope;
/** Предупреждения для UI */
@ -83,8 +86,8 @@ import { createReadStream } from 'fs';
import * as readline from 'readline';
export class TaskBoundaryParser {
private cache = new Map<string, { data: TaskBoundariesResult; expiresAt: number }>();
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
private cache = new Map<string, { data: TaskBoundariesResult; mtime: number; expiresAt: number }>();
private readonly CACHE_TTL = 60 * 1000; // 1 мин (не 3 — JSONL файлы меняются часто при активной работе)
/**
* Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры.
@ -225,9 +228,12 @@ private extractTeamctlBoundaries(
```typescript
async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
// 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<number, { toolUseId: string; toolName: string; filePath?: string }[]>();
@ -300,7 +306,7 @@ async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
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<string, unknown>): unknown[] | null {
// Subagent format
// Subagent format: entry.message.content
const message = entry.message as Record<string, unknown> | 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<string, unknown>): 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<TaskChangeSet> {
// 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<LogFileRef[]> {
const refs: LogFileRef[] = [];
// Группируем по memberName для batch resolve
const byMember = new Map<string, MemberLogSummary[]>();
for (const log of logs) {
const name = log.memberName ?? 'unknown';
if (!byMember.has(name)) byMember.set(name, []);
byMember.get(name)!.push(log);
}
for (const [memberName, memberLogs] of byMember) {
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
// 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<string, { data: AgentChangeSet; expiresAt: number }>();
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<string>
): Promise<FileChangeSummary[]> {
const fileMap = new Map<string, FileChangeSummary>();
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<FileChangeSummary[]> {
return this.extractFilteredChanges(
[{ filePath, memberName }],
new Set() // empty set + shouldFilter=false → accept all
);
}
```
**Phase 3 (новая реализация getTaskChanges):**
```typescript
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
// 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<TaskChangeSetV2>
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
logs: MemberLogSummary[]
logRefs: LogFileRef[] // C6 fix: LogFileRef вместо MemberLogSummary
): Promise<TaskChangeSetV2> {
// Проверяем: если 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<boolean> {
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<boolean> {
}
```
**Оптимизация `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<MemberLogSummary[]> {
// 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) && (
<div className="flex items-center gap-2">
<ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
{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 |

View file

@ -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<string> {
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<string, boolean>();
/**
* Получить содержимое файла из конкретного коммита.
* Используется когда file-history-snapshot недоступен.
@ -557,14 +632,20 @@ export class GitDiffFallback {
/**
* Проверить: является ли projectPath git repo.
* M3 fix: результат кешируется per projectPath (один exec за сессию).
*/
async isGitRepo(projectPath: string): Promise<boolean> {
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<Array<{ hash: string; timestamp: string; message: string }>>;
// В 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 |
---

View file

@ -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<void> {
}
}
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 <taskId>
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 "<actual-member-name>" --notify --from "${leadName}"`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
``,
`Notification policy:`,
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
].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 <id>" / "review request-changes <id>"
- 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 <teammate-message> 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 <teammate-message teammate_id="alice">...</teammate-message>, 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 "<actual-member-name>" --notify --from "${leadName}"
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
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 <teammate-message> 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 <teammate-message teammate_id="alice">...</teammate-message>, 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 "<actual-member-name>" --notify --from "${leadName}"
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
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) => {

View file

@ -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<UserChatGroupProps>): 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<UserChatGroupProps>): 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 (
<div className="flex justify-end">
@ -433,8 +434,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
<User className="size-3.5" style={{ color: 'var(--color-text-secondary)' }} />
</div>
{/* Content - polished bubble with subtle depth */}
{textContent && (
{/* Content - polished bubble with subtle depth (hide when only agent blocks) */}
{stripped && (
<div
className="group relative overflow-hidden rounded-2xl rounded-br-sm px-4 py-3"
style={{
@ -443,7 +444,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
boxShadow: 'var(--chat-user-shadow)',
}}
>
<CopyButton text={textContent} bgColor="var(--chat-user-bg)" />
<CopyButton text={stripped} bgColor="var(--chat-user-bg)" />
<div className="text-sm" style={{ color: 'var(--chat-user-text)' }} data-search-content>
<ReactMarkdown

View file

@ -10,6 +10,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
@ -98,6 +99,11 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
[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<TeammateMessageItemProps> = ({
{/* Expanded content */}
{isExpanded && (
<div className="p-3">
<MarkdownViewer content={teammateMessage.content} copyable />
<MarkdownViewer content={displayContent} copyable />
</div>
)}
</div>

View file

@ -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<RepositoryCardProps>): 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 */}
<div className="mb-3 flex size-8 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" />
{/* Icon + Project name */}
<div className="mb-1 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" />
</div>
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name}
</h3>
</div>
{/* Project name */}
<h3 className="mb-1 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name}
</h3>
{/* Project path - monospace, muted */}
<p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p>
@ -181,7 +183,15 @@ const RepositoryCard = ({
</div>
{/* Tasks progress bar */}
{taskCounts &&
{tasksLoading ? (
<div className="mt-2 w-full">
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 animate-pulse overflow-hidden rounded-full bg-[var(--color-surface-raised)]" />
<div className="h-2.5 w-6 animate-pulse rounded bg-[var(--color-surface-raised)]" />
</div>
</div>
) : (
taskCounts &&
(() => {
const pending = taskCounts.pending ?? 0;
const inProgress = taskCounts.inProgress ?? 0;
@ -212,7 +222,8 @@ const RepositoryCard = ({
</div>
</div>
);
})()}
})()
)}
</button>
);
};
@ -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}
/>
);
})}

View file

@ -296,7 +296,7 @@ export const GlobalTaskList = ({
Team: {task.teamDisplayName}
</div>
)}
<SidebarTaskItem task={task} />
<SidebarTaskItem task={task} hideTeamName />
</div>
);
})}

View file

@ -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 (
<button
@ -66,12 +92,18 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme
style={{ color: 'var(--color-text-muted)' }}
>
<span>{task.owner ?? 'unassigned'}</span>
<span className="opacity-40">·</span>
<span className="truncate">{task.teamDisplayName}</span>
{!hideTeamName && (
<>
<span className="opacity-40">·</span>
<span className="truncate">{task.teamDisplayName}</span>
</>
)}
{dateLabel && (
<>
<span className="opacity-40">·</span>
<span className="shrink-0">{dateLabel}</span>
<span className={`shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
{dateLabel}
</span>
</>
)}
</div>

View file

@ -18,6 +18,7 @@ import { useStore } from '@renderer/store';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
Bell,
CheckCheck,
@ -1073,7 +1074,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}}
onReplyToMessage={(message) => {
setSendDialogRecipient(message.from);
setReplyQuote({ from: message.from, text: message.text });
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
setSendDialogOpen(true);
}}
onMessageVisible={handleMessageVisible}

View file

@ -18,7 +18,7 @@ import {
parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react';
@ -121,11 +121,6 @@ function getSystemMessageLabel(text: string): string | null {
return null;
}
/** Strip ```info_for_agent ... ``` blocks from text for UI display. */
function stripAgentBlocks(text: string): string {
return text.replace(createAgentBlockRegex(), '').trim();
}
// ---------------------------------------------------------------------------
// Full message card — left colored border, name badge, collapsible content
// ---------------------------------------------------------------------------
@ -361,12 +356,7 @@ export const ActivityItem = ({
) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} />
) : (
<MarkdownViewer
content={displayText ?? message.text}
maxHeight="max-h-56"
copyable
bare
/>
<MarkdownViewer content={displayText ?? ''} maxHeight="max-h-56" copyable bare />
)}
{message.attachments?.length && message.messageId ? (
<AttachmentDisplay

View file

@ -10,6 +10,7 @@ import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
@ -126,7 +127,9 @@ export const TaskCommentsSection = ({
onClick={() =>
setReplyTo({
author: comment.author,
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
text: stripAgentBlocks(
parseMessageReply(comment.text)?.replyText ?? comment.text
),
})
}
>
@ -139,7 +142,8 @@ export const TaskCommentsSection = ({
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const displayText = reply ? reply.replyText : comment.text;
const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = stripAgentBlocks(rawForDisplay);
const needsExpandCollapse = displayText.includes('\n');
const expanded = expandedCommentIds.has(comment.id);
const collapsedHeight = 'max-h-[120px]';
@ -154,14 +158,18 @@ export const TaskCommentsSection = ({
>
{reply ? (
<ReplyQuoteBlock
reply={reply}
reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
}}
bodyMaxHeight={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
}
/>
) : (
<MarkdownViewer
content={comment.text}
content={displayText}
maxHeight={
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
}

View file

@ -7,7 +7,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { createAgentBlockRegex, stripAgentBlocks } from '@shared/constants/agentBlocks';
import { format } from 'date-fns';
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
@ -102,7 +102,7 @@ function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[]
.trim();
if (content) agentInfo.push(content);
}
const humanText = raw.replace(createAgentBlockRegex(), '').trim();
const humanText = stripAgentBlocks(raw);
return { humanText, agentInfo };
}

View file

@ -3,6 +3,7 @@
*/
import { findLastOutput } from '@renderer/utils/aiGroupEnhancer';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch';
import type { AppState, SearchMatch } from '../types';
@ -257,8 +258,8 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
for (const item of conversation.items) {
if (item.type === 'user') {
// Search user message text
const text = item.group.content.rawText ?? item.group.content.text ?? '';
const raw = item.group.content.rawText ?? item.group.content.text ?? '';
const text = stripAgentBlocks(raw);
addMarkdownMatches(text, item.group.id, 'user');
} else if (item.type === 'ai') {
// For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents)

View file

@ -19,6 +19,42 @@ function getEffectiveDate(task: GlobalTask): string | undefined {
return task.updatedAt ?? task.createdAt;
}
function getEffectiveTs(task: GlobalTask): number {
const d = getEffectiveDate(task);
return d ? new Date(d).getTime() : 0;
}
/**
* Build a map: teamName max effective timestamp among its tasks.
* Used to sort team sub-groups by most recent activity (not alphabetically).
*/
function buildTeamMaxTs(tasks: GlobalTask[]): Map<string, number> {
const m = new Map<string, number>();
for (const t of tasks) {
const ts = getEffectiveTs(t);
const cur = m.get(t.teamName) ?? 0;
if (ts > cur) m.set(t.teamName, ts);
}
return m;
}
/**
* Sort comparator: teams ordered by most recent task (desc),
* within the same team by individual task date (desc).
*/
function compareByTeamFreshness(
a: GlobalTask,
b: GlobalTask,
teamMaxTs: Map<string, number>
): number {
if (a.teamName !== b.teamName) {
const teamTsA = teamMaxTs.get(a.teamName) ?? 0;
const teamTsB = teamMaxTs.get(b.teamName) ?? 0;
return teamTsB - teamTsA;
}
return getEffectiveTs(b) - getEffectiveTs(a);
}
function getDateCategory(dateStr: string | undefined): DateCategory {
if (!dateStr) return 'Older';
const d = new Date(dateStr);
@ -43,15 +79,8 @@ export function groupTasksByDate(tasks: GlobalTask[]): DateGroupedTasks {
}
for (const cat of DATE_CATEGORY_ORDER) {
groups[cat].sort((a, b) => {
const cmp = a.teamName.localeCompare(b.teamName);
if (cmp !== 0) return cmp;
const dateA = getEffectiveDate(a);
const dateB = getEffectiveDate(b);
const tsA = dateA ? new Date(dateA).getTime() : 0;
const tsB = dateB ? new Date(dateB).getTime() : 0;
return tsB - tsA;
});
const teamTs = buildTeamMaxTs(groups[cat]);
groups[cat].sort((a, b) => compareByTeamFreshness(a, b, teamTs));
}
return groups;
@ -94,15 +123,8 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] {
}
for (const entry of byKey.values()) {
entry.tasks.sort((a, b) => {
const cmp = a.teamName.localeCompare(b.teamName);
if (cmp !== 0) return cmp;
const dateA = getEffectiveDate(a);
const dateB = getEffectiveDate(b);
const tsA = dateA ? new Date(dateA).getTime() : 0;
const tsB = dateB ? new Date(dateB).getTime() : 0;
return tsB - tsA;
});
const teamTs = buildTeamMaxTs(entry.tasks);
entry.tasks.sort((a, b) => compareByTeamFreshness(a, b, teamTs));
}
const groups: ProjectTaskGroup[] = [];
@ -112,18 +134,8 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] {
}
groups.sort((a, b) => {
const tsA = Math.max(
...a.tasks.map((t) => {
const d = getEffectiveDate(t);
return d ? new Date(d).getTime() : 0;
})
);
const tsB = Math.max(
...b.tasks.map((t) => {
const d = getEffectiveDate(t);
return d ? new Date(d).getTime() : 0;
})
);
const tsA = Math.max(...a.tasks.map(getEffectiveTs));
const tsB = Math.max(...b.tasks.map(getEffectiveTs));
return tsB - tsA;
});

View file

@ -26,6 +26,13 @@ export function createAgentBlockRegex(): RegExp {
return new RegExp(AGENT_BLOCK_PATTERN, 'g');
}
/**
* Removes ```info_for_agent ... ``` blocks from text for UI display.
*/
export function stripAgentBlocks(text: string): string {
return text.replace(createAgentBlockRegex(), '').trim();
}
/**
* @deprecated Use createAgentBlockRegex() instead to avoid stateful 'g' flag issues.
* Kept for backward compatibility with .replace() calls.