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:
parent
051a727eb2
commit
61c62741c3
17 changed files with 1052 additions and 277 deletions
|
|
@ -58,7 +58,7 @@ export interface TaskChangeSet {
|
|||
totalLinesAdded: number;
|
||||
totalLinesRemoved: number;
|
||||
totalFiles: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
confidence: 'high' | 'medium' | 'low' | 'fallback'; // 'fallback' добавлен для Phase 3 Tier 4
|
||||
computedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +110,14 @@ export class ChangeExtractorService {
|
|||
```
|
||||
5. **Пропуск ошибок**: Следующий за tool_use блок `tool_result` с `is_error: true` → пропускаем этот tool_use
|
||||
6. **Фильтрация proxy_ префикса**: Имена инструментов приходят как `proxy_Edit` — нужно strip prefix (паттерн из MemberStatsComputer)
|
||||
7. **Подсчёт строк**: `linesAdded = newString.split('\n').length - oldString.split('\n').length` (для добавленных), аналогично для removed
|
||||
7. **Подсчёт строк** (через `jsdiff.diffLines`):
|
||||
```typescript
|
||||
import { diffLines } from 'diff';
|
||||
const changes = diffLines(oldString, newString);
|
||||
const linesAdded = changes.filter(c => c.added).reduce((sum, c) => sum + (c.count ?? 0), 0);
|
||||
const linesRemoved = changes.filter(c => c.removed).reduce((sum, c) => sum + (c.count ?? 0), 0);
|
||||
```
|
||||
**НЕ использовать** `newString.split('\n').length - oldString.split('\n').length` — это даёт "net difference", а не отдельные added/removed. Может давать отрицательные числа.
|
||||
|
||||
**Task scoping (для `getTaskChanges`):**
|
||||
|
||||
|
|
@ -142,14 +149,32 @@ export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats';
|
|||
|
||||
```typescript
|
||||
import { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
import { IpcResult } from '@shared/types/api';
|
||||
import type { IpcResult } from '@shared/types'; // IpcResult живёт в @shared/types/ipc.ts, barrel через @shared/types
|
||||
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
||||
import { REVIEW_GET_AGENT_CHANGES, REVIEW_GET_TASK_CHANGES, REVIEW_GET_CHANGE_STATS } from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
const logger = createLogger('IPC:review');
|
||||
|
||||
// --- Module-level state (паттерн из teams.ts) ---
|
||||
|
||||
let changeExtractor: ChangeExtractorService | null = null;
|
||||
|
||||
export function initializeReviewHandlers(service: ChangeExtractorService): void {
|
||||
changeExtractor = service;
|
||||
function getChangeExtractor(): ChangeExtractorService {
|
||||
if (!changeExtractor) throw new Error('Review handlers not initialized');
|
||||
return changeExtractor;
|
||||
}
|
||||
|
||||
// --- Forward-compatible config object (Phase 2/3/4 добавят новые сервисы) ---
|
||||
|
||||
interface ReviewHandlerDeps {
|
||||
extractor: ChangeExtractorService;
|
||||
// Phase 2 добавит: applier?: ReviewApplierService; contentResolver?: FileContentResolver;
|
||||
// Phase 4 добавит: gitFallback?: GitDiffFallback;
|
||||
}
|
||||
|
||||
export function initializeReviewHandlers(deps: ReviewHandlerDeps): void {
|
||||
changeExtractor = deps.extractor;
|
||||
}
|
||||
|
||||
export function registerReviewHandlers(ipcMain: IpcMain): void {
|
||||
|
|
@ -164,19 +189,140 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS);
|
||||
}
|
||||
|
||||
// Handlers follow wrapTeamHandler pattern from teams.ts
|
||||
// --- Local wrapReviewHandler (копия wrapTeamHandler из teams.ts, НЕ экспортируется) ---
|
||||
|
||||
async function wrapReviewHandler<T>(operation: string, handler: () => Promise<T>): Promise<IpcResult<T>> {
|
||||
try {
|
||||
const data = await handler();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Review handler error [${operation}]:`, message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
async function handleGetAgentChanges(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<IpcResult<AgentChangeSet>> {
|
||||
return wrapReviewHandler('getAgentChanges', () =>
|
||||
getChangeExtractor().getAgentChanges(teamName, memberName)
|
||||
);
|
||||
}
|
||||
|
||||
// ... аналогично handleGetTaskChanges, handleGetChangeStats
|
||||
```
|
||||
|
||||
### 5. Регистрация в main process
|
||||
|
||||
В `src/main/index.ts` (или где инициализируются IPC):
|
||||
- Создать `ChangeExtractorService` с зависимостью `TeamMemberLogsFinder`
|
||||
- Вызвать `initializeReviewHandlers(changeExtractor)`
|
||||
- Вызвать `registerReviewHandlers(ipcMain)` после team handlers
|
||||
**Файл: `src/main/ipc/handlers.ts`** — единственное место регистрации ВСЕХ IPC handlers.
|
||||
|
||||
### 6. Preload bridge: `src/preload/index.ts` (MODIFY)
|
||||
**Шаг 1: Создание сервиса** — в `src/main/index.ts`, функция `initializeServices()` (после строки ~305 где создаются team services):
|
||||
```typescript
|
||||
// После создания teamMemberLogsFinder и memberStatsComputer:
|
||||
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder);
|
||||
```
|
||||
|
||||
Добавить в `electronAPI`:
|
||||
**Шаг 2: Инициализация** — в `src/main/ipc/handlers.ts`, функция `initializeIpcHandlers()`.
|
||||
|
||||
**ВАЖНО**: `initializeIpcHandlers()` использует ПОЗИЦИОННЫЕ параметры (9 штук, строки 76-92).
|
||||
НЕ менять сигнатуру на объект! Вместо этого добавить `changeExtractor` как 10-й позиционный параметр:
|
||||
|
||||
```typescript
|
||||
// handlers.ts — расширение сигнатуры:
|
||||
export function initializeIpcHandlers(
|
||||
registry: ServiceContextRegistry,
|
||||
updater: UpdaterService,
|
||||
sshManager: SshConnectionManager,
|
||||
teamDataService: TeamDataService,
|
||||
teamProvisioningService: TeamProvisioningService,
|
||||
teamMemberLogsFinder: TeamMemberLogsFinder,
|
||||
memberStatsComputer: MemberStatsComputer,
|
||||
contextCallbacks: { ... },
|
||||
httpServerDeps?: { ... },
|
||||
changeExtractor?: ChangeExtractorService // ← Phase 1 addition (optional для backward compat)
|
||||
): void {
|
||||
// ... existing initialization ...
|
||||
if (changeExtractor) {
|
||||
initializeReviewHandlers({ extractor: changeExtractor });
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// index.ts — добавить 10-й аргумент при вызове:
|
||||
initializeIpcHandlers(
|
||||
contextRegistry,
|
||||
updaterService,
|
||||
sshConnectionManager,
|
||||
teamDataService,
|
||||
teamProvisioningService,
|
||||
teamMemberLogsFinder,
|
||||
memberStatsComputer,
|
||||
contextCallbacks,
|
||||
httpServerDeps,
|
||||
changeExtractor // ← Phase 1
|
||||
);
|
||||
```
|
||||
|
||||
**Шаг 3: Регистрация** — в `src/main/ipc/handlers.ts`, после `registerTeamHandlers(ipcMain)` (строка ~130):
|
||||
```typescript
|
||||
registerReviewHandlers(ipcMain);
|
||||
```
|
||||
|
||||
**Шаг 4: Cleanup** — в `src/main/ipc/handlers.ts`, функция `removeIpcHandlers()` (после `removeTeamHandlers(ipcMain)`, строка ~155):
|
||||
```typescript
|
||||
removeReviewHandlers(ipcMain);
|
||||
```
|
||||
|
||||
**Шаг 5: Import** — в `src/main/ipc/handlers.ts`, добавить import:
|
||||
```typescript
|
||||
import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';
|
||||
```
|
||||
|
||||
### 6. Preload bridge + ElectronAPI типы
|
||||
|
||||
#### 6a. Типы: `src/shared/types/api.ts` (MODIFY)
|
||||
|
||||
**ВАЖНО**: `ElectronAPI` интерфейс (строки ~406-519) типизирует `window.electronAPI`.
|
||||
Без добавления `review` — TypeScript не пропустит `api.review.*` вызовы.
|
||||
|
||||
```typescript
|
||||
// В src/shared/types/api.ts добавить:
|
||||
|
||||
import type { AgentChangeSet, TaskChangeSet, ChangeStats } from './review';
|
||||
|
||||
export interface ReviewAPI {
|
||||
getAgentChanges: (teamName: string, memberName: string) => Promise<AgentChangeSet>;
|
||||
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSet>;
|
||||
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
|
||||
}
|
||||
|
||||
// В ElectronAPI интерфейс добавить поле:
|
||||
export interface ElectronAPI {
|
||||
// ... existing fields ...
|
||||
review: ReviewAPI;
|
||||
}
|
||||
```
|
||||
|
||||
#### 6b. HttpAPIClient: `src/renderer/api/httpClient.ts` (MODIFY)
|
||||
|
||||
Для browser mode (SSH/remote) нужны заглушки:
|
||||
```typescript
|
||||
// В HttpAPIClient class добавить:
|
||||
review = {
|
||||
getAgentChanges: async () => { throw new Error('Review not available in browser mode'); },
|
||||
getTaskChanges: async () => { throw new Error('Review not available in browser mode'); },
|
||||
getChangeStats: async () => { throw new Error('Review not available in browser mode'); },
|
||||
};
|
||||
```
|
||||
|
||||
#### 6c. Preload: `src/preload/index.ts` (MODIFY)
|
||||
|
||||
Добавить в `electronAPI` объект:
|
||||
```typescript
|
||||
review: {
|
||||
getAgentChanges: (teamName: string, memberName: string) =>
|
||||
|
|
@ -216,6 +362,17 @@ export interface ChangeReviewSlice {
|
|||
|
||||
Зарегистрировать в `src/renderer/store/index.ts` как новый slice.
|
||||
|
||||
**ВАЖНО**: Также обновить `src/renderer/store/types.ts`:
|
||||
```typescript
|
||||
import type { ChangeReviewSlice } from './slices/changeReviewSlice';
|
||||
|
||||
export type AppState = ProjectSlice &
|
||||
// ... existing slices ...
|
||||
UpdateSlice &
|
||||
ChangeReviewSlice; // ← Phase 1 addition
|
||||
```
|
||||
Без этого `useStore().fetchAgentChanges` не будет доступен в TypeScript.
|
||||
|
||||
### 8. Компоненты
|
||||
|
||||
#### `src/renderer/components/team/review/ChangeReviewDialog.tsx` (NEW)
|
||||
|
|
@ -266,21 +423,25 @@ export interface ChangeReviewSlice {
|
|||
| Файл | Тип | ~LOC |
|
||||
|------|-----|---:|
|
||||
| `src/shared/types/review.ts` | NEW | 80 |
|
||||
| `src/shared/types/api.ts` | MODIFY | +15 (ReviewAPI interface + ElectronAPI field) |
|
||||
| `src/main/services/team/ChangeExtractorService.ts` | NEW | 350 |
|
||||
| `src/main/ipc/review.ts` | NEW | 80 |
|
||||
| `src/main/ipc/review.ts` | NEW | 100 (с wrapReviewHandler) |
|
||||
| `src/main/ipc/handlers.ts` | MODIFY | +10 (import + init + register + remove) |
|
||||
| `src/main/services/team/index.ts` | MODIFY | +1 |
|
||||
| `src/main/index.ts` | MODIFY | +10 |
|
||||
| `src/preload/constants/ipcChannels.ts` | MODIFY | +3 |
|
||||
| `src/preload/index.ts` | MODIFY | +10 |
|
||||
| `src/renderer/api/httpClient.ts` | MODIFY | +8 (review stubs) |
|
||||
| `src/renderer/store/slices/changeReviewSlice.ts` | NEW | 100 |
|
||||
| `src/renderer/store/index.ts` | MODIFY | +5 |
|
||||
| `src/renderer/store/types.ts` | MODIFY | +2 (import + AppState intersection) |
|
||||
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | NEW | 150 |
|
||||
| `src/renderer/components/team/review/ReviewFileTree.tsx` | NEW | 180 |
|
||||
| `src/renderer/components/team/review/ReviewDiffContent.tsx` | NEW | 250 |
|
||||
| `src/renderer/components/team/review/ReviewDiffContent.tsx` | NEW | 250 (throwaway — заменяется в Phase 2 на CodeMirror) |
|
||||
| `src/renderer/components/team/review/ChangeStatsBadge.tsx` | NEW | 40 |
|
||||
| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +30 |
|
||||
| `src/renderer/components/team/TeamDetailView.tsx` | MODIFY | +40 |
|
||||
| **Итого** | 8 NEW + 7 MODIFY | ~1,330 |
|
||||
| **Итого** | 8 NEW + 10 MODIFY | ~1,430 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ pnpm add @codemirror/merge @codemirror/state @codemirror/view
|
|||
pnpm add @codemirror/lang-javascript @codemirror/lang-python @codemirror/lang-json
|
||||
pnpm add @codemirror/lang-css @codemirror/lang-html @codemirror/lang-xml
|
||||
pnpm add @codemirror/theme-one-dark
|
||||
pnpm add diff # jsdiff v8 — applyPatch, reversePatch
|
||||
pnpm add node-diff3 # Three-way merge для конфликтов
|
||||
pnpm add diff # jsdiff v8 — structuredPatch, applyPatch (НЕТ reversePatch!)
|
||||
pnpm add node-diff3 # Three-way merge для конфликтов (diff3Merge)
|
||||
```
|
||||
|
||||
**Примечание**: `react-codemirror-merge` НЕ используем — пишем свой React wrapper для полного контроля над lifecycle и event handling.
|
||||
|
|
@ -79,7 +79,8 @@ export interface FileChangeWithContent extends FileChangeSummary {
|
|||
/** Полное содержимое файла ПОСЛЕ изменений (для CodeMirror modified) */
|
||||
modifiedFullContent: string | null;
|
||||
/** Источник original content */
|
||||
contentSource: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'unavailable';
|
||||
contentSource: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'git-fallback' | 'unavailable';
|
||||
// 'git-fallback' добавлен для Phase 4 Git Fallback feature
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -116,7 +117,7 @@ export class FileContentResolver {
|
|||
): Promise<{
|
||||
original: string | null;
|
||||
modified: string | null;
|
||||
source: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'unavailable';
|
||||
source: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'git-fallback' | 'unavailable';
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
|
@ -235,36 +236,122 @@ export class ReviewApplierService {
|
|||
|
||||
**Reject algorithm детально:**
|
||||
|
||||
У нас есть два подхода. **PRIMARY** — snippet-level replace (простой и надёжный). **FALLBACK** — hunk-level inverse patch (для случаев когда snippet-chain неполный).
|
||||
|
||||
**PRIMARY: Snippet-level replace (рекомендуемый)**
|
||||
|
||||
```typescript
|
||||
// Шаг 1: Вычислить structured patch
|
||||
const patch = Diff.structuredPatch('file', 'file', original, modified);
|
||||
// patch.hunks = [ { oldStart, oldLines, newStart, newLines, lines: ['+', '-', ' '] } ]
|
||||
// Простейший подход: у нас уже есть snippets с (oldString, newString)
|
||||
// Reject = заменить newString обратно на oldString
|
||||
async rejectHunks(
|
||||
filePath: string,
|
||||
original: string,
|
||||
modified: string,
|
||||
hunkIndicesToReject: number[],
|
||||
snippets: SnippetDiff[]
|
||||
): Promise<RejectResult> {
|
||||
// 1. Прочитать текущий файл с диска
|
||||
let content = await readFile(filePath, 'utf8');
|
||||
|
||||
// Шаг 2: Отфильтровать только rejected hunks
|
||||
const rejectedPatch = {
|
||||
...patch,
|
||||
hunks: patch.hunks.filter((_, idx) => hunkIndicesToReject.includes(idx))
|
||||
};
|
||||
// 2. Проверить: файл не изменён с момента agent changes?
|
||||
if (content !== modified) {
|
||||
// Конфликт — файл был модифицирован
|
||||
return this.resolveWithThreeWayMerge(original, content, modified, hunkIndicesToReject, snippets);
|
||||
}
|
||||
|
||||
// Шаг 3: Reverse patch (откат)
|
||||
// jsdiff.applyPatch НЕ имеет reversed: true!
|
||||
// Нужно вручную инвертировать: '+' → '-', '-' → '+', swap oldStart↔newStart
|
||||
const inversePatch = invertPatch(rejectedPatch);
|
||||
// 3. Применить snippet-level replace для rejected hunks
|
||||
//
|
||||
// R2 FIX: Используем ПОЗИЦИОННЫЙ reverse (не хронологический!):
|
||||
// - Сначала находим ВСЕ позиции через indexOf
|
||||
// - Сортируем по позиции УБЫВАНИЯ (от конца файла к началу)
|
||||
// - Применяем замены — каждая не сдвигает позиции предыдущих
|
||||
//
|
||||
// Также: если newString встречается в файле несколько раз,
|
||||
// используем позицию ближайшую к ожидаемой (на основе snippet order).
|
||||
|
||||
// Шаг 4: Применить к modified content
|
||||
const result = Diff.applyPatch(modified, inversePatch);
|
||||
if (result === false) {
|
||||
// Patch не применился — конфликт
|
||||
return threeWayMerge(original, currentDisk, targetContent);
|
||||
const rejectedSnippets = hunkIndicesToReject
|
||||
.map(idx => snippets[idx])
|
||||
.filter(Boolean);
|
||||
|
||||
// Найти позиции ПЕРЕД заменами
|
||||
const positioned: Array<{ snippet: SnippetDiff; offset: number }> = [];
|
||||
for (const snippet of rejectedSnippets) {
|
||||
if (snippet.type === 'write-new') continue; // Обрабатывается через rejectFile()
|
||||
|
||||
const offset = content.indexOf(snippet.newString);
|
||||
if (offset === -1) {
|
||||
// Snippet не найден — fallback на hunk-level
|
||||
return this.rejectHunksFallback(filePath, original, modified, hunkIndicesToReject);
|
||||
}
|
||||
|
||||
// Проверка уникальности: если есть второе вхождение, это опасно
|
||||
const secondOccurrence = content.indexOf(snippet.newString, offset + 1);
|
||||
if (secondOccurrence !== -1 && snippet.newString.length < 20) {
|
||||
// Короткий неуникальный snippet — fallback (безопаснее)
|
||||
return this.rejectHunksFallback(filePath, original, modified, hunkIndicesToReject);
|
||||
}
|
||||
|
||||
positioned.push({ snippet, offset });
|
||||
}
|
||||
|
||||
// Сортировка по позиции УБЫВАНИЯ (от конца к началу)
|
||||
positioned.sort((a, b) => b.offset - a.offset);
|
||||
|
||||
for (const { snippet, offset } of positioned) {
|
||||
content = content.slice(0, offset) + snippet.oldString + content.slice(offset + snippet.newString.length);
|
||||
}
|
||||
|
||||
// 4. Записать результат
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
return { success: true, newContent: content, hadConflicts: false };
|
||||
}
|
||||
```
|
||||
|
||||
**Инвертирование patch:**
|
||||
**FALLBACK: Hunk-level inverse patch (когда snippets неполные)**
|
||||
|
||||
```typescript
|
||||
// Используется когда snippet.newString не найден в файле (файл изменён после agent)
|
||||
private async rejectHunksFallback(
|
||||
filePath: string,
|
||||
original: string,
|
||||
modified: string,
|
||||
hunkIndicesToReject: number[]
|
||||
): Promise<RejectResult> {
|
||||
// Шаг 1: Вычислить structured patch
|
||||
const patch = Diff.structuredPatch('file', 'file', original, modified);
|
||||
|
||||
// Шаг 2: Отфильтровать только rejected hunks
|
||||
const rejectedPatch = {
|
||||
...patch,
|
||||
hunks: patch.hunks.filter((_, idx) => hunkIndicesToReject.includes(idx))
|
||||
};
|
||||
|
||||
// Шаг 3: Инвертировать patch вручную (jsdiff НЕ имеет reversed option!)
|
||||
const inversePatch = invertPatch(rejectedPatch);
|
||||
|
||||
// Шаг 4: Применить к modified content
|
||||
const result = Diff.applyPatch(modified, inversePatch);
|
||||
if (result === false) {
|
||||
// Patch не применился — three-way merge
|
||||
const currentDisk = await readFile(filePath, 'utf8');
|
||||
return this.resolveWithThreeWayMerge(original, currentDisk, modified, hunkIndicesToReject, []);
|
||||
}
|
||||
|
||||
await writeFile(filePath, result, 'utf8');
|
||||
return { success: true, newContent: result, hadConflicts: false };
|
||||
}
|
||||
```
|
||||
|
||||
**Инвертирование patch (verified jsdiff API):**
|
||||
|
||||
```typescript
|
||||
function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff {
|
||||
return {
|
||||
...patch,
|
||||
oldFileName: patch.newFileName,
|
||||
newFileName: patch.oldFileName,
|
||||
oldHeader: patch.newHeader ?? '',
|
||||
newHeader: patch.oldHeader ?? '',
|
||||
hunks: patch.hunks.map(hunk => ({
|
||||
oldStart: hunk.newStart,
|
||||
oldLines: hunk.newLines,
|
||||
|
|
@ -273,27 +360,34 @@ function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff {
|
|||
lines: hunk.lines.map(line => {
|
||||
if (line.startsWith('+')) return '-' + line.slice(1);
|
||||
if (line.startsWith('-')) return '+' + line.slice(1);
|
||||
return line; // context lines unchanged
|
||||
return line; // context lines (prefix ' ') — без изменений
|
||||
})
|
||||
}))
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Three-way merge (при конфликтах):**
|
||||
**Three-way merge (при конфликтах) — verified node-diff3 API:**
|
||||
|
||||
```typescript
|
||||
import { diff3Merge } from 'node-diff3';
|
||||
|
||||
// VERIFIED API: diff3Merge(a, o, b, options?)
|
||||
// a = "ours" (changed version A)
|
||||
// o = "original" (base)
|
||||
// b = "theirs" (changed version B)
|
||||
// Принимает string[] (массив строк) ИЛИ строки
|
||||
// Возвращает: Array<{ ok: string[] } | { conflict: { a: string[], o: string[], b: string[] } }>
|
||||
|
||||
function threeWayMerge(
|
||||
base: string, // Original content before agent changes
|
||||
ours: string, // Current file on disk (user's version)
|
||||
theirs: string // What we want after reject
|
||||
): { content: string; hasConflicts: boolean } {
|
||||
const result = diff3Merge(
|
||||
ours.split('\n'),
|
||||
base.split('\n'),
|
||||
theirs.split('\n')
|
||||
ours.split('\n'), // a = current disk
|
||||
base.split('\n'), // o = original (base)
|
||||
theirs.split('\n') // b = target after reject
|
||||
);
|
||||
|
||||
let hasConflicts = false;
|
||||
|
|
@ -302,12 +396,14 @@ function threeWayMerge(
|
|||
for (const part of result) {
|
||||
if ('ok' in part) {
|
||||
lines.push(...part.ok);
|
||||
} else {
|
||||
} else if ('conflict' in part) {
|
||||
hasConflicts = true;
|
||||
lines.push('<<<<<<< Current (yours)');
|
||||
lines.push(...(part.conflict?.a ?? []));
|
||||
lines.push(...(part.conflict.a ?? []));
|
||||
lines.push('||||||| Original');
|
||||
lines.push(...(part.conflict.o ?? [])); // node-diff3 также возвращает .o (original)
|
||||
lines.push('=======');
|
||||
lines.push(...(part.conflict?.b ?? []));
|
||||
lines.push(...(part.conflict.b ?? []));
|
||||
lines.push('>>>>>>> Reverted (rejected changes)');
|
||||
}
|
||||
}
|
||||
|
|
@ -330,20 +426,72 @@ export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent';
|
|||
|
||||
### 5. IPC хендлеры: `src/main/ipc/review.ts` (MODIFY — расширение Phase 1)
|
||||
|
||||
**Регистрация**: В `src/main/index.ts` `initializeServices()` создать новые сервисы:
|
||||
```typescript
|
||||
// Добавляем к Phase 1 хендлерам
|
||||
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder);
|
||||
const reviewApplier = new ReviewApplierService();
|
||||
```
|
||||
|
||||
Обновить вызов `initializeReviewHandlers()` — Phase 1 использует объект-конфиг `ReviewHandlerDeps`, Phase 2 добавляет optional fields:
|
||||
```typescript
|
||||
// index.ts — Phase 2 расширение (вместо только { extractor: changeExtractor }):
|
||||
initializeReviewHandlers({
|
||||
extractor: changeExtractor,
|
||||
applier: reviewApplier,
|
||||
contentResolver: fileContentResolver,
|
||||
});
|
||||
```
|
||||
`registerReviewHandlers()` и `removeReviewHandlers()` уже зарегистрированы в Phase 1.
|
||||
|
||||
**ВАЖНО**: Обновить `ReviewAPI` в `src/shared/types/api.ts` — добавить Phase 2 методы:
|
||||
```typescript
|
||||
export interface ReviewAPI {
|
||||
// Phase 1
|
||||
getAgentChanges: (...) => Promise<AgentChangeSet>;
|
||||
getTaskChanges: (...) => Promise<TaskChangeSet>;
|
||||
getChangeStats: (...) => Promise<ChangeStats>;
|
||||
// Phase 2
|
||||
checkConflict: (filePath: string, expectedModified: string) => Promise<ConflictCheckResult>;
|
||||
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise<RejectResult>;
|
||||
rejectFile: (filePath: string, original: string, modified: string) => Promise<RejectResult>;
|
||||
previewReject: (...) => Promise<{ preview: string; hasConflicts: boolean }>;
|
||||
applyDecisions: (request: ApplyReviewRequest) => Promise<ApplyReviewResult>;
|
||||
getFileContent: (teamName: string, memberName: string, filePath: string) => Promise<FileChangeWithContent>;
|
||||
}
|
||||
```
|
||||
|
||||
Также обновить `HttpAPIClient` — добавить стубы для Phase 2 методов.
|
||||
|
||||
```typescript
|
||||
// Расширяем Phase 1 хендлеры
|
||||
|
||||
let reviewApplier: ReviewApplierService | null = null;
|
||||
let fileContentResolver: FileContentResolver | null = null;
|
||||
|
||||
export function initializeReviewHandlers(
|
||||
extractor: ChangeExtractorService,
|
||||
applier: ReviewApplierService,
|
||||
contentResolver: FileContentResolver
|
||||
): void {
|
||||
changeExtractor = extractor;
|
||||
reviewApplier = applier;
|
||||
fileContentResolver = contentResolver;
|
||||
// Phase 2: Расширяем ReviewHandlerDeps из Phase 1 (объект-конфиг — forward compatible)
|
||||
// Phase 1 определил: interface ReviewHandlerDeps { extractor: ChangeExtractorService; ... }
|
||||
// Phase 2 добавляет optional fields (НЕ ломает Phase 1 вызов):
|
||||
interface ReviewHandlerDeps {
|
||||
extractor: ChangeExtractorService;
|
||||
applier?: ReviewApplierService;
|
||||
contentResolver?: FileContentResolver;
|
||||
// Phase 4 добавит: gitFallback?: GitDiffFallback;
|
||||
}
|
||||
|
||||
export function initializeReviewHandlers(deps: ReviewHandlerDeps): void {
|
||||
changeExtractor = deps.extractor;
|
||||
reviewApplier = deps.applier ?? null;
|
||||
fileContentResolver = deps.contentResolver ?? null;
|
||||
}
|
||||
|
||||
// Guard helpers
|
||||
function getApplier(): ReviewApplierService {
|
||||
if (!reviewApplier) throw new Error('ReviewApplierService not initialized (Phase 2 required)');
|
||||
return reviewApplier;
|
||||
}
|
||||
function getContentResolver(): FileContentResolver {
|
||||
if (!fileContentResolver) throw new Error('FileContentResolver not initialized (Phase 2 required)');
|
||||
return fileContentResolver;
|
||||
}
|
||||
|
||||
// Регистрация Phase 2 хендлеров
|
||||
|
|
@ -368,7 +516,7 @@ async function handleGetFileContent(
|
|||
memberName: string,
|
||||
filePath: string
|
||||
): Promise<IpcResult<FileChangeWithContent>> {
|
||||
return wrapHandler('review:getFileContent', async () => {
|
||||
return wrapReviewHandler('review:getFileContent', async () => {
|
||||
const resolver = getContentResolver();
|
||||
const result = await resolver.resolveFileContent(teamName, memberName, filePath);
|
||||
return result;
|
||||
|
|
@ -380,11 +528,14 @@ async function handleRejectHunks(
|
|||
filePath: string,
|
||||
original: string,
|
||||
modified: string,
|
||||
hunkIndices: number[]
|
||||
hunkIndices: number[],
|
||||
snippets: SnippetDiff[] // R1 fix: renderer MUST передать snippets
|
||||
): Promise<IpcResult<RejectResult>> {
|
||||
return wrapHandler('review:rejectHunks', async () => {
|
||||
// Security fix: валидация что filePath внутри проекта
|
||||
// TODO: получить projectPath из team config и проверить path.resolve(filePath).startsWith(projectPath)
|
||||
return wrapReviewHandler('review:rejectHunks', async () => {
|
||||
const applier = getApplier();
|
||||
return await applier.rejectHunks(filePath, original, modified, hunkIndices);
|
||||
return await applier.rejectHunks(filePath, original, modified, hunkIndices, snippets);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -392,7 +543,7 @@ async function handleApplyDecisions(
|
|||
_event: IpcMainInvokeEvent,
|
||||
request: ApplyReviewRequest
|
||||
): Promise<IpcResult<ApplyReviewResult>> {
|
||||
return wrapHandler('review:applyDecisions', async () => {
|
||||
return wrapReviewHandler('review:applyDecisions', async () => {
|
||||
const applier = getApplier();
|
||||
const resolver = getContentResolver();
|
||||
|
||||
|
|
@ -424,8 +575,8 @@ review: {
|
|||
// Phase 2
|
||||
checkConflict: (filePath: string, expectedModified: string) =>
|
||||
invokeIpcWithResult<ConflictCheckResult>(REVIEW_CHECK_CONFLICT, filePath, expectedModified),
|
||||
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[]) =>
|
||||
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices),
|
||||
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) =>
|
||||
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices, snippets),
|
||||
rejectFile: (filePath: string, original: string, modified: string) =>
|
||||
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_FILE, filePath, original, modified),
|
||||
previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[]) =>
|
||||
|
|
@ -592,9 +743,9 @@ function mapReviewError(error: unknown): string {
|
|||
|
||||
```typescript
|
||||
import { useRef, useEffect, useMemo } from 'react';
|
||||
import { EditorView, ViewUpdate } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { EditorState, Transaction } from '@codemirror/state';
|
||||
import { unifiedMergeView, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
|
|
@ -681,52 +832,101 @@ export function CodeMirrorDiffView({
|
|||
}
|
||||
```
|
||||
|
||||
3. **Merge controls (accept/reject кнопки)**:
|
||||
3. **Merge controls (accept/reject кнопки) — VERIFIED API:**
|
||||
|
||||
```typescript
|
||||
// mergeControls принимает callback factory
|
||||
// type = 'accept' | 'reject', action = closure для применения
|
||||
// VERIFIED: mergeControls сигнатура:
|
||||
// (type: "reject" | "accept", action: (e: MouseEvent) => void) => HTMLElement
|
||||
//
|
||||
// ВАЖНО: action — это callback с MouseEvent параметром!
|
||||
// Кнопки должны использовать onmousedown (не onclick) — это паттерн CM.
|
||||
|
||||
mergeControls: showMergeControls
|
||||
? (type, action) => {
|
||||
? (type: 'reject' | 'accept', action: (e: MouseEvent) => void) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = type === 'accept'
|
||||
? 'cm-merge-accept-btn'
|
||||
: 'cm-merge-reject-btn';
|
||||
btn.textContent = type === 'accept' ? 'Accept' : 'Reject';
|
||||
btn.title = type === 'accept'
|
||||
? 'Keep this change (Ctrl+Shift+A)'
|
||||
: 'Revert this change (Ctrl+Shift+R)';
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
action(); // CM applies the change internally
|
||||
};
|
||||
? 'Keep this change'
|
||||
: 'Revert this change';
|
||||
btn.onmousedown = action; // ВАЖНО: onmousedown, НЕ onclick!
|
||||
return btn;
|
||||
}
|
||||
: undefined,
|
||||
```
|
||||
|
||||
4. **Event tracking для hunk index**:
|
||||
4. **Event tracking для accept/reject — VERIFIED API:**
|
||||
|
||||
```typescript
|
||||
// CodeMirror merge fires user events 'accept' and 'revert'
|
||||
// НО не сообщает hunk index напрямую!
|
||||
// Решение: Отслеживать через transaction.changes и chunk positions
|
||||
// VERIFIED: CodeMirror merge помечает transactions через annotation:
|
||||
// - Accept: Transaction.userEvent = "accept"
|
||||
// - Reject: Transaction.userEvent = "revert"
|
||||
//
|
||||
// НЕ используем tr.isUserEvent() — используем tr.annotation()!
|
||||
|
||||
let hunkCounter = 0;
|
||||
|
||||
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
EditorView.updateListener.of((update) => {
|
||||
for (const tr of update.transactions) {
|
||||
if (tr.isUserEvent('accept')) {
|
||||
onHunkAccepted?.(hunkCounter);
|
||||
hunkCounter++;
|
||||
const userEvent = tr.annotation(Transaction.userEvent);
|
||||
if (userEvent === 'accept') {
|
||||
// Определяем hunk index по позиции cursor ПЕРЕД транзакцией
|
||||
const pos = tr.startState.selection.main.head;
|
||||
const hunkIndex = computeHunkIndexAtPos(tr.startState, pos);
|
||||
onHunkAccepted?.(hunkIndex);
|
||||
}
|
||||
if (tr.isUserEvent('revert')) {
|
||||
onHunkRejected?.(hunkCounter);
|
||||
hunkCounter++;
|
||||
if (userEvent === 'revert') {
|
||||
const pos = tr.startState.selection.main.head;
|
||||
const hunkIndex = computeHunkIndexAtPos(tr.startState, pos);
|
||||
onHunkRejected?.(hunkIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**ВАЖНО**: Hunk index tracking через counter НЕ надёжен при non-sequential clicks. Альтернатива — вычислять hunk index по `transaction.changes.newLength` и маппить на chunk ranges. Это Phase 2 implementation detail.
|
||||
**ВАЖНО: Chunk positions — NO PUBLIC API!**
|
||||
|
||||
`@codemirror/merge` НЕ экспортирует публичный API для получения позиций chunks.
|
||||
Нужно вычислять hunk index самостоятельно через diff algorithm:
|
||||
|
||||
```typescript
|
||||
// Вычисляем позиции hunks через jsdiff (уже установлен)
|
||||
import { structuredPatch } from 'diff';
|
||||
|
||||
function computeHunkRanges(original: string, modified: string) {
|
||||
const patch = structuredPatch('', '', original, modified);
|
||||
// patch.hunks содержит newStart/newLines для каждого hunk
|
||||
return patch.hunks.map((h, idx) => ({
|
||||
index: idx,
|
||||
fromLine: h.newStart,
|
||||
toLine: h.newStart + h.newLines,
|
||||
}));
|
||||
}
|
||||
|
||||
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
|
||||
const line = state.doc.lineAt(pos).number;
|
||||
const ranges = hunkRangesRef.current; // Кешируем при создании editor
|
||||
for (const r of ranges) {
|
||||
if (line >= r.fromLine && line <= r.toLine) return r.index;
|
||||
}
|
||||
return -1; // Not in a hunk
|
||||
}
|
||||
```
|
||||
|
||||
5. **Keyboard navigation через StateCommand — VERIFIED API:**
|
||||
|
||||
```typescript
|
||||
// goToNextChunk и goToPreviousChunk — это StateCommand объекты.
|
||||
// Используются через keymap ИЛИ вызов .run(view):
|
||||
|
||||
keymap.of([
|
||||
{ key: 'Ctrl-Alt-ArrowDown', run: goToNextChunk },
|
||||
{ key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk },
|
||||
]),
|
||||
|
||||
// Программный вызов:
|
||||
// goToNextChunk(editorRef.current!) // returns boolean
|
||||
```
|
||||
|
||||
5. **Тема (CSS variables integration)**:
|
||||
```typescript
|
||||
|
|
@ -785,18 +985,37 @@ export function CodeMirrorDiffView({
|
|||
}, { dark: true });
|
||||
```
|
||||
|
||||
6. **Extensions assembly**:
|
||||
6. **Extensions assembly — VERIFIED unifiedMergeView config:**
|
||||
```typescript
|
||||
// VERIFIED: полная сигнатура unifiedMergeView config:
|
||||
// {
|
||||
// original: Text | string, // Required
|
||||
// highlightChanges?: boolean, // Default: true
|
||||
// gutter?: boolean, // Default: true
|
||||
// syntaxHighlightDeletions?: boolean, // Default: true
|
||||
// mergeControls?: boolean | ((type, action) => HTMLElement),
|
||||
// diffConfig?: DiffConfig,
|
||||
// collapseUnchanged?: { margin?: number, minSize?: number },
|
||||
// }
|
||||
|
||||
const extensions = useMemo(() => [
|
||||
readOnly ? EditorState.readOnly.of(true) : [],
|
||||
readOnly ? EditorView.editable.of(false) : [],
|
||||
getLanguageExtension(fileName),
|
||||
customTheme,
|
||||
keymap.of([
|
||||
{ key: 'Ctrl-Alt-ArrowDown', run: goToNextChunk },
|
||||
{ key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk },
|
||||
]),
|
||||
unifiedMergeView({
|
||||
original,
|
||||
mergeControls: showMergeControls ? mergeControlsFactory : undefined,
|
||||
collapseUnchanged: collapseUnchanged ? { margin: collapseMargin } : undefined,
|
||||
highlightChanges: true,
|
||||
gutter: true,
|
||||
syntaxHighlightDeletions: true,
|
||||
collapseUnchanged: collapseUnchanged
|
||||
? { margin: collapseMargin, minSize: 4 }
|
||||
: undefined,
|
||||
}),
|
||||
updateListener,
|
||||
].flat(), [original, modified, fileName, showMergeControls, collapseUnchanged]);
|
||||
|
|
@ -946,6 +1165,7 @@ function getFileStatusIcon(filePath: string, hunkDecisions: Record<string, HunkD
|
|||
8. **Multiple agents edited same file** — каждый agent показывается отдельно, reject применяется к конкретному agent's changes
|
||||
9. **Content source = 'unavailable'** — показываем snippet-only view (Phase 1 fallback) с warning: "Full file content unavailable. Showing snippet diffs only."
|
||||
10. **Accept без Apply** — decisions хранятся в Zustand (in-memory), пропадают при закрытии dialog. Это by design: accept = "я посмотрел и ОК", reject + Apply = "откатить изменения"
|
||||
11. **App restart между view и apply** — `ApplyReviewRequest` не содержит original/modified content. Если app перезагрузить → `FileContentResolver` переобчислит content из JSONL (кешируется на 3 мин). Worst case: file-history backup + snippet chain дадут тот же результат. Если файл на диске изменился → conflict detection сработает корректно
|
||||
|
||||
## Тестирование
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ export interface TaskBoundariesResult {
|
|||
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none';
|
||||
}
|
||||
|
||||
/** Расширенный TaskChangeSet с confidence деталями */
|
||||
/** Расширенный TaskChangeSet с confidence деталями.
|
||||
* TaskChangeSet определён в Phase 1 (review.ts) — backwards compatible extension.
|
||||
* Все Phase 1 поля (teamName, taskId, files, confidence, computedAt) сохраняются.
|
||||
*/
|
||||
export interface TaskChangeSetV2 extends TaskChangeSet {
|
||||
scope: TaskChangeScope;
|
||||
/** Предупреждения для UI */
|
||||
|
|
@ -83,8 +86,8 @@ import { createReadStream } from 'fs';
|
|||
import * as readline from 'readline';
|
||||
|
||||
export class TaskBoundaryParser {
|
||||
private cache = new Map<string, { data: TaskBoundariesResult; expiresAt: number }>();
|
||||
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
|
||||
private cache = new Map<string, { data: TaskBoundariesResult; mtime: number; expiresAt: number }>();
|
||||
private readonly CACHE_TTL = 60 * 1000; // 1 мин (не 3 — JSONL файлы меняются часто при активной работе)
|
||||
|
||||
/**
|
||||
* Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры.
|
||||
|
|
@ -225,9 +228,12 @@ private extractTeamctlBoundaries(
|
|||
|
||||
```typescript
|
||||
async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
|
||||
// Check cache
|
||||
// Check cache (S2 fix: проверяем И TTL И mtime файла)
|
||||
const stat = await import('fs/promises').then(f => f.stat(filePath));
|
||||
const cached = this.cache.get(filePath);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||
if (cached && cached.mtime === stat.mtimeMs && cached.expiresAt > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const boundaries: TaskBoundary[] = [];
|
||||
const allToolUsesByLine = new Map<number, { toolUseId: string; toolName: string; filePath?: string }[]>();
|
||||
|
|
@ -300,7 +306,7 @@ async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
|
|||
detectedMechanism,
|
||||
};
|
||||
|
||||
this.cache.set(filePath, { data: result, expiresAt: Date.now() + this.CACHE_TTL });
|
||||
this.cache.set(filePath, { data: result, mtime: stat.mtimeMs, expiresAt: Date.now() + this.CACHE_TTL });
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
|
@ -395,18 +401,25 @@ private computeScopes(
|
|||
}
|
||||
```
|
||||
|
||||
**extractContent helper (паттерн из MemberStatsComputer):**
|
||||
**extractContent helper (паттерн из MemberStatsComputer + FIXED для всех JSONL форматов):**
|
||||
|
||||
```typescript
|
||||
// Subagent JSONL: entry.message.content (массив блоков)
|
||||
// Main JSONL: entry.content (массив блоков)
|
||||
// JSONL файлы содержат СМЕШАННЫЕ форматы entries:
|
||||
// 1. Subagent assistant: entry.message.content = ContentBlock[]
|
||||
// 2. Main assistant: entry.content = ContentBlock[]
|
||||
// 3. tool_result entries: entry.content = string | ContentBlock[]
|
||||
// 4. Meta entries (file-history-snapshot): нет content с tool_use
|
||||
//
|
||||
// TaskUpdate блоки находятся ТОЛЬКО в assistant entries (1, 2),
|
||||
// поэтому проверяем оба формата.
|
||||
|
||||
private extractContent(entry: Record<string, unknown>): unknown[] | null {
|
||||
// Subagent format
|
||||
// Subagent format: entry.message.content
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
if (message && Array.isArray(message.content)) {
|
||||
return message.content;
|
||||
}
|
||||
// Main format fallback
|
||||
// Main format: entry.content (array)
|
||||
if (Array.isArray(entry.content)) {
|
||||
return entry.content;
|
||||
}
|
||||
|
|
@ -416,38 +429,155 @@ private extractContent(entry: Record<string, unknown>): unknown[] | null {
|
|||
|
||||
### 3. Модификация: `src/main/services/team/ChangeExtractorService.ts` (MODIFY)
|
||||
|
||||
Phase 1 создал `getTaskChanges()` с keyword-based scoping. Phase 3 заменяет на structure-based:
|
||||
Phase 1 создал `getTaskChanges()` с keyword-based scoping. Phase 3 заменяет на structure-based.
|
||||
|
||||
**CRITICAL FIX (C6+C7): `MemberLogSummary` НЕ имеет поля `filePath`!**
|
||||
|
||||
Реальный тип `MemberLogSummary` — это union `MemberSubagentLogSummary | MemberLeadSessionLogSummary`.
|
||||
Базовые поля: `kind`, `sessionId`, `projectId`, `description`, `memberName` (string | null), `startTime`, `durationMs`, `messageCount`, `isOngoing`.
|
||||
**НЕТ поля `filePath`!**
|
||||
|
||||
Также метод `getAllSessionLogs()` **НЕ существует** в `TeamMemberLogsFinder`.
|
||||
|
||||
**Решение**: Ввести внутренний тип `LogFileRef` и новый метод `resolveLogPaths()`:
|
||||
|
||||
```typescript
|
||||
// Phase 1 (заменяем)
|
||||
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSet> {
|
||||
// Keyword search через logsFinder.findLogsForTask()
|
||||
/** Внутренний тип — НЕ экспортируется из модуля */
|
||||
interface LogFileRef {
|
||||
filePath: string;
|
||||
memberName: string; // always string (fallback: 'unknown')
|
||||
}
|
||||
|
||||
// Phase 3 (новая реализация)
|
||||
/**
|
||||
* Конвертирует MemberLogSummary → LogFileRef.
|
||||
* Используем существующий findMemberLogPaths() для получения файловых путей.
|
||||
*/
|
||||
private async resolveLogFileRefs(
|
||||
teamName: string,
|
||||
logs: MemberLogSummary[]
|
||||
): Promise<LogFileRef[]> {
|
||||
const refs: LogFileRef[] = [];
|
||||
// Группируем по memberName для batch resolve
|
||||
const byMember = new Map<string, MemberLogSummary[]>();
|
||||
for (const log of logs) {
|
||||
const name = log.memberName ?? 'unknown';
|
||||
if (!byMember.has(name)) byMember.set(name, []);
|
||||
byMember.get(name)!.push(log);
|
||||
}
|
||||
|
||||
for (const [memberName, memberLogs] of byMember) {
|
||||
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
|
||||
// Match paths to logs by sessionId
|
||||
for (const log of memberLogs) {
|
||||
const matchedPath = paths.find(p =>
|
||||
log.kind === 'subagent'
|
||||
? p.includes(log.sessionId) && p.includes(log.subagentId)
|
||||
: p.includes(log.sessionId) && p.endsWith('.jsonl')
|
||||
);
|
||||
if (matchedPath) {
|
||||
refs.push({ filePath: matchedPath, memberName });
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
```
|
||||
|
||||
**Constructor (Phase 3 добавляет TaskBoundaryParser зависимость):**
|
||||
|
||||
```typescript
|
||||
export class ChangeExtractorService {
|
||||
private cache = new Map<string, { data: AgentChangeSet; expiresAt: number }>();
|
||||
private readonly CACHE_TTL = 60 * 1000; // 1 мин (совпадает с TaskBoundaryParser)
|
||||
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
private readonly boundaryParser: TaskBoundaryParser // Phase 3 addition
|
||||
) {}
|
||||
|
||||
// ... Phase 1 methods (getAgentChanges, getChangeStats) remain unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
**Методы extractFilteredChanges и extractAllChanges (используют LogFileRef, НЕ MemberLogSummary):**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Извлечь изменения ТОЛЬКО для указанных tool_use IDs.
|
||||
* Парсит JSONL, находит Edit/Write/MultiEdit блоки, фильтрует по allowedIds.
|
||||
*
|
||||
* IMPORTANT: принимает LogFileRef[] (НЕ MemberLogSummary[]) — у MemberLogSummary нет filePath!
|
||||
*/
|
||||
private async extractFilteredChanges(
|
||||
logRefs: LogFileRef[],
|
||||
allowedToolUseIds: Set<string>
|
||||
): Promise<FileChangeSummary[]> {
|
||||
const fileMap = new Map<string, FileChangeSummary>();
|
||||
const shouldFilter = allowedToolUseIds.size > 0;
|
||||
|
||||
for (const ref of logRefs) {
|
||||
const stream = createReadStream(ref.filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
// ... parse entry, extract content, find tool_use blocks ...
|
||||
// if (shouldFilter && !allowedToolUseIds.has(block.id)) continue;
|
||||
// → add to fileMap
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
return [...fileMap.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь ВСЕ изменения из одного JSONL файла (без фильтрации).
|
||||
* Используется для single-task sessions и Tier 4 fallback.
|
||||
*/
|
||||
private async extractAllChanges(filePath: string, memberName: string = 'unknown'): Promise<FileChangeSummary[]> {
|
||||
return this.extractFilteredChanges(
|
||||
[{ filePath, memberName }],
|
||||
new Set() // empty set + shouldFilter=false → accept all
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Phase 3 (новая реализация getTaskChanges):**
|
||||
|
||||
```typescript
|
||||
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
|
||||
// 1. Найти JSONL файлы через logsFinder
|
||||
// 1. Найти MemberLogSummary через logsFinder (реальный API)
|
||||
const logs = await this.logsFinder.findLogsForTask(teamName, taskId);
|
||||
|
||||
// 2. Для каждого JSONL — парсить boundaries через TaskBoundaryParser
|
||||
// 2. Конвертировать в LogFileRef (MemberLogSummary НЕ имеет filePath!)
|
||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
||||
if (logRefs.length === 0) {
|
||||
return this.emptyTaskChangeSet(teamName, taskId);
|
||||
}
|
||||
|
||||
// 3. Для каждого JSONL — парсить boundaries через TaskBoundaryParser
|
||||
const allScopes: TaskChangeScope[] = [];
|
||||
for (const log of logs) {
|
||||
const boundaries = await this.boundaryParser.parseBoundaries(log.filePath);
|
||||
for (const ref of logRefs) {
|
||||
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
|
||||
const scope = boundaries.scopes.find(s => s.taskId === taskId);
|
||||
if (scope) {
|
||||
scope.memberName = log.memberName;
|
||||
scope.memberName = ref.memberName;
|
||||
allScopes.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Если нет structural scopes → fallback на single-task assumption
|
||||
// 4. Если нет structural scopes → fallback на single-task assumption
|
||||
if (allScopes.length === 0) {
|
||||
return this.fallbackSingleTaskScope(teamName, taskId, logs);
|
||||
return this.fallbackSingleTaskScope(teamName, taskId, logRefs);
|
||||
}
|
||||
|
||||
// 4. Фильтровать snippets по tool_use IDs из scope
|
||||
// 5. Фильтровать snippets по tool_use IDs из scope
|
||||
const allowedToolUseIds = new Set(allScopes.flatMap(s => s.toolUseIds));
|
||||
const files = await this.extractFilteredChanges(logs, allowedToolUseIds);
|
||||
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds);
|
||||
|
||||
// 5. Compute confidence (worst case across all scopes)
|
||||
const worstTier = Math.max(...allScopes.map(s => s.confidence.tier));
|
||||
|
|
@ -478,17 +608,17 @@ async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2>
|
|||
private async fallbackSingleTaskScope(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
logs: MemberLogSummary[]
|
||||
logRefs: LogFileRef[] // C6 fix: LogFileRef вместо MemberLogSummary
|
||||
): Promise<TaskChangeSetV2> {
|
||||
// Проверяем: если agent работал только над одной задачей,
|
||||
// ВСЕ изменения в сессии = изменения этой задачи
|
||||
|
||||
for (const log of logs) {
|
||||
const boundaries = await this.boundaryParser.parseBoundaries(log.filePath);
|
||||
for (const ref of logRefs) {
|
||||
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
|
||||
|
||||
if (boundaries.isSingleTaskSession) {
|
||||
// Весь файл = одна задача → extract все changes
|
||||
const files = await this.extractAllChanges(log.filePath);
|
||||
const files = await this.extractAllChanges(ref.filePath, ref.memberName);
|
||||
return {
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -500,7 +630,7 @@ private async fallbackSingleTaskScope(
|
|||
computedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: log.memberName,
|
||||
memberName: ref.memberName,
|
||||
startLine: 1,
|
||||
endLine: Infinity,
|
||||
startTimestamp: '',
|
||||
|
|
@ -515,7 +645,8 @@ private async fallbackSingleTaskScope(
|
|||
}
|
||||
|
||||
// No single-task session found → Tier 4 fallback
|
||||
const files = await this.extractAllChanges(logs[0]?.filePath ?? '');
|
||||
const firstRef = logRefs[0];
|
||||
const files = firstRef ? await this.extractAllChanges(firstRef.filePath, firstRef.memberName) : [];
|
||||
return {
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -527,7 +658,7 @@ private async fallbackSingleTaskScope(
|
|||
computedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: logs[0]?.memberName ?? 'unknown',
|
||||
memberName: firstRef?.memberName ?? 'unknown',
|
||||
startLine: 1,
|
||||
endLine: Infinity,
|
||||
startTimestamp: '',
|
||||
|
|
@ -539,6 +670,21 @@ private async fallbackSingleTaskScope(
|
|||
warnings: ['Could not determine task boundaries. Showing all changes from this session.'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Empty result when no logs found at all */
|
||||
private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 {
|
||||
return {
|
||||
teamName, taskId, files: [],
|
||||
totalLinesAdded: 0, totalLinesRemoved: 0, totalFiles: 0,
|
||||
confidence: 'low', computedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
taskId, memberName: 'unknown', startLine: 0, endLine: 0,
|
||||
startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for this task.' },
|
||||
},
|
||||
warnings: ['No log files found for this task.'],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Модификация: `src/main/services/team/TeamMemberLogsFinder.ts` (MODIFY)
|
||||
|
|
@ -554,7 +700,9 @@ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
|||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
const pattern = new RegExp(`"taskId"\\s*:\\s*"${taskId}"`);
|
||||
// H5 fix: экранируем taskId для безопасного использования в regex
|
||||
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`"taskId"\\s*:\\s*"${escapedTaskId}"`);
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.includes('TaskUpdate') && pattern.test(line)) {
|
||||
|
|
@ -575,38 +723,16 @@ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
|||
}
|
||||
```
|
||||
|
||||
**Оптимизация `findLogsForTask()`:**
|
||||
**НЕ модифицируем `findLogsForTask()`** — он уже работает корректно через `fileMentionsTaskId()` (keyword search по JSONL). `hasTaskUpdateMarker()` может использоваться **параллельно** как fast-path проверка ДО полного парсинга, но НЕ заменяет `findLogsForTask()`.
|
||||
|
||||
Текущий метод использует `fileMentionsTaskId()` — keyword search. Phase 3 добавляет приоритетный path:
|
||||
**CRITICAL: `getAllSessionLogs()` НЕ существует!** Реальный `findLogsForTask()` (строки 110-197 TeamMemberLogsFinder.ts) уже итерирует по всем сессиям через `discoverProjectSessions()` и `fileMentionsTaskId()`. Метод возвращает `MemberLogSummary[]`.
|
||||
|
||||
`hasTaskUpdateMarker()` — это вспомогательный метод для `TaskBoundaryParser`, НЕ для замены findLogsForTask.
|
||||
|
||||
```typescript
|
||||
async findLogsForTask(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: { owner?: string; status?: string }
|
||||
): Promise<MemberLogSummary[]> {
|
||||
// Phase 3: Сначала пробуем structural markers (быстрее и точнее)
|
||||
const allLogs = await this.getAllSessionLogs(teamName);
|
||||
const results: MemberLogSummary[] = [];
|
||||
|
||||
for (const log of allLogs) {
|
||||
// Fast path: check for TaskUpdate markers
|
||||
const hasMarker = await this.hasTaskUpdateMarker(log.filePath, taskId);
|
||||
if (hasMarker) {
|
||||
results.push(log);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: keyword search (Phase 1 behaviour)
|
||||
if (await this.fileMentionsTaskId(log.filePath, taskId)) {
|
||||
results.push(log);
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) =>
|
||||
(b.startTime ?? '').localeCompare(a.startTime ?? '')
|
||||
);
|
||||
}
|
||||
// hasTaskUpdateMarker() может вызываться напрямую из TaskBoundaryParser или из renderer
|
||||
// для быстрой проверки "поддерживает ли сессия structural scoping"
|
||||
// Но findLogsForTask() НЕ МЕНЯЕТСЯ.
|
||||
```
|
||||
|
||||
### 5. IPC (без изменений)
|
||||
|
|
@ -704,11 +830,21 @@ export function ScopeWarningBanner({ warnings, confidence, onDismiss }: ScopeWar
|
|||
|
||||
#### `ChangeReviewDialog.tsx` (MODIFY)
|
||||
|
||||
Добавляем scope information в header:
|
||||
Добавляем scope information в header.
|
||||
|
||||
**Type guard (H2 fix — cross-phase compatibility):**
|
||||
```typescript
|
||||
// Phase 2 компоненты используют TaskChangeSet (Phase 1 тип).
|
||||
// Phase 3 расширяет до TaskChangeSetV2 с полем .scope.
|
||||
// ВСЕГДА проверять наличие .scope через 'in' guard:
|
||||
function isTaskChangeSetV2(cs: TaskChangeSet): cs is TaskChangeSetV2 {
|
||||
return 'scope' in cs;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// В header диалога (рядом с title)
|
||||
{mode === 'task' && activeChangeSet && 'scope' in activeChangeSet && (
|
||||
{mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
|
||||
{activeChangeSet.warnings.length > 0 && (
|
||||
|
|
@ -823,6 +959,7 @@ JSONL Timeline:
|
|||
| `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +150 |
|
||||
| `src/main/services/team/TeamMemberLogsFinder.ts` | MODIFY | +40 |
|
||||
| `src/main/services/team/index.ts` | MODIFY | +1 |
|
||||
| `src/main/index.ts` | MODIFY | +5 (создать TaskBoundaryParser, передать в ChangeExtractorService) |
|
||||
| `src/renderer/components/team/review/ConfidenceBadge.tsx` | NEW | 45 |
|
||||
| `src/renderer/components/team/review/ScopeWarningBanner.tsx` | NEW | 50 |
|
||||
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 |
|
||||
|
|
|
|||
|
|
@ -49,18 +49,28 @@ export function useDiffNavigation(
|
|||
|
||||
| Key | Action | Context |
|
||||
|-----|--------|---------|
|
||||
| `j` или `↓` | Next hunk | Diff view focused |
|
||||
| `k` или `↑` | Previous hunk | Diff view focused |
|
||||
| `n` | Next file | Any |
|
||||
| `p` или `Shift+N` | Previous file | Any |
|
||||
| `a` | Accept current hunk | Hunk focused |
|
||||
| `x` | Reject current hunk | Hunk focused |
|
||||
| `Shift+A` | Accept all hunks in file | File selected |
|
||||
| `Shift+X` | Reject all hunks in file | File selected |
|
||||
| `Enter` | Toggle hunk collapse | Hunk focused |
|
||||
| `Escape` | Close diff dialog | Any |
|
||||
| `j` или `↓` | Next hunk | Diff dialog open |
|
||||
| `k` или `↑` | Previous hunk | Diff dialog open |
|
||||
| `n` | Next file | Diff dialog open |
|
||||
| `p` или `Shift+N` | Previous file | Diff dialog open |
|
||||
| `a` | Accept current hunk | Diff dialog open |
|
||||
| `x` | Reject current hunk | Diff dialog open |
|
||||
| `Shift+A` | Accept all hunks in file | Diff dialog open |
|
||||
| `Shift+X` | Reject all hunks in file | Diff dialog open |
|
||||
| `Enter` | Toggle hunk collapse | Diff dialog open |
|
||||
| `?` | Show shortcuts help | Diff dialog open |
|
||||
| `Escape` | Close diff dialog | Diff dialog open |
|
||||
|
||||
**Реализация через existing pattern (useKeyboardShortcuts.ts):**
|
||||
**ВАЖНО (H1 fix): Конфликт с useKeyboardShortcuts!**
|
||||
|
||||
Существующий `useKeyboardShortcuts.ts` уже занимает `Cmd+Shift+K` (цикл contexts).
|
||||
Все Phase 4 shortcuts работают **ТОЛЬКО внутри открытого ChangeReviewDialog** (модальный контекст).
|
||||
Это предотвращает конфликты — глобальные shortcuts не срабатывают когда dialog открыт.
|
||||
|
||||
НЕ добавляем shortcuts в `useKeyboardShortcuts.ts` — вместо этого регистрируем
|
||||
локальный handler внутри `useDiffNavigation` hook с guard `if (!isDialogOpen) return`.
|
||||
|
||||
**Реализация через МОДАЛЬНЫЙ контекст (НЕ useKeyboardShortcuts.ts):**
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
|
|
@ -122,18 +132,20 @@ useEffect(() => {
|
|||
}, [isDialogOpen, currentHunkIndex, selectedFilePath]);
|
||||
```
|
||||
|
||||
**Scroll-to-hunk через CodeMirror API:**
|
||||
**Scroll-to-hunk через CodeMirror API — VERIFIED:**
|
||||
|
||||
```typescript
|
||||
// @codemirror/merge предоставляет goToNextChunk / goToPreviousChunk
|
||||
// VERIFIED: goToNextChunk и goToPreviousChunk — это StateCommand объекты.
|
||||
// Вызываются через .run(view) или через keymap:
|
||||
import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
|
||||
function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev') {
|
||||
if (direction === 'next') {
|
||||
goToNextChunk(editorView);
|
||||
goToNextChunk.run(editorView); // StateCommand.run() — НЕ прямой вызов!
|
||||
} else {
|
||||
goToPreviousChunk(editorView);
|
||||
goToPreviousChunk.run(editorView);
|
||||
}
|
||||
// Возвращает boolean: true если нашёл chunk, false если конец/начало
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -165,44 +177,104 @@ interface KeyboardShortcutsHelpProps {
|
|||
|
||||
```typescript
|
||||
const STORAGE_PREFIX = 'diff-viewed';
|
||||
const MAX_ENTRIES_PER_SCOPE = 5000; // M2 fix: cap per-scope entries
|
||||
const MAX_TOTAL_ENTRIES = 50; // M2 fix: max number of scope keys in storage
|
||||
|
||||
/**
|
||||
* Ключ = `diff-viewed:{teamName}:{taskOrMemberKey}`.
|
||||
* Значение = JSON array of viewed file paths.
|
||||
* Ключ = `diff-viewed:{teamName}:{scopeKey}`.
|
||||
* Значение = JSON object `{ files: string[], updatedAt: string }`.
|
||||
*
|
||||
* R3 FIX: Формат хранения — ВСЕГДА объект { files, updatedAt }, НЕ плоский string[].
|
||||
* Это обеспечивает совместимость get/set/cleanup.
|
||||
*
|
||||
* M2 fix: scopeKey включает version hash (computedAt) для инвалидации
|
||||
* при перевычислении changeSet.
|
||||
*/
|
||||
|
||||
interface ViewedStorageEntry {
|
||||
files: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function getStorageKey(teamName: string, scopeKey: string): string {
|
||||
return `${STORAGE_PREFIX}:${teamName}:${scopeKey}`;
|
||||
}
|
||||
|
||||
function parseEntry(raw: string | null): ViewedStorageEntry | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// R3 FIX: Миграция из старого формата (plain string[]) → новый формат
|
||||
if (Array.isArray(parsed)) {
|
||||
return { files: parsed, updatedAt: new Date(0).toISOString() };
|
||||
}
|
||||
if (parsed && Array.isArray(parsed.files)) {
|
||||
return parsed as ViewedStorageEntry;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveEntry(teamName: string, scopeKey: string, entry: ViewedStorageEntry): void {
|
||||
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
/** M2 fix: Cleanup старых entries при переполнении */
|
||||
export function cleanupOldViewedEntries(): void {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
|
||||
}
|
||||
// Если слишком много — удаляем самые старые по updatedAt
|
||||
if (keys.length > MAX_TOTAL_ENTRIES) {
|
||||
const sorted = keys
|
||||
.map(k => ({ key: k, entry: parseEntry(localStorage.getItem(k)) }))
|
||||
.sort((a, b) => (a.entry?.updatedAt ?? '').localeCompare(b.entry?.updatedAt ?? ''));
|
||||
for (let i = 0; i < sorted.length - MAX_TOTAL_ENTRIES; i++) {
|
||||
localStorage.removeItem(sorted[i].key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить Set просмотренных файлов */
|
||||
export function getViewedFiles(teamName: string, scopeKey: string): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(teamName, scopeKey));
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw) as string[];
|
||||
return new Set(arr);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
const entry = parseEntry(localStorage.getItem(getStorageKey(teamName, scopeKey)));
|
||||
return entry ? new Set(entry.files) : new Set();
|
||||
}
|
||||
|
||||
/** Отметить файл как просмотренный */
|
||||
export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void {
|
||||
const set = getViewedFiles(teamName, scopeKey);
|
||||
set.add(filePath);
|
||||
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify([...set]));
|
||||
saveEntry(teamName, scopeKey, {
|
||||
files: [...set],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Отметить файл как НЕ просмотренный */
|
||||
export function unmarkFileViewed(teamName: string, scopeKey: string, filePath: string): void {
|
||||
const set = getViewedFiles(teamName, scopeKey);
|
||||
set.delete(filePath);
|
||||
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify([...set]));
|
||||
if (set.size === 0) {
|
||||
localStorage.removeItem(getStorageKey(teamName, scopeKey));
|
||||
return;
|
||||
}
|
||||
saveEntry(teamName, scopeKey, {
|
||||
files: [...set],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Отметить все файлы как просмотренные */
|
||||
export function markAllViewed(teamName: string, scopeKey: string, filePaths: string[]): void {
|
||||
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(filePaths));
|
||||
saveEntry(teamName, scopeKey, {
|
||||
files: filePaths,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Сбросить все отметки */
|
||||
|
|
@ -463,6 +535,9 @@ import { promisify } from 'util';
|
|||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export class GitDiffFallback {
|
||||
// M3 fix: кеш isGitRepo результатов — один exec per projectPath за сессию
|
||||
private gitRepoCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* Получить содержимое файла из конкретного коммита.
|
||||
* Используется когда file-history-snapshot недоступен.
|
||||
|
|
@ -557,14 +632,20 @@ export class GitDiffFallback {
|
|||
|
||||
/**
|
||||
* Проверить: является ли projectPath git repo.
|
||||
* M3 fix: результат кешируется per projectPath (один exec за сессию).
|
||||
*/
|
||||
async isGitRepo(projectPath: string): Promise<boolean> {
|
||||
if (this.gitRepoCache.has(projectPath)) {
|
||||
return this.gitRepoCache.get(projectPath)!;
|
||||
}
|
||||
try {
|
||||
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
|
||||
cwd: projectPath,
|
||||
});
|
||||
this.gitRepoCache.set(projectPath, true);
|
||||
return true;
|
||||
} catch {
|
||||
this.gitRepoCache.set(projectPath, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -631,6 +712,53 @@ private async tryGitFallback(filePath: string): Promise<...> {
|
|||
}
|
||||
```
|
||||
|
||||
#### Интеграция в `src/main/index.ts` (MODIFY)
|
||||
|
||||
```typescript
|
||||
// В initializeIpcHandlers():
|
||||
// GitDiffFallback создаётся здесь и передаётся в review handlers
|
||||
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
||||
|
||||
const gitDiffFallback = new GitDiffFallback();
|
||||
|
||||
// Передать gitFallback в ReviewHandlerDeps (через 10-й позиционный параметр):
|
||||
// initializeReviewHandlers принимает deps — добавляем gitFallback
|
||||
// (ReviewHandlerDeps.gitFallback? добавлен в Phase 4)
|
||||
```
|
||||
|
||||
**ВАЖНО**: `ReviewHandlerDeps` расширяется в Phase 4:
|
||||
```typescript
|
||||
// В review.ts
|
||||
interface ReviewHandlerDeps {
|
||||
extractor: ChangeExtractorService;
|
||||
applier?: ChangeApplierService; // Phase 2
|
||||
contentResolver?: FileContentResolver; // Phase 2
|
||||
gitFallback?: GitDiffFallback; // Phase 4
|
||||
}
|
||||
```
|
||||
|
||||
`FileContentResolver` конструктор также расширяется для принятия optional `GitDiffFallback`:
|
||||
```typescript
|
||||
// В FileContentResolver.ts
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
private readonly gitFallback?: GitDiffFallback, // Phase 4 optional
|
||||
) {}
|
||||
```
|
||||
|
||||
#### Обновление `src/shared/types/api.ts` и `src/renderer/api/httpClient.ts` (MODIFY)
|
||||
|
||||
Добавить Phase 4 метод в `ReviewAPI` interface:
|
||||
```typescript
|
||||
// В ReviewAPI (api.ts):
|
||||
getGitFileLog: (projectPath: string, filePath: string) =>
|
||||
Promise<Array<{ hash: string; timestamp: string; message: string }>>;
|
||||
|
||||
// В HttpAPIClient (httpClient.ts):
|
||||
getGitFileLog: async (projectPath: string, filePath: string) =>
|
||||
window.electronAPI.review.getGitFileLog(projectPath, filePath),
|
||||
```
|
||||
|
||||
#### IPC: `src/preload/constants/ipcChannels.ts` (MODIFY)
|
||||
|
||||
```typescript
|
||||
|
|
@ -724,10 +852,13 @@ return (
|
|||
| `src/preload/constants/ipcChannels.ts` | MODIFY | +1 |
|
||||
| `src/preload/index.ts` | MODIFY | +5 |
|
||||
| `src/main/services/team/index.ts` | MODIFY | +1 |
|
||||
| `src/main/index.ts` | MODIFY | +5 |
|
||||
| `src/shared/types/api.ts` | MODIFY | +5 |
|
||||
| `src/renderer/api/httpClient.ts` | MODIFY | +5 |
|
||||
| **Feature 5: Auto-Viewed** | | |
|
||||
| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +25 |
|
||||
| `src/renderer/components/team/review/ReviewToolbar.tsx` | MODIFY | +15 |
|
||||
| **Итого** | 7 NEW + 14 MODIFY | ~960 |
|
||||
| **Итого** | 7 NEW + 17 MODIFY | ~975 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile, spawn } from 'child_process';
|
||||
|
|
@ -253,6 +254,12 @@ async function ensureCwdExists(cwd: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function wrapInAgentBlock(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return '';
|
||||
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
|
||||
}
|
||||
|
||||
function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
||||
return members
|
||||
.map((member) => {
|
||||
|
|
@ -263,7 +270,7 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
|||
}
|
||||
|
||||
function buildTaskStatusProtocol(teamName: string): string {
|
||||
return `MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
1. Use this command to mark task started:
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <taskId>
|
||||
2. Use this command to mark task completed BEFORE sending your final reply:
|
||||
|
|
@ -286,7 +293,43 @@ function buildTaskStatusProtocol(teamName: string): string {
|
|||
- Typical flow:
|
||||
a) Owner finishes work on #X → task complete #X
|
||||
b) Reviewer accepts → review approve #X
|
||||
Failure to follow this protocol means the task board will show incorrect status.`;
|
||||
Failure to follow this protocol means the task board will show incorrect status.`);
|
||||
}
|
||||
|
||||
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
|
||||
return wrapInAgentBlock(
|
||||
[
|
||||
`Internal task board tooling (teamctl.js):`,
|
||||
`- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`,
|
||||
``,
|
||||
`Task board operations — use teamctl.js via Bash:`,
|
||||
`- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"`,
|
||||
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
|
||||
``,
|
||||
`Notification policy:`,
|
||||
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
function buildAgentBlockUsagePolicy(): string {
|
||||
return `Agent-only formatting policy (applies to ALL messages you write):
|
||||
- Humans can see teammate inbox messages and coordination text in the UI.
|
||||
- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks.
|
||||
- Any internal operational instructions about tooling/scripts MUST be hidden inside an agent-only block, including:
|
||||
- how to use internal scripts (e.g. teamctl.js), exact CLI commands, flags (e.g. --notify)
|
||||
- review command phrases like "review approve <id>" / "review request-changes <id>"
|
||||
- internal file paths under ~/.claude/ (tools, teams, tasks, kanban state, etc.)
|
||||
- meta coordination lines like "All teammates are online and have received their assignments via --notify."
|
||||
- Use an agent-only fenced block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE):
|
||||
- AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}
|
||||
- AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}
|
||||
- IMPORTANT: the fence lines must start at the beginning of the line (no indentation).
|
||||
- Example (copy/paste exactly, no indentation):
|
||||
${AGENT_BLOCK_OPEN}
|
||||
(internal instructions: commands, script usage, paths, etc.)
|
||||
${AGENT_BLOCK_CLOSE}
|
||||
- Put ONLY the internal instructions inside the agent-only block.`;
|
||||
}
|
||||
|
||||
function getSystemLocale(): string {
|
||||
|
|
@ -311,12 +354,14 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
const members = buildMembersPrompt(request.members);
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
|
||||
const leadName =
|
||||
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
||||
|
||||
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||
You are "${leadName}", the team lead.
|
||||
|
|
@ -333,17 +378,18 @@ Constraints:
|
|||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||
- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).
|
||||
- Use the team task board for assigned/substantial work.
|
||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||
|
||||
${teamCtlOps}
|
||||
|
||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
|
||||
Task board operations — use teamctl.js via Bash:
|
||||
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"
|
||||
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
|
||||
Message formatting:
|
||||
${agentBlockPolicy}
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
|
|
@ -359,12 +405,13 @@ Steps (execute in this exact order):
|
|||
${languageInstruction}
|
||||
Introduce yourself briefly (name and role) and confirm you are ready.
|
||||
Then wait for task assignments.
|
||||
Include the following agent-only instructions verbatim in the prompt:
|
||||
|
||||
${taskProtocol}
|
||||
${taskProtocol}
|
||||
|
||||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations").
|
||||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
- Avoid duplicate notifications for the same assignment.
|
||||
|
||||
4) After all steps, output a short summary.
|
||||
|
||||
|
|
@ -383,8 +430,10 @@ function buildLaunchPrompt(
|
|||
: '';
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
|
||||
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
||||
|
||||
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||
You are "${leadName}", the team lead.
|
||||
|
|
@ -401,17 +450,18 @@ Constraints:
|
|||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||
- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).
|
||||
- Use the team task board for assigned/substantial work.
|
||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||
|
||||
${teamCtlOps}
|
||||
|
||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
|
||||
Task board operations — use teamctl.js via Bash:
|
||||
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"
|
||||
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
|
||||
Message formatting:
|
||||
${agentBlockPolicy}
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
|
|
@ -428,12 +478,13 @@ Steps (execute in this exact order):
|
|||
${languageInstruction}
|
||||
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready.
|
||||
Then resume any pending work you own (if any) and wait for new assignments.
|
||||
Include the following agent-only instructions verbatim in the prompt:
|
||||
|
||||
${taskProtocol}
|
||||
${taskProtocol}
|
||||
|
||||
4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations").
|
||||
4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
- Avoid duplicate notifications for the same assignment.
|
||||
|
||||
5) After all steps, output a short summary.
|
||||
|
||||
|
|
@ -1292,7 +1343,10 @@ export class TeamProvisioningService {
|
|||
const message = [
|
||||
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
||||
`Process them in order (oldest first).`,
|
||||
`If action is required, delegate via task creation (teamctl.js --notify) or SendMessage, and keep responses minimal.`,
|
||||
`If action is required, delegate via task creation or SendMessage, and keep responses minimal.`,
|
||||
AGENT_BLOCK_OPEN,
|
||||
`Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
``,
|
||||
`Messages:`,
|
||||
...batch.flatMap((m, idx) => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { api } from '@renderer/api';
|
|||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { format } from 'date-fns';
|
||||
import { User } from 'lucide-react';
|
||||
|
|
@ -343,7 +344,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
const hasImages = content.images.length > 0;
|
||||
// Use rawText to preserve /commands inline
|
||||
const textContent = content.rawText ?? content.text ?? '';
|
||||
const isLongContent = textContent.length > 500;
|
||||
const stripped = useMemo(() => stripAgentBlocks(textContent), [textContent]);
|
||||
const isLongContent = stripped.length > 500;
|
||||
|
||||
// Extract @path mentions from text
|
||||
const pathMentions = useMemo(() => {
|
||||
|
|
@ -416,8 +418,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
const isExpanded = isManuallyExpanded || shouldAutoExpand;
|
||||
|
||||
// Determine display text
|
||||
const displayText =
|
||||
isLongContent && !isExpanded ? textContent.slice(0, 500) + '...' : textContent;
|
||||
const displayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -433,8 +434,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
<User className="size-3.5" style={{ color: 'var(--color-text-secondary)' }} />
|
||||
</div>
|
||||
|
||||
{/* Content - polished bubble with subtle depth */}
|
||||
{textContent && (
|
||||
{/* Content - polished bubble with subtle depth (hide when only agent blocks) */}
|
||||
{stripped && (
|
||||
<div
|
||||
className="group relative overflow-hidden rounded-2xl rounded-br-sm px-4 py-3"
|
||||
style={{
|
||||
|
|
@ -443,7 +444,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
boxShadow: 'var(--chat-user-shadow)',
|
||||
}}
|
||||
>
|
||||
<CopyButton text={textContent} bgColor="var(--chat-user-bg)" />
|
||||
<CopyButton text={stripped} bgColor="var(--chat-user-bg)" />
|
||||
|
||||
<div className="text-sm" style={{ color: 'var(--chat-user-text)' }} data-search-content>
|
||||
<ReactMarkdown
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
|
||||
|
|
@ -98,6 +99,11 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
[teammateMessage.replyToSummary]
|
||||
);
|
||||
|
||||
const displayContent = useMemo(
|
||||
() => stripAgentBlocks(teammateMessage.content),
|
||||
[teammateMessage.content]
|
||||
);
|
||||
|
||||
// Noise: minimal inline row (no card, no expand)
|
||||
if (noiseLabel) {
|
||||
return (
|
||||
|
|
@ -215,7 +221,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="p-3">
|
||||
<MarkdownViewer content={teammateMessage.content} copyable />
|
||||
<MarkdownViewer content={displayContent} copyable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ interface RepositoryCardProps {
|
|||
onClick: () => void;
|
||||
isHighlighted?: boolean;
|
||||
taskCounts?: TaskStatusCounts;
|
||||
tasksLoading?: boolean;
|
||||
}
|
||||
|
||||
const RepositoryCard = ({
|
||||
|
|
@ -112,6 +113,7 @@ const RepositoryCard = ({
|
|||
onClick,
|
||||
isHighlighted,
|
||||
taskCounts,
|
||||
tasksLoading,
|
||||
}: Readonly<RepositoryCardProps>): React.JSX.Element => {
|
||||
const lastActivity = repo.mostRecentSession
|
||||
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true })
|
||||
|
|
@ -133,16 +135,16 @@ const RepositoryCard = ({
|
|||
: 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised'
|
||||
} `}
|
||||
>
|
||||
{/* Icon with subtle border */}
|
||||
<div className="mb-3 flex size-8 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
||||
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" />
|
||||
{/* Icon + Project name */}
|
||||
<div className="mb-1 flex items-center gap-2.5">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
||||
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" />
|
||||
</div>
|
||||
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||
{repo.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Project name */}
|
||||
<h3 className="mb-1 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||
{repo.name}
|
||||
</h3>
|
||||
|
||||
{/* Project path - monospace, muted */}
|
||||
<p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p>
|
||||
|
||||
|
|
@ -181,7 +183,15 @@ const RepositoryCard = ({
|
|||
</div>
|
||||
|
||||
{/* Tasks progress bar */}
|
||||
{taskCounts &&
|
||||
{tasksLoading ? (
|
||||
<div className="mt-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 animate-pulse overflow-hidden rounded-full bg-[var(--color-surface-raised)]" />
|
||||
<div className="h-2.5 w-6 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
taskCounts &&
|
||||
(() => {
|
||||
const pending = taskCounts.pending ?? 0;
|
||||
const inProgress = taskCounts.inProgress ?? 0;
|
||||
|
|
@ -212,7 +222,8 @@ const RepositoryCard = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
})()
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -348,6 +359,7 @@ const ProjectsGrid = ({
|
|||
fetchRepositoryGroups,
|
||||
selectRepository,
|
||||
globalTasks,
|
||||
globalTasksLoading,
|
||||
fetchAllTasks,
|
||||
openTeamsTab,
|
||||
} = useStore(
|
||||
|
|
@ -357,6 +369,7 @@ const ProjectsGrid = ({
|
|||
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||
selectRepository: s.selectRepository,
|
||||
globalTasks: s.globalTasks,
|
||||
globalTasksLoading: s.globalTasksLoading,
|
||||
fetchAllTasks: s.fetchAllTasks,
|
||||
openTeamsTab: s.openTeamsTab,
|
||||
}))
|
||||
|
|
@ -497,7 +510,8 @@ const ProjectsGrid = ({
|
|||
openTeamsTab();
|
||||
}}
|
||||
isHighlighted={!!searchQuery.trim()}
|
||||
taskCounts={counts}
|
||||
taskCounts={globalTasksLoading ? undefined : counts}
|
||||
tasksLoading={globalTasksLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ export const GlobalTaskList = ({
|
|||
Team: {task.teamDisplayName}
|
||||
</div>
|
||||
)}
|
||||
<SidebarTaskItem task={task} />
|
||||
<SidebarTaskItem task={task} hideTeamName />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,36 @@ function formatTaskDate(dateStr: string | undefined): string | null {
|
|||
return format(d, 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
interface SidebarTaskItemProps {
|
||||
task: GlobalTask;
|
||||
function formatUpdatedLabel(task: GlobalTask): string | null {
|
||||
const updatedStr = task.updatedAt;
|
||||
if (!updatedStr) return null;
|
||||
const updated = new Date(updatedStr);
|
||||
if (isNaN(updated.getTime())) return null;
|
||||
|
||||
// Don't show "updated" if there's no createdAt to compare, or times are within 60s
|
||||
const createdStr = task.createdAt;
|
||||
if (createdStr) {
|
||||
const created = new Date(createdStr);
|
||||
if (!isNaN(created.getTime()) && Math.abs(updated.getTime() - created.getTime()) < 60_000) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isToday(updated)) return `upd ${format(updated, 'HH:mm')}`;
|
||||
if (isYesterday(updated)) return 'upd yesterday';
|
||||
if (isThisYear(updated)) return `upd ${format(updated, 'MMM d')}`;
|
||||
return `upd ${format(updated, 'MMM d, yyyy')}`;
|
||||
}
|
||||
|
||||
export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => {
|
||||
interface SidebarTaskItemProps {
|
||||
task: GlobalTask;
|
||||
hideTeamName?: boolean;
|
||||
}
|
||||
|
||||
export const SidebarTaskItem = ({
|
||||
task,
|
||||
hideTeamName,
|
||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||
const cfg =
|
||||
|
|
@ -37,7 +62,8 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme
|
|||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||
const StatusIcon = cfg.icon;
|
||||
const dateLabel = formatTaskDate(task.updatedAt ?? task.createdAt);
|
||||
const updatedLabel = formatUpdatedLabel(task);
|
||||
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -66,12 +92,18 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme
|
|||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<span>{task.owner ?? 'unassigned'}</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="truncate">{task.teamDisplayName}</span>
|
||||
{!hideTeamName && (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="truncate">{task.teamDisplayName}</span>
|
||||
</>
|
||||
)}
|
||||
{dateLabel && (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="shrink-0">{dateLabel}</span>
|
||||
<span className={`shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
|
||||
{dateLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useStore } from '@renderer/store';
|
|||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
|
|
@ -1073,7 +1074,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}}
|
||||
onReplyToMessage={(message) => {
|
||||
setSendDialogRecipient(message.from);
|
||||
setReplyQuote({ from: message.from, text: message.text });
|
||||
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
|
||||
setSendDialogOpen(true);
|
||||
}}
|
||||
onMessageVisible={handleMessageVisible}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
parseStructuredAgentMessage,
|
||||
} from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react';
|
||||
|
|
@ -121,11 +121,6 @@ function getSystemMessageLabel(text: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Strip ```info_for_agent ... ``` blocks from text for UI display. */
|
||||
function stripAgentBlocks(text: string): string {
|
||||
return text.replace(createAgentBlockRegex(), '').trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full message card — left colored border, name badge, collapsible content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -361,12 +356,7 @@ export const ActivityItem = ({
|
|||
) : parsedReply ? (
|
||||
<ReplyQuoteBlock reply={parsedReply} />
|
||||
) : (
|
||||
<MarkdownViewer
|
||||
content={displayText ?? message.text}
|
||||
maxHeight="max-h-56"
|
||||
copyable
|
||||
bare
|
||||
/>
|
||||
<MarkdownViewer content={displayText ?? ''} maxHeight="max-h-56" copyable bare />
|
||||
)}
|
||||
{message.attachments?.length && message.messageId ? (
|
||||
<AttachmentDisplay
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useStore } from '@renderer/store';
|
|||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -126,7 +127,9 @@ export const TaskCommentsSection = ({
|
|||
onClick={() =>
|
||||
setReplyTo({
|
||||
author: comment.author,
|
||||
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
|
||||
text: stripAgentBlocks(
|
||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
@ -139,7 +142,8 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
const displayText = reply ? reply.replyText : comment.text;
|
||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||
const displayText = stripAgentBlocks(rawForDisplay);
|
||||
const needsExpandCollapse = displayText.includes('\n');
|
||||
const expanded = expandedCommentIds.has(comment.id);
|
||||
const collapsedHeight = 'max-h-[120px]';
|
||||
|
|
@ -154,14 +158,18 @@ export const TaskCommentsSection = ({
|
|||
>
|
||||
{reply ? (
|
||||
<ReplyQuoteBlock
|
||||
reply={reply}
|
||||
reply={{
|
||||
...reply,
|
||||
originalText: stripAgentBlocks(reply.originalText),
|
||||
replyText: stripAgentBlocks(reply.replyText),
|
||||
}}
|
||||
bodyMaxHeight={
|
||||
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownViewer
|
||||
content={comment.text}
|
||||
content={displayText}
|
||||
maxHeight={
|
||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
||||
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
|
||||
import { createAgentBlockRegex, stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { format } from 'date-fns';
|
||||
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[]
|
|||
.trim();
|
||||
if (content) agentInfo.push(content);
|
||||
}
|
||||
const humanText = raw.replace(createAgentBlockRegex(), '').trim();
|
||||
const humanText = stripAgentBlocks(raw);
|
||||
return { humanText, agentInfo };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { findLastOutput } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch';
|
||||
|
||||
import type { AppState, SearchMatch } from '../types';
|
||||
|
|
@ -257,8 +258,8 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
|
|||
|
||||
for (const item of conversation.items) {
|
||||
if (item.type === 'user') {
|
||||
// Search user message text
|
||||
const text = item.group.content.rawText ?? item.group.content.text ?? '';
|
||||
const raw = item.group.content.rawText ?? item.group.content.text ?? '';
|
||||
const text = stripAgentBlocks(raw);
|
||||
addMarkdownMatches(text, item.group.id, 'user');
|
||||
} else if (item.type === 'ai') {
|
||||
// For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,42 @@ function getEffectiveDate(task: GlobalTask): string | undefined {
|
|||
return task.updatedAt ?? task.createdAt;
|
||||
}
|
||||
|
||||
function getEffectiveTs(task: GlobalTask): number {
|
||||
const d = getEffectiveDate(task);
|
||||
return d ? new Date(d).getTime() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map: teamName → max effective timestamp among its tasks.
|
||||
* Used to sort team sub-groups by most recent activity (not alphabetically).
|
||||
*/
|
||||
function buildTeamMaxTs(tasks: GlobalTask[]): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
for (const t of tasks) {
|
||||
const ts = getEffectiveTs(t);
|
||||
const cur = m.get(t.teamName) ?? 0;
|
||||
if (ts > cur) m.set(t.teamName, ts);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort comparator: teams ordered by most recent task (desc),
|
||||
* within the same team — by individual task date (desc).
|
||||
*/
|
||||
function compareByTeamFreshness(
|
||||
a: GlobalTask,
|
||||
b: GlobalTask,
|
||||
teamMaxTs: Map<string, number>
|
||||
): number {
|
||||
if (a.teamName !== b.teamName) {
|
||||
const teamTsA = teamMaxTs.get(a.teamName) ?? 0;
|
||||
const teamTsB = teamMaxTs.get(b.teamName) ?? 0;
|
||||
return teamTsB - teamTsA;
|
||||
}
|
||||
return getEffectiveTs(b) - getEffectiveTs(a);
|
||||
}
|
||||
|
||||
function getDateCategory(dateStr: string | undefined): DateCategory {
|
||||
if (!dateStr) return 'Older';
|
||||
const d = new Date(dateStr);
|
||||
|
|
@ -43,15 +79,8 @@ export function groupTasksByDate(tasks: GlobalTask[]): DateGroupedTasks {
|
|||
}
|
||||
|
||||
for (const cat of DATE_CATEGORY_ORDER) {
|
||||
groups[cat].sort((a, b) => {
|
||||
const cmp = a.teamName.localeCompare(b.teamName);
|
||||
if (cmp !== 0) return cmp;
|
||||
const dateA = getEffectiveDate(a);
|
||||
const dateB = getEffectiveDate(b);
|
||||
const tsA = dateA ? new Date(dateA).getTime() : 0;
|
||||
const tsB = dateB ? new Date(dateB).getTime() : 0;
|
||||
return tsB - tsA;
|
||||
});
|
||||
const teamTs = buildTeamMaxTs(groups[cat]);
|
||||
groups[cat].sort((a, b) => compareByTeamFreshness(a, b, teamTs));
|
||||
}
|
||||
|
||||
return groups;
|
||||
|
|
@ -94,15 +123,8 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] {
|
|||
}
|
||||
|
||||
for (const entry of byKey.values()) {
|
||||
entry.tasks.sort((a, b) => {
|
||||
const cmp = a.teamName.localeCompare(b.teamName);
|
||||
if (cmp !== 0) return cmp;
|
||||
const dateA = getEffectiveDate(a);
|
||||
const dateB = getEffectiveDate(b);
|
||||
const tsA = dateA ? new Date(dateA).getTime() : 0;
|
||||
const tsB = dateB ? new Date(dateB).getTime() : 0;
|
||||
return tsB - tsA;
|
||||
});
|
||||
const teamTs = buildTeamMaxTs(entry.tasks);
|
||||
entry.tasks.sort((a, b) => compareByTeamFreshness(a, b, teamTs));
|
||||
}
|
||||
|
||||
const groups: ProjectTaskGroup[] = [];
|
||||
|
|
@ -112,18 +134,8 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] {
|
|||
}
|
||||
|
||||
groups.sort((a, b) => {
|
||||
const tsA = Math.max(
|
||||
...a.tasks.map((t) => {
|
||||
const d = getEffectiveDate(t);
|
||||
return d ? new Date(d).getTime() : 0;
|
||||
})
|
||||
);
|
||||
const tsB = Math.max(
|
||||
...b.tasks.map((t) => {
|
||||
const d = getEffectiveDate(t);
|
||||
return d ? new Date(d).getTime() : 0;
|
||||
})
|
||||
);
|
||||
const tsA = Math.max(...a.tasks.map(getEffectiveTs));
|
||||
const tsB = Math.max(...b.tasks.map(getEffectiveTs));
|
||||
return tsB - tsA;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ export function createAgentBlockRegex(): RegExp {
|
|||
return new RegExp(AGENT_BLOCK_PATTERN, 'g');
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes ```info_for_agent ... ``` blocks from text for UI display.
|
||||
*/
|
||||
export function stripAgentBlocks(text: string): string {
|
||||
return text.replace(createAgentBlockRegex(), '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use createAgentBlockRegex() instead to avoid stateful 'g' flag issues.
|
||||
* Kept for backward compatibility with .replace() calls.
|
||||
|
|
|
|||
Loading…
Reference in a new issue