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; totalLinesAdded: number;
totalLinesRemoved: number; totalLinesRemoved: number;
totalFiles: number; totalFiles: number;
confidence: 'high' | 'medium' | 'low'; confidence: 'high' | 'medium' | 'low' | 'fallback'; // 'fallback' добавлен для Phase 3 Tier 4
computedAt: string; computedAt: string;
} }
@ -110,7 +110,14 @@ export class ChangeExtractorService {
``` ```
5. **Пропуск ошибок**: Следующий за tool_use блок `tool_result` с `is_error: true` → пропускаем этот tool_use 5. **Пропуск ошибок**: Следующий за tool_use блок `tool_result` с `is_error: true` → пропускаем этот tool_use
6. **Фильтрация proxy_ префикса**: Имена инструментов приходят как `proxy_Edit` — нужно strip prefix (паттерн из MemberStatsComputer) 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`):** **Task scoping (для `getTaskChanges`):**
@ -142,14 +149,32 @@ export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats';
```typescript ```typescript
import { IpcMain, IpcMainInvokeEvent } from 'electron'; 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 { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { REVIEW_GET_AGENT_CHANGES, REVIEW_GET_TASK_CHANGES, REVIEW_GET_CHANGE_STATS } from '@preload/constants/ipcChannels'; 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; let changeExtractor: ChangeExtractorService | null = null;
export function initializeReviewHandlers(service: ChangeExtractorService): void { function getChangeExtractor(): ChangeExtractorService {
changeExtractor = service; 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 { export function registerReviewHandlers(ipcMain: IpcMain): void {
@ -164,19 +189,140 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS); 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 ### 5. Регистрация в main process
В `src/main/index.ts` (или где инициализируются IPC): **Файл: `src/main/ipc/handlers.ts`** — единственное место регистрации ВСЕХ IPC handlers.
- Создать `ChangeExtractorService` с зависимостью `TeamMemberLogsFinder`
- Вызвать `initializeReviewHandlers(changeExtractor)`
- Вызвать `registerReviewHandlers(ipcMain)` после team 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 ```typescript
review: { review: {
getAgentChanges: (teamName: string, memberName: string) => getAgentChanges: (teamName: string, memberName: string) =>
@ -216,6 +362,17 @@ export interface ChangeReviewSlice {
Зарегистрировать в `src/renderer/store/index.ts` как новый slice. Зарегистрировать в `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. Компоненты ### 8. Компоненты
#### `src/renderer/components/team/review/ChangeReviewDialog.tsx` (NEW) #### `src/renderer/components/team/review/ChangeReviewDialog.tsx` (NEW)
@ -266,21 +423,25 @@ export interface ChangeReviewSlice {
| Файл | Тип | ~LOC | | Файл | Тип | ~LOC |
|------|-----|---:| |------|-----|---:|
| `src/shared/types/review.ts` | NEW | 80 | | `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/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/services/team/index.ts` | MODIFY | +1 |
| `src/main/index.ts` | MODIFY | +10 | | `src/main/index.ts` | MODIFY | +10 |
| `src/preload/constants/ipcChannels.ts` | MODIFY | +3 | | `src/preload/constants/ipcChannels.ts` | MODIFY | +3 |
| `src/preload/index.ts` | MODIFY | +10 | | `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/slices/changeReviewSlice.ts` | NEW | 100 |
| `src/renderer/store/index.ts` | MODIFY | +5 | | `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/ChangeReviewDialog.tsx` | NEW | 150 |
| `src/renderer/components/team/review/ReviewFileTree.tsx` | NEW | 180 | | `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/review/ChangeStatsBadge.tsx` | NEW | 40 |
| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +30 | | `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +30 |
| `src/renderer/components/team/TeamDetailView.tsx` | MODIFY | +40 | | `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-javascript @codemirror/lang-python @codemirror/lang-json
pnpm add @codemirror/lang-css @codemirror/lang-html @codemirror/lang-xml pnpm add @codemirror/lang-css @codemirror/lang-html @codemirror/lang-xml
pnpm add @codemirror/theme-one-dark pnpm add @codemirror/theme-one-dark
pnpm add diff # jsdiff v8 — applyPatch, reversePatch pnpm add diff # jsdiff v8 — structuredPatch, applyPatch (НЕТ reversePatch!)
pnpm add node-diff3 # Three-way merge для конфликтов pnpm add node-diff3 # Three-way merge для конфликтов (diff3Merge)
``` ```
**Примечание**: `react-codemirror-merge` НЕ используем — пишем свой React wrapper для полного контроля над lifecycle и event handling. **Примечание**: `react-codemirror-merge` НЕ используем — пишем свой React wrapper для полного контроля над lifecycle и event handling.
@ -79,7 +79,8 @@ export interface FileChangeWithContent extends FileChangeSummary {
/** Полное содержимое файла ПОСЛЕ изменений (для CodeMirror modified) */ /** Полное содержимое файла ПОСЛЕ изменений (для CodeMirror modified) */
modifiedFullContent: string | null; modifiedFullContent: string | null;
/** Источник original content */ /** Источник 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<{ ): Promise<{
original: string | null; original: string | null;
modified: 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 детально:** **Reject algorithm детально:**
У нас есть два подхода. **PRIMARY** — snippet-level replace (простой и надёжный). **FALLBACK** — hunk-level inverse patch (для случаев когда snippet-chain неполный).
**PRIMARY: Snippet-level replace (рекомендуемый)**
```typescript ```typescript
// Шаг 1: Вычислить structured patch // Простейший подход: у нас уже есть snippets с (oldString, newString)
const patch = Diff.structuredPatch('file', 'file', original, modified); // Reject = заменить newString обратно на oldString
// patch.hunks = [ { oldStart, oldLines, newStart, newLines, lines: ['+', '-', ' '] } ] async rejectHunks(
filePath: string,
original: string,
modified: string,
hunkIndicesToReject: number[],
snippets: SnippetDiff[]
): Promise<RejectResult> {
// 1. Прочитать текущий файл с диска
let content = await readFile(filePath, 'utf8');
// Шаг 2: Отфильтровать только rejected hunks // 2. Проверить: файл не изменён с момента agent changes?
const rejectedPatch = { if (content !== modified) {
...patch, // Конфликт — файл был модифицирован
hunks: patch.hunks.filter((_, idx) => hunkIndicesToReject.includes(idx)) return this.resolveWithThreeWayMerge(original, content, modified, hunkIndicesToReject, snippets);
}; }
// Шаг 3: Reverse patch (откат) // 3. Применить snippet-level replace для rejected hunks
// jsdiff.applyPatch НЕ имеет reversed: true! //
// Нужно вручную инвертировать: '+' → '-', '-' → '+', swap oldStart↔newStart // R2 FIX: Используем ПОЗИЦИОННЫЙ reverse (не хронологический!):
const inversePatch = invertPatch(rejectedPatch); // - Сначала находим ВСЕ позиции через indexOf
// - Сортируем по позиции УБЫВАНИЯ (от конца файла к началу)
// - Применяем замены — каждая не сдвигает позиции предыдущих
//
// Также: если newString встречается в файле несколько раз,
// используем позицию ближайшую к ожидаемой (на основе snippet order).
// Шаг 4: Применить к modified content const rejectedSnippets = hunkIndicesToReject
const result = Diff.applyPatch(modified, inversePatch); .map(idx => snippets[idx])
if (result === false) { .filter(Boolean);
// Patch не применился — конфликт
return threeWayMerge(original, currentDisk, targetContent); // Найти позиции ПЕРЕД заменами
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 ```typescript
function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff { function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff {
return { return {
...patch, ...patch,
oldFileName: patch.newFileName,
newFileName: patch.oldFileName,
oldHeader: patch.newHeader ?? '',
newHeader: patch.oldHeader ?? '',
hunks: patch.hunks.map(hunk => ({ hunks: patch.hunks.map(hunk => ({
oldStart: hunk.newStart, oldStart: hunk.newStart,
oldLines: hunk.newLines, oldLines: hunk.newLines,
@ -273,27 +360,34 @@ function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff {
lines: hunk.lines.map(line => { lines: hunk.lines.map(line => {
if (line.startsWith('+')) return '-' + line.slice(1); if (line.startsWith('+')) return '-' + line.slice(1);
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 ```typescript
import { diff3Merge } from 'node-diff3'; 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( function threeWayMerge(
base: string, // Original content before agent changes base: string, // Original content before agent changes
ours: string, // Current file on disk (user's version) ours: string, // Current file on disk (user's version)
theirs: string // What we want after reject theirs: string // What we want after reject
): { content: string; hasConflicts: boolean } { ): { content: string; hasConflicts: boolean } {
const result = diff3Merge( const result = diff3Merge(
ours.split('\n'), ours.split('\n'), // a = current disk
base.split('\n'), base.split('\n'), // o = original (base)
theirs.split('\n') theirs.split('\n') // b = target after reject
); );
let hasConflicts = false; let hasConflicts = false;
@ -302,12 +396,14 @@ function threeWayMerge(
for (const part of result) { for (const part of result) {
if ('ok' in part) { if ('ok' in part) {
lines.push(...part.ok); lines.push(...part.ok);
} else { } else if ('conflict' in part) {
hasConflicts = true; hasConflicts = true;
lines.push('<<<<<<< Current (yours)'); 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('=======');
lines.push(...(part.conflict?.b ?? [])); lines.push(...(part.conflict.b ?? []));
lines.push('>>>>>>> Reverted (rejected changes)'); 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) ### 5. IPC хендлеры: `src/main/ipc/review.ts` (MODIFY — расширение Phase 1)
**Регистрация**: В `src/main/index.ts` `initializeServices()` создать новые сервисы:
```typescript ```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 reviewApplier: ReviewApplierService | null = null;
let fileContentResolver: FileContentResolver | null = null; let fileContentResolver: FileContentResolver | null = null;
export function initializeReviewHandlers( // Phase 2: Расширяем ReviewHandlerDeps из Phase 1 (объект-конфиг — forward compatible)
extractor: ChangeExtractorService, // Phase 1 определил: interface ReviewHandlerDeps { extractor: ChangeExtractorService; ... }
applier: ReviewApplierService, // Phase 2 добавляет optional fields (НЕ ломает Phase 1 вызов):
contentResolver: FileContentResolver interface ReviewHandlerDeps {
): void { extractor: ChangeExtractorService;
changeExtractor = extractor; applier?: ReviewApplierService;
reviewApplier = applier; contentResolver?: FileContentResolver;
fileContentResolver = contentResolver; // 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 хендлеров // Регистрация Phase 2 хендлеров
@ -368,7 +516,7 @@ async function handleGetFileContent(
memberName: string, memberName: string,
filePath: string filePath: string
): Promise<IpcResult<FileChangeWithContent>> { ): Promise<IpcResult<FileChangeWithContent>> {
return wrapHandler('review:getFileContent', async () => { return wrapReviewHandler('review:getFileContent', async () => {
const resolver = getContentResolver(); const resolver = getContentResolver();
const result = await resolver.resolveFileContent(teamName, memberName, filePath); const result = await resolver.resolveFileContent(teamName, memberName, filePath);
return result; return result;
@ -380,11 +528,14 @@ async function handleRejectHunks(
filePath: string, filePath: string,
original: string, original: string,
modified: string, modified: string,
hunkIndices: number[] hunkIndices: number[],
snippets: SnippetDiff[] // R1 fix: renderer MUST передать snippets
): Promise<IpcResult<RejectResult>> { ): 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(); 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, _event: IpcMainInvokeEvent,
request: ApplyReviewRequest request: ApplyReviewRequest
): Promise<IpcResult<ApplyReviewResult>> { ): Promise<IpcResult<ApplyReviewResult>> {
return wrapHandler('review:applyDecisions', async () => { return wrapReviewHandler('review:applyDecisions', async () => {
const applier = getApplier(); const applier = getApplier();
const resolver = getContentResolver(); const resolver = getContentResolver();
@ -424,8 +575,8 @@ review: {
// Phase 2 // Phase 2
checkConflict: (filePath: string, expectedModified: string) => checkConflict: (filePath: string, expectedModified: string) =>
invokeIpcWithResult<ConflictCheckResult>(REVIEW_CHECK_CONFLICT, filePath, expectedModified), invokeIpcWithResult<ConflictCheckResult>(REVIEW_CHECK_CONFLICT, filePath, expectedModified),
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[]) => rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) =>
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices), invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices, snippets),
rejectFile: (filePath: string, original: string, modified: string) => rejectFile: (filePath: string, original: string, modified: string) =>
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_FILE, filePath, original, modified), invokeIpcWithResult<RejectResult>(REVIEW_REJECT_FILE, filePath, original, modified),
previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[]) => previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[]) =>
@ -592,9 +743,9 @@ function mapReviewError(error: unknown): string {
```typescript ```typescript
import { useRef, useEffect, useMemo } from 'react'; import { useRef, useEffect, useMemo } from 'react';
import { EditorView, ViewUpdate } from '@codemirror/view'; import { EditorView, keymap } from '@codemirror/view';
import { EditorState } from '@codemirror/state'; import { EditorState, Transaction } from '@codemirror/state';
import { unifiedMergeView } from '@codemirror/merge'; import { unifiedMergeView, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python'; import { python } from '@codemirror/lang-python';
import { json } from '@codemirror/lang-json'; 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 ```typescript
// mergeControls принимает callback factory // VERIFIED: mergeControls сигнатура:
// type = 'accept' | 'reject', action = closure для применения // (type: "reject" | "accept", action: (e: MouseEvent) => void) => HTMLElement
//
// ВАЖНО: action — это callback с MouseEvent параметром!
// Кнопки должны использовать onmousedown (не onclick) — это паттерн CM.
mergeControls: showMergeControls mergeControls: showMergeControls
? (type, action) => { ? (type: 'reject' | 'accept', action: (e: MouseEvent) => void) => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = type === 'accept' btn.className = type === 'accept'
? 'cm-merge-accept-btn' ? 'cm-merge-accept-btn'
: 'cm-merge-reject-btn'; : 'cm-merge-reject-btn';
btn.textContent = type === 'accept' ? 'Accept' : 'Reject'; btn.textContent = type === 'accept' ? 'Accept' : 'Reject';
btn.title = type === 'accept' btn.title = type === 'accept'
? 'Keep this change (Ctrl+Shift+A)' ? 'Keep this change'
: 'Revert this change (Ctrl+Shift+R)'; : 'Revert this change';
btn.onclick = (e) => { btn.onmousedown = action; // ВАЖНО: onmousedown, НЕ onclick!
e.stopPropagation();
action(); // CM applies the change internally
};
return btn; return btn;
} }
: undefined, : undefined,
``` ```
4. **Event tracking для hunk index**: 4. **Event tracking для accept/reject — VERIFIED API:**
```typescript ```typescript
// CodeMirror merge fires user events 'accept' and 'revert' // VERIFIED: CodeMirror merge помечает transactions через annotation:
// НО не сообщает hunk index напрямую! // - Accept: Transaction.userEvent = "accept"
// Решение: Отслеживать через transaction.changes и chunk positions // - Reject: Transaction.userEvent = "revert"
//
// НЕ используем tr.isUserEvent() — используем tr.annotation()!
let hunkCounter = 0; EditorView.updateListener.of((update) => {
EditorView.updateListener.of((update: ViewUpdate) => {
for (const tr of update.transactions) { for (const tr of update.transactions) {
if (tr.isUserEvent('accept')) { const userEvent = tr.annotation(Transaction.userEvent);
onHunkAccepted?.(hunkCounter); if (userEvent === 'accept') {
hunkCounter++; // Определяем hunk index по позиции cursor ПЕРЕД транзакцией
const pos = tr.startState.selection.main.head;
const hunkIndex = computeHunkIndexAtPos(tr.startState, pos);
onHunkAccepted?.(hunkIndex);
} }
if (tr.isUserEvent('revert')) { if (userEvent === 'revert') {
onHunkRejected?.(hunkCounter); const pos = tr.startState.selection.main.head;
hunkCounter++; 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)**: 5. **Тема (CSS variables integration)**:
```typescript ```typescript
@ -785,18 +985,37 @@ export function CodeMirrorDiffView({
}, { dark: true }); }, { dark: true });
``` ```
6. **Extensions assembly**: 6. **Extensions assembly — VERIFIED unifiedMergeView config:**
```typescript ```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(() => [ const extensions = useMemo(() => [
readOnly ? EditorState.readOnly.of(true) : [], readOnly ? EditorState.readOnly.of(true) : [],
readOnly ? EditorView.editable.of(false) : [], readOnly ? EditorView.editable.of(false) : [],
getLanguageExtension(fileName), getLanguageExtension(fileName),
customTheme, customTheme,
keymap.of([
{ key: 'Ctrl-Alt-ArrowDown', run: goToNextChunk },
{ key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk },
]),
unifiedMergeView({ unifiedMergeView({
original, original,
mergeControls: showMergeControls ? mergeControlsFactory : undefined, mergeControls: showMergeControls ? mergeControlsFactory : undefined,
collapseUnchanged: collapseUnchanged ? { margin: collapseMargin } : undefined, highlightChanges: true,
gutter: true,
syntaxHighlightDeletions: true, syntaxHighlightDeletions: true,
collapseUnchanged: collapseUnchanged
? { margin: collapseMargin, minSize: 4 }
: undefined,
}), }),
updateListener, updateListener,
].flat(), [original, modified, fileName, showMergeControls, collapseUnchanged]); ].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 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." 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 = "откатить изменения" 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'; 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 { export interface TaskChangeSetV2 extends TaskChangeSet {
scope: TaskChangeScope; scope: TaskChangeScope;
/** Предупреждения для UI */ /** Предупреждения для UI */
@ -83,8 +86,8 @@ import { createReadStream } from 'fs';
import * as readline from 'readline'; import * as readline from 'readline';
export class TaskBoundaryParser { export class TaskBoundaryParser {
private cache = new Map<string, { data: TaskBoundariesResult; expiresAt: number }>(); private cache = new Map<string, { data: TaskBoundariesResult; mtime: number; expiresAt: number }>();
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин private readonly CACHE_TTL = 60 * 1000; // 1 мин (не 3 — JSONL файлы меняются часто при активной работе)
/** /**
* Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры. * Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры.
@ -225,9 +228,12 @@ private extractTeamctlBoundaries(
```typescript ```typescript
async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> { 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); 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 boundaries: TaskBoundary[] = [];
const allToolUsesByLine = new Map<number, { toolUseId: string; toolName: string; filePath?: string }[]>(); const allToolUsesByLine = new Map<number, { toolUseId: string; toolName: string; filePath?: string }[]>();
@ -300,7 +306,7 @@ async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
detectedMechanism, 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; return result;
} }
``` ```
@ -395,18 +401,25 @@ private computeScopes(
} }
``` ```
**extractContent helper (паттерн из MemberStatsComputer):** **extractContent helper (паттерн из MemberStatsComputer + FIXED для всех JSONL форматов):**
```typescript ```typescript
// Subagent JSONL: entry.message.content (массив блоков) // JSONL файлы содержат СМЕШАННЫЕ форматы entries:
// Main JSONL: entry.content (массив блоков) // 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 { private extractContent(entry: Record<string, unknown>): unknown[] | null {
// Subagent format // Subagent format: entry.message.content
const message = entry.message as Record<string, unknown> | undefined; const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) { if (message && Array.isArray(message.content)) {
return message.content; return message.content;
} }
// Main format fallback // Main format: entry.content (array)
if (Array.isArray(entry.content)) { if (Array.isArray(entry.content)) {
return 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) ### 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 ```typescript
// Phase 1 (заменяем) /** Внутренний тип — НЕ экспортируется из модуля */
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSet> { interface LogFileRef {
// Keyword search через logsFinder.findLogsForTask() 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> { async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
// 1. Найти JSONL файлы через logsFinder // 1. Найти MemberLogSummary через logsFinder (реальный API)
const logs = await this.logsFinder.findLogsForTask(teamName, taskId); 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[] = []; const allScopes: TaskChangeScope[] = [];
for (const log of logs) { for (const ref of logRefs) {
const boundaries = await this.boundaryParser.parseBoundaries(log.filePath); const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
const scope = boundaries.scopes.find(s => s.taskId === taskId); const scope = boundaries.scopes.find(s => s.taskId === taskId);
if (scope) { if (scope) {
scope.memberName = log.memberName; scope.memberName = ref.memberName;
allScopes.push(scope); allScopes.push(scope);
} }
} }
// 3. Если нет structural scopes → fallback на single-task assumption // 4. Если нет structural scopes → fallback на single-task assumption
if (allScopes.length === 0) { 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 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) // 5. Compute confidence (worst case across all scopes)
const worstTier = Math.max(...allScopes.map(s => s.confidence.tier)); 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( private async fallbackSingleTaskScope(
teamName: string, teamName: string,
taskId: string, taskId: string,
logs: MemberLogSummary[] logRefs: LogFileRef[] // C6 fix: LogFileRef вместо MemberLogSummary
): Promise<TaskChangeSetV2> { ): Promise<TaskChangeSetV2> {
// Проверяем: если agent работал только над одной задачей, // Проверяем: если agent работал только над одной задачей,
// ВСЕ изменения в сессии = изменения этой задачи // ВСЕ изменения в сессии = изменения этой задачи
for (const log of logs) { for (const ref of logRefs) {
const boundaries = await this.boundaryParser.parseBoundaries(log.filePath); const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
if (boundaries.isSingleTaskSession) { if (boundaries.isSingleTaskSession) {
// Весь файл = одна задача → extract все changes // Весь файл = одна задача → extract все changes
const files = await this.extractAllChanges(log.filePath); const files = await this.extractAllChanges(ref.filePath, ref.memberName);
return { return {
teamName, teamName,
taskId, taskId,
@ -500,7 +630,7 @@ private async fallbackSingleTaskScope(
computedAt: new Date().toISOString(), computedAt: new Date().toISOString(),
scope: { scope: {
taskId, taskId,
memberName: log.memberName, memberName: ref.memberName,
startLine: 1, startLine: 1,
endLine: Infinity, endLine: Infinity,
startTimestamp: '', startTimestamp: '',
@ -515,7 +645,8 @@ private async fallbackSingleTaskScope(
} }
// No single-task session found → Tier 4 fallback // 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 { return {
teamName, teamName,
taskId, taskId,
@ -527,7 +658,7 @@ private async fallbackSingleTaskScope(
computedAt: new Date().toISOString(), computedAt: new Date().toISOString(),
scope: { scope: {
taskId, taskId,
memberName: logs[0]?.memberName ?? 'unknown', memberName: firstRef?.memberName ?? 'unknown',
startLine: 1, startLine: 1,
endLine: Infinity, endLine: Infinity,
startTimestamp: '', startTimestamp: '',
@ -539,6 +670,21 @@ private async fallbackSingleTaskScope(
warnings: ['Could not determine task boundaries. Showing all changes from this session.'], 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) ### 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 stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); 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) { for await (const line of rl) {
if (line.includes('TaskUpdate') && pattern.test(line)) { 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 ```typescript
async findLogsForTask( // hasTaskUpdateMarker() может вызываться напрямую из TaskBoundaryParser или из renderer
teamName: string, // для быстрой проверки "поддерживает ли сессия structural scoping"
taskId: string, // Но findLogsForTask() НЕ МЕНЯЕТСЯ.
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 ?? '')
);
}
``` ```
### 5. IPC (без изменений) ### 5. IPC (без изменений)
@ -704,11 +830,21 @@ export function ScopeWarningBanner({ warnings, confidence, onDismiss }: ScopeWar
#### `ChangeReviewDialog.tsx` (MODIFY) #### `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 ```typescript
// В header диалога (рядом с title) // В header диалога (рядом с title)
{mode === 'task' && activeChangeSet && 'scope' in activeChangeSet && ( {mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ConfidenceBadge confidence={activeChangeSet.scope.confidence} /> <ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
{activeChangeSet.warnings.length > 0 && ( {activeChangeSet.warnings.length > 0 && (
@ -823,6 +959,7 @@ JSONL Timeline:
| `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +150 | | `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +150 |
| `src/main/services/team/TeamMemberLogsFinder.ts` | MODIFY | +40 | | `src/main/services/team/TeamMemberLogsFinder.ts` | MODIFY | +40 |
| `src/main/services/team/index.ts` | MODIFY | +1 | | `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/ConfidenceBadge.tsx` | NEW | 45 |
| `src/renderer/components/team/review/ScopeWarningBanner.tsx` | NEW | 50 | | `src/renderer/components/team/review/ScopeWarningBanner.tsx` | NEW | 50 |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 | | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 |

View file

@ -49,18 +49,28 @@ export function useDiffNavigation(
| Key | Action | Context | | Key | Action | Context |
|-----|--------|---------| |-----|--------|---------|
| `j` или `↓` | Next hunk | Diff view focused | | `j` или `↓` | Next hunk | Diff dialog open |
| `k` или `↑` | Previous hunk | Diff view focused | | `k` или `↑` | Previous hunk | Diff dialog open |
| `n` | Next file | Any | | `n` | Next file | Diff dialog open |
| `p` или `Shift+N` | Previous file | Any | | `p` или `Shift+N` | Previous file | Diff dialog open |
| `a` | Accept current hunk | Hunk focused | | `a` | Accept current hunk | Diff dialog open |
| `x` | Reject current hunk | Hunk focused | | `x` | Reject current hunk | Diff dialog open |
| `Shift+A` | Accept all hunks in file | File selected | | `Shift+A` | Accept all hunks in file | Diff dialog open |
| `Shift+X` | Reject all hunks in file | File selected | | `Shift+X` | Reject all hunks in file | Diff dialog open |
| `Enter` | Toggle hunk collapse | Hunk focused | | `Enter` | Toggle hunk collapse | Diff dialog open |
| `Escape` | Close diff dialog | Any | | `?` | 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 ```typescript
useEffect(() => { useEffect(() => {
@ -122,18 +132,20 @@ useEffect(() => {
}, [isDialogOpen, currentHunkIndex, selectedFilePath]); }, [isDialogOpen, currentHunkIndex, selectedFilePath]);
``` ```
**Scroll-to-hunk через CodeMirror API:** **Scroll-to-hunk через CodeMirror API — VERIFIED:**
```typescript ```typescript
// @codemirror/merge предоставляет goToNextChunk / goToPreviousChunk // VERIFIED: goToNextChunk и goToPreviousChunk — это StateCommand объекты.
// Вызываются через .run(view) или через keymap:
import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge'; import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev') { function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev') {
if (direction === 'next') { if (direction === 'next') {
goToNextChunk(editorView); goToNextChunk.run(editorView); // StateCommand.run() — НЕ прямой вызов!
} else { } else {
goToPreviousChunk(editorView); goToPreviousChunk.run(editorView);
} }
// Возвращает boolean: true если нашёл chunk, false если конец/начало
} }
``` ```
@ -165,44 +177,104 @@ interface KeyboardShortcutsHelpProps {
```typescript ```typescript
const STORAGE_PREFIX = 'diff-viewed'; 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}`. * Ключ = `diff-viewed:{teamName}:{scopeKey}`.
* Значение = JSON array of viewed file paths. * Значение = 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 { function getStorageKey(teamName: string, scopeKey: string): string {
return `${STORAGE_PREFIX}:${teamName}:${scopeKey}`; 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 просмотренных файлов */ /** Получить Set просмотренных файлов */
export function getViewedFiles(teamName: string, scopeKey: string): Set<string> { export function getViewedFiles(teamName: string, scopeKey: string): Set<string> {
try { const entry = parseEntry(localStorage.getItem(getStorageKey(teamName, scopeKey)));
const raw = localStorage.getItem(getStorageKey(teamName, scopeKey)); return entry ? new Set(entry.files) : new Set();
if (!raw) return new Set();
const arr = JSON.parse(raw) as string[];
return new Set(arr);
} catch {
return new Set();
}
} }
/** Отметить файл как просмотренный */ /** Отметить файл как просмотренный */
export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void { export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void {
const set = getViewedFiles(teamName, scopeKey); const set = getViewedFiles(teamName, scopeKey);
set.add(filePath); 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 { export function unmarkFileViewed(teamName: string, scopeKey: string, filePath: string): void {
const set = getViewedFiles(teamName, scopeKey); const set = getViewedFiles(teamName, scopeKey);
set.delete(filePath); 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 { 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); const execFileAsync = promisify(execFile);
export class GitDiffFallback { export class GitDiffFallback {
// M3 fix: кеш isGitRepo результатов — один exec per projectPath за сессию
private gitRepoCache = new Map<string, boolean>();
/** /**
* Получить содержимое файла из конкретного коммита. * Получить содержимое файла из конкретного коммита.
* Используется когда file-history-snapshot недоступен. * Используется когда file-history-snapshot недоступен.
@ -557,14 +632,20 @@ export class GitDiffFallback {
/** /**
* Проверить: является ли projectPath git repo. * Проверить: является ли projectPath git repo.
* M3 fix: результат кешируется per projectPath (один exec за сессию).
*/ */
async isGitRepo(projectPath: string): Promise<boolean> { async isGitRepo(projectPath: string): Promise<boolean> {
if (this.gitRepoCache.has(projectPath)) {
return this.gitRepoCache.get(projectPath)!;
}
try { try {
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
cwd: projectPath, cwd: projectPath,
}); });
this.gitRepoCache.set(projectPath, true);
return true; return true;
} catch { } catch {
this.gitRepoCache.set(projectPath, false);
return 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) #### IPC: `src/preload/constants/ipcChannels.ts` (MODIFY)
```typescript ```typescript
@ -724,10 +852,13 @@ return (
| `src/preload/constants/ipcChannels.ts` | MODIFY | +1 | | `src/preload/constants/ipcChannels.ts` | MODIFY | +1 |
| `src/preload/index.ts` | MODIFY | +5 | | `src/preload/index.ts` | MODIFY | +5 |
| `src/main/services/team/index.ts` | MODIFY | +1 | | `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** | | | | **Feature 5: Auto-Viewed** | | |
| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +25 | | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +25 |
| `src/renderer/components/team/review/ReviewToolbar.tsx` | MODIFY | +15 | | `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, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { execFile, spawn } from 'child_process'; 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 { function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members return members
.map((member) => { .map((member) => {
@ -263,7 +270,7 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
} }
function buildTaskStatusProtocol(teamName: string): 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: 1. Use this command to mark task started:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <taskId> node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <taskId>
2. Use this command to mark task completed BEFORE sending your final reply: 2. Use this command to mark task completed BEFORE sending your final reply:
@ -286,7 +293,43 @@ function buildTaskStatusProtocol(teamName: string): string {
- Typical flow: - Typical flow:
a) Owner finishes work on #X task complete #X a) Owner finishes work on #X task complete #X
b) Reviewer accepts review approve #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 { function getSystemLocale(): string {
@ -311,12 +354,14 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
const members = buildMembersPrompt(request.members); const members = buildMembersPrompt(request.members);
const taskProtocol = buildTaskStatusProtocol(request.teamName); const taskProtocol = buildTaskStatusProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction(); const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const userPromptBlock = request.prompt?.trim() const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: ''; : '';
const leadName = const leadName =
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; 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. 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. You are "${leadName}", the team lead.
@ -333,17 +378,18 @@ Constraints:
- Keep assistant text minimal. - Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - 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. - 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. - 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): 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. - 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. - 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"). - 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: Message formatting:
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}" ${agentBlockPolicy}
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
Steps (execute in this exact order): Steps (execute in this exact order):
@ -359,12 +405,13 @@ Steps (execute in this exact order):
${languageInstruction} ${languageInstruction}
Introduce yourself briefly (name and role) and confirm you are ready. Introduce yourself briefly (name and role) and confirm you are ready.
Then wait for task assignments. 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. - 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. 4) After all steps, output a short summary.
@ -383,8 +430,10 @@ function buildLaunchPrompt(
: ''; : '';
const taskProtocol = buildTaskStatusProtocol(request.teamName); const taskProtocol = buildTaskStatusProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction(); const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; 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. 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. You are "${leadName}", the team lead.
@ -401,17 +450,18 @@ Constraints:
- Keep assistant text minimal. - Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - 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. - 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. - 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): 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. - 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. - 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"). - 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: Message formatting:
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}" ${agentBlockPolicy}
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
Steps (execute in this exact order): Steps (execute in this exact order):
@ -428,12 +478,13 @@ Steps (execute in this exact order):
${languageInstruction} ${languageInstruction}
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready. 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. 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. - 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. 5) After all steps, output a short summary.
@ -1292,7 +1343,10 @@ export class TeamProvisioningService {
const message = [ const message = [
`You have new inbox messages addressed to you (team lead "${leadName}").`, `You have new inbox messages addressed to you (team lead "${leadName}").`,
`Process them in order (oldest first).`, `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:`, `Messages:`,
...batch.flatMap((m, idx) => { ...batch.flatMap((m, idx) => {

View file

@ -5,6 +5,7 @@ import { api } from '@renderer/api';
import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTabUI } from '@renderer/hooks/useTabUI';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
@ -343,7 +344,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
const hasImages = content.images.length > 0; const hasImages = content.images.length > 0;
// Use rawText to preserve /commands inline // Use rawText to preserve /commands inline
const textContent = content.rawText ?? content.text ?? ''; 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 // Extract @path mentions from text
const pathMentions = useMemo(() => { const pathMentions = useMemo(() => {
@ -416,8 +418,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
const isExpanded = isManuallyExpanded || shouldAutoExpand; const isExpanded = isManuallyExpanded || shouldAutoExpand;
// Determine display text // Determine display text
const displayText = const displayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped;
isLongContent && !isExpanded ? textContent.slice(0, 500) + '...' : textContent;
return ( return (
<div className="flex justify-end"> <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)' }} /> <User className="size-3.5" style={{ color: 'var(--color-text-secondary)' }} />
</div> </div>
{/* Content - polished bubble with subtle depth */} {/* Content - polished bubble with subtle depth (hide when only agent blocks) */}
{textContent && ( {stripped && (
<div <div
className="group relative overflow-hidden rounded-2xl rounded-br-sm px-4 py-3" className="group relative overflow-hidden rounded-2xl rounded-br-sm px-4 py-3"
style={{ style={{
@ -443,7 +444,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
boxShadow: 'var(--chat-user-shadow)', 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> <div className="text-sm" style={{ color: 'var(--chat-user-text)' }} data-search-content>
<ReactMarkdown <ReactMarkdown

View file

@ -10,6 +10,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters'; import { formatTokensCompact } from '@renderer/utils/formatters';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react'; import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
@ -98,6 +99,11 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
[teammateMessage.replyToSummary] [teammateMessage.replyToSummary]
); );
const displayContent = useMemo(
() => stripAgentBlocks(teammateMessage.content),
[teammateMessage.content]
);
// Noise: minimal inline row (no card, no expand) // Noise: minimal inline row (no card, no expand)
if (noiseLabel) { if (noiseLabel) {
return ( return (
@ -215,7 +221,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
{/* Expanded content */} {/* Expanded content */}
{isExpanded && ( {isExpanded && (
<div className="p-3"> <div className="p-3">
<MarkdownViewer content={teammateMessage.content} copyable /> <MarkdownViewer content={displayContent} copyable />
</div> </div>
)} )}
</div> </div>

View file

@ -105,6 +105,7 @@ interface RepositoryCardProps {
onClick: () => void; onClick: () => void;
isHighlighted?: boolean; isHighlighted?: boolean;
taskCounts?: TaskStatusCounts; taskCounts?: TaskStatusCounts;
tasksLoading?: boolean;
} }
const RepositoryCard = ({ const RepositoryCard = ({
@ -112,6 +113,7 @@ const RepositoryCard = ({
onClick, onClick,
isHighlighted, isHighlighted,
taskCounts, taskCounts,
tasksLoading,
}: Readonly<RepositoryCardProps>): React.JSX.Element => { }: Readonly<RepositoryCardProps>): React.JSX.Element => {
const lastActivity = repo.mostRecentSession const lastActivity = repo.mostRecentSession
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true }) ? 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' : 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised'
} `} } `}
> >
{/* Icon with subtle border */} {/* Icon + Project name */}
<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"> <div className="mb-1 flex items-center gap-2.5">
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" /> <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> </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 */} {/* Project path - monospace, muted */}
<p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p> <p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p>
@ -181,7 +183,15 @@ const RepositoryCard = ({
</div> </div>
{/* Tasks progress bar */} {/* 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 pending = taskCounts.pending ?? 0;
const inProgress = taskCounts.inProgress ?? 0; const inProgress = taskCounts.inProgress ?? 0;
@ -212,7 +222,8 @@ const RepositoryCard = ({
</div> </div>
</div> </div>
); );
})()} })()
)}
</button> </button>
); );
}; };
@ -348,6 +359,7 @@ const ProjectsGrid = ({
fetchRepositoryGroups, fetchRepositoryGroups,
selectRepository, selectRepository,
globalTasks, globalTasks,
globalTasksLoading,
fetchAllTasks, fetchAllTasks,
openTeamsTab, openTeamsTab,
} = useStore( } = useStore(
@ -357,6 +369,7 @@ const ProjectsGrid = ({
fetchRepositoryGroups: s.fetchRepositoryGroups, fetchRepositoryGroups: s.fetchRepositoryGroups,
selectRepository: s.selectRepository, selectRepository: s.selectRepository,
globalTasks: s.globalTasks, globalTasks: s.globalTasks,
globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
openTeamsTab: s.openTeamsTab, openTeamsTab: s.openTeamsTab,
})) }))
@ -497,7 +510,8 @@ const ProjectsGrid = ({
openTeamsTab(); openTeamsTab();
}} }}
isHighlighted={!!searchQuery.trim()} isHighlighted={!!searchQuery.trim()}
taskCounts={counts} taskCounts={globalTasksLoading ? undefined : counts}
tasksLoading={globalTasksLoading}
/> />
); );
})} })}

View file

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

View file

@ -23,11 +23,36 @@ function formatTaskDate(dateStr: string | undefined): string | null {
return format(d, 'MMM d, yyyy'); return format(d, 'MMM d, yyyy');
} }
interface SidebarTaskItemProps { function formatUpdatedLabel(task: GlobalTask): string | null {
task: GlobalTask; 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 openTeamTab = useStore((s) => s.openTeamTab);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const cfg = 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) ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending); : (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon; const StatusIcon = cfg.icon;
const dateLabel = formatTaskDate(task.updatedAt ?? task.createdAt); const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
return ( return (
<button <button
@ -66,12 +92,18 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
<span>{task.owner ?? 'unassigned'}</span> <span>{task.owner ?? 'unassigned'}</span>
<span className="opacity-40">·</span> {!hideTeamName && (
<span className="truncate">{task.teamDisplayName}</span> <>
<span className="opacity-40">·</span>
<span className="truncate">{task.teamDisplayName}</span>
</>
)}
{dateLabel && ( {dateLabel && (
<> <>
<span className="opacity-40">·</span> <span className="opacity-40">·</span>
<span className="shrink-0">{dateLabel}</span> <span className={`shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
{dateLabel}
</span>
</> </>
)} )}
</div> </div>

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react'; import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
@ -126,7 +127,9 @@ export const TaskCommentsSection = ({
onClick={() => onClick={() =>
setReplyTo({ setReplyTo({
author: comment.author, 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> </div>
{(() => { {(() => {
const reply = parseMessageReply(comment.text); 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 needsExpandCollapse = displayText.includes('\n');
const expanded = expandedCommentIds.has(comment.id); const expanded = expandedCommentIds.has(comment.id);
const collapsedHeight = 'max-h-[120px]'; const collapsedHeight = 'max-h-[120px]';
@ -154,14 +158,18 @@ export const TaskCommentsSection = ({
> >
{reply ? ( {reply ? (
<ReplyQuoteBlock <ReplyQuoteBlock
reply={reply} reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
}}
bodyMaxHeight={ bodyMaxHeight={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none' needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
} }
/> />
) : ( ) : (
<MarkdownViewer <MarkdownViewer
content={comment.text} content={displayText}
maxHeight={ maxHeight={
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none' 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; 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 { format } from 'date-fns';
import { Bot, ChevronDown, ChevronRight } from 'lucide-react'; import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
@ -102,7 +102,7 @@ function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[]
.trim(); .trim();
if (content) agentInfo.push(content); if (content) agentInfo.push(content);
} }
const humanText = raw.replace(createAgentBlockRegex(), '').trim(); const humanText = stripAgentBlocks(raw);
return { humanText, agentInfo }; return { humanText, agentInfo };
} }

View file

@ -3,6 +3,7 @@
*/ */
import { findLastOutput } from '@renderer/utils/aiGroupEnhancer'; import { findLastOutput } from '@renderer/utils/aiGroupEnhancer';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch'; import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch';
import type { AppState, SearchMatch } from '../types'; import type { AppState, SearchMatch } from '../types';
@ -257,8 +258,8 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
for (const item of conversation.items) { for (const item of conversation.items) {
if (item.type === 'user') { if (item.type === 'user') {
// Search user message text const raw = item.group.content.rawText ?? item.group.content.text ?? '';
const text = item.group.content.rawText ?? item.group.content.text ?? ''; const text = stripAgentBlocks(raw);
addMarkdownMatches(text, item.group.id, 'user'); addMarkdownMatches(text, item.group.id, 'user');
} else if (item.type === 'ai') { } else if (item.type === 'ai') {
// For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents) // 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; 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 { function getDateCategory(dateStr: string | undefined): DateCategory {
if (!dateStr) return 'Older'; if (!dateStr) return 'Older';
const d = new Date(dateStr); const d = new Date(dateStr);
@ -43,15 +79,8 @@ export function groupTasksByDate(tasks: GlobalTask[]): DateGroupedTasks {
} }
for (const cat of DATE_CATEGORY_ORDER) { for (const cat of DATE_CATEGORY_ORDER) {
groups[cat].sort((a, b) => { const teamTs = buildTeamMaxTs(groups[cat]);
const cmp = a.teamName.localeCompare(b.teamName); groups[cat].sort((a, b) => compareByTeamFreshness(a, b, teamTs));
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;
});
} }
return groups; return groups;
@ -94,15 +123,8 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] {
} }
for (const entry of byKey.values()) { for (const entry of byKey.values()) {
entry.tasks.sort((a, b) => { const teamTs = buildTeamMaxTs(entry.tasks);
const cmp = a.teamName.localeCompare(b.teamName); entry.tasks.sort((a, b) => compareByTeamFreshness(a, b, teamTs));
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 groups: ProjectTaskGroup[] = []; const groups: ProjectTaskGroup[] = [];
@ -112,18 +134,8 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] {
} }
groups.sort((a, b) => { groups.sort((a, b) => {
const tsA = Math.max( const tsA = Math.max(...a.tasks.map(getEffectiveTs));
...a.tasks.map((t) => { const tsB = Math.max(...b.tasks.map(getEffectiveTs));
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;
})
);
return tsB - tsA; return tsB - tsA;
}); });

View file

@ -26,6 +26,13 @@ export function createAgentBlockRegex(): RegExp {
return new RegExp(AGENT_BLOCK_PATTERN, 'g'); 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. * @deprecated Use createAgentBlockRegex() instead to avoid stateful 'g' flag issues.
* Kept for backward compatibility with .replace() calls. * Kept for backward compatibility with .replace() calls.