Merge branch 'main' into cw/small-change-flow-conflict-1772826399181

# Conflicts:
#	README.md
#	apps/server/execution/orchestrator.ts
#	apps/server/test/unit/headquarters.test.ts
#	apps/server/trpc/router.ts
#	apps/server/trpc/routers/agent.ts
#	apps/server/trpc/routers/headquarters.ts
#	apps/web/src/components/hq/HQSections.test.tsx
#	apps/web/src/components/hq/types.ts
#	apps/web/src/layouts/AppLayout.tsx
#	apps/web/src/routes/hq.tsx
#	apps/web/tsconfig.app.tsbuildinfo
#	docs/dispatch-events.md
#	docs/server-api.md
#	vitest.config.ts
This commit is contained in:
Lukas May
2026-03-06 21:01:36 +01:00
91 changed files with 7135 additions and 1398 deletions

View File

@@ -113,7 +113,11 @@ describe('DefaultConflictResolutionService', () => {
const conflicts = ['src/file1.ts', 'src/file2.ts'];
await service.handleConflict(originalTask.id, conflicts);
const result = await service.handleConflict(originalTask.id, conflicts);
// Should return the created task
expect(result).toBeDefined();
expect(result!.name).toBe('Resolve conflicts: Original Task');
// Check resolution task was created
const tasks = await taskRepository.findByPhaseId(testPhaseId);
@@ -135,12 +139,12 @@ describe('DefaultConflictResolutionService', () => {
expect(resolutionTask!.description).toContain('Original Task');
});
it('should update original task status to blocked', async () => {
it('should NOT block original task (it stays at its current status)', async () => {
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Task To Block',
status: 'in_progress',
name: 'Task To Not Block',
status: 'completed',
order: 1,
});
@@ -152,9 +156,37 @@ describe('DefaultConflictResolutionService', () => {
await service.handleConflict(originalTask.id, ['conflict.ts']);
// Check original task is blocked
// Original task should remain completed (not blocked)
const updatedTask = await taskRepository.findById(originalTask.id);
expect(updatedTask!.status).toBe('blocked');
expect(updatedTask!.status).toBe('completed');
});
it('should return null and skip creation if duplicate resolution task exists', async () => {
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Dedup Task',
order: 1,
});
await agentRepository.create({
name: 'agent-dedup',
taskId: originalTask.id,
worktreeId: 'wt-dedup',
});
// First call creates the resolution task
const first = await service.handleConflict(originalTask.id, ['conflict.ts']);
expect(first).toBeDefined();
// Second call should return null (dedup)
const second = await service.handleConflict(originalTask.id, ['conflict.ts']);
expect(second).toBeNull();
// Only one resolution task should exist
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const resolutionTasks = tasks.filter(t => t.name.startsWith('Resolve conflicts:'));
expect(resolutionTasks.length).toBe(1);
});
it('should create message to agent about conflict', async () => {
@@ -243,9 +275,9 @@ describe('DefaultConflictResolutionService', () => {
worktreeId: 'wt-no-msg',
});
// Should not throw and should still create task
await expect(serviceNoMsg.handleConflict(originalTask.id, ['test.ts']))
.resolves.not.toThrow();
// Should not throw and should return the created task
const result = await serviceNoMsg.handleConflict(originalTask.id, ['test.ts']);
expect(result).toBeDefined();
// Check resolution task was still created
const tasks = await taskRepository.findByPhaseId(testPhaseId);
@@ -275,9 +307,9 @@ describe('DefaultConflictResolutionService', () => {
worktreeId: 'wt-no-events',
});
// Should not throw and should still create task
await expect(serviceNoEvents.handleConflict(originalTask.id, ['test.ts']))
.resolves.not.toThrow();
// Should not throw and should return the created task
const result = await serviceNoEvents.handleConflict(originalTask.id, ['test.ts']);
expect(result).toBeDefined();
// Check resolution task was still created
const tasks = await taskRepository.findByPhaseId(testPhaseId);

View File

@@ -14,6 +14,7 @@ import type { EventBus, TaskQueuedEvent } from '../events/index.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { Task } from '../db/schema.js';
// =============================================================================
// ConflictResolutionService Interface (Port)
@@ -38,8 +39,9 @@ export interface ConflictResolutionService {
* @param taskId - ID of the task that conflicted
* @param conflicts - List of conflicting file paths
* @param mergeContext - Optional branch context for branch hierarchy merges
* @returns The created conflict-resolution task, or null if a duplicate already exists
*/
handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<void>;
handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<Task | null>;
}
// =============================================================================
@@ -63,8 +65,13 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
/**
* Handle a merge conflict.
* Creates a conflict-resolution task and notifies the agent via message.
* Returns the created task, or null if a duplicate already exists.
*
* NOTE: The original task is NOT blocked. It was already completed by
* handleAgentStopped before this method is called. The pending resolution
* task prevents premature phase completion on its own.
*/
async handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<void> {
async handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<Task | null> {
// Get original task for context
const originalTask = await this.taskRepository.findById(taskId);
if (!originalTask) {
@@ -77,6 +84,19 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
throw new Error(`No agent found for task: ${taskId}`);
}
// Dedup: skip if a pending/in_progress resolution task already exists for this original task
if (originalTask.phaseId) {
const phaseTasks = await this.taskRepository.findByPhaseId(originalTask.phaseId);
const existingResolution = phaseTasks.find(
(t) =>
t.name === `Resolve conflicts: ${originalTask.name}` &&
(t.status === 'pending' || t.status === 'in_progress'),
);
if (existingResolution) {
return null;
}
}
// Build conflict description
const descriptionLines = [
'Merge conflicts detected. Resolve conflicts in the following files:',
@@ -115,9 +135,6 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
order: originalTask.order + 1,
});
// Update original task status to blocked
await this.taskRepository.update(taskId, { status: 'blocked' });
// Create message to agent if messageRepository is configured
if (this.messageRepository) {
const messageContent = [
@@ -155,5 +172,7 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
};
this.eventBus.emit(event);
}
return conflictTask;
}
}

View File

@@ -477,9 +477,9 @@ describe('DefaultCoordinationManager', () => {
expect(conflictTask!.priority).toBe('high');
expect(conflictTask!.description).toContain('src/index.ts');
// Check original task blocked
// Original task should NOT be blocked (stays at its current status)
const updatedOriginal = await taskRepository.findById(task.id);
expect(updatedOriginal!.status).toBe('blocked');
expect(updatedOriginal!.status).toBe('pending');
// Check TaskQueuedEvent emitted for conflict task
const queuedEvent = eventBus.emittedEvents.find(

View File

@@ -20,4 +20,18 @@ export interface ConversationRepository {
findById(id: string): Promise<Conversation | null>;
findPendingForAgent(toAgentId: string): Promise<Conversation[]>;
answer(id: string, answer: string): Promise<Conversation | null>;
/**
* Count conversations grouped by fromAgentId for a batch of agent IDs.
* Returns only agents that have at least one conversation (count > 0).
* Used by listForRadar to compute messagesCount without N+1 queries.
*/
countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]>;
/**
* Find all conversations initiated by a given agent, ordered by createdAt ascending.
* Used by conversation.getByFromAgent drilldown procedure.
* Cap at 200 results.
*/
findByFromAgentId(agentId: string): Promise<Conversation[]>;
}

View File

@@ -4,7 +4,7 @@
* Implements ConversationRepository interface using Drizzle ORM.
*/
import { eq, and, asc } from 'drizzle-orm';
import { eq, and, asc, count, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { conversations, type Conversation } from '../../schema.js';
@@ -64,4 +64,26 @@ export class DrizzleConversationRepository implements ConversationRepository {
.where(eq(conversations.id, id));
return this.findById(id);
}
async countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]> {
if (agentIds.length === 0) return [];
const rows = await this.db
.select({
agentId: conversations.fromAgentId,
count: count(),
})
.from(conversations)
.where(inArray(conversations.fromAgentId, agentIds))
.groupBy(conversations.fromAgentId);
return rows.map(r => ({ agentId: r.agentId, count: Number(r.count) }));
}
async findByFromAgentId(agentId: string): Promise<Conversation[]> {
return this.db
.select()
.from(conversations)
.where(eq(conversations.fromAgentId, agentId))
.orderBy(asc(conversations.createdAt))
.limit(200);
}
}

View File

@@ -4,7 +4,7 @@
* Implements LogChunkRepository interface using Drizzle ORM.
*/
import { eq, asc, max } from 'drizzle-orm';
import { eq, asc, max, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { agentLogChunks } from '../../schema.js';
@@ -41,6 +41,20 @@ export class DrizzleLogChunkRepository implements LogChunkRepository {
.orderBy(asc(agentLogChunks.createdAt));
}
async findByAgentIds(agentIds: string[]): Promise<{ agentId: string; content: string; sessionNumber: number; createdAt: Date }[]> {
if (agentIds.length === 0) return [];
return this.db
.select({
agentId: agentLogChunks.agentId,
content: agentLogChunks.content,
sessionNumber: agentLogChunks.sessionNumber,
createdAt: agentLogChunks.createdAt,
})
.from(agentLogChunks)
.where(inArray(agentLogChunks.agentId, agentIds))
.orderBy(asc(agentLogChunks.createdAt));
}
async deleteByAgentId(agentId: string): Promise<void> {
await this.db
.delete(agentLogChunks)

View File

@@ -17,6 +17,13 @@ export interface LogChunkRepository {
findByAgentId(agentId: string): Promise<Pick<AgentLogChunk, 'content' | 'sessionNumber' | 'createdAt'>[]>;
/**
* Batch-fetch chunks for multiple agent IDs in a single query.
* Returns chunks ordered by createdAt ascending.
* agentId field is included so results can be grouped by agent.
*/
findByAgentIds(agentIds: string[]): Promise<{ agentId: string; content: string; sessionNumber: number; createdAt: Date }[]>;
deleteByAgentId(agentId: string): Promise<void>;
getSessionCount(agentId: string): Promise<number>;

View File

@@ -22,8 +22,9 @@ export class EventEmitterBus implements EventBus {
constructor() {
this.emitter = new EventEmitter();
// Allow more listeners for complex systems
this.emitter.setMaxListeners(100);
// SSE subscriptions register per-event-type listeners (30+ types × N clients).
// Listeners are properly cleaned up on disconnect, so disable the warning.
this.emitter.setMaxListeners(0);
}
/**

View File

@@ -46,6 +46,9 @@ function createMocks() {
ensureBranch: vi.fn(),
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged', previousRef: 'abc000' }),
diffBranches: vi.fn().mockResolvedValue(''),
diffBranchesStat: vi.fn().mockResolvedValue([]),
diffFileSingle: vi.fn().mockResolvedValue(''),
getHeadCommitHash: vi.fn().mockResolvedValue('deadbeef00000000000000000000000000000000'),
deleteBranch: vi.fn(),
branchExists: vi.fn().mockResolvedValue(true),
remoteBranchExists: vi.fn().mockResolvedValue(false),

View File

@@ -23,6 +23,7 @@ import type { ConflictResolutionService } from '../coordination/conflict-resolut
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js';
import { createModuleLogger } from '../logger/index.js';
import { phaseMetaCache, fileDiffCache } from '../review/diff-cache.js';
const log = createModuleLogger('execution-orchestrator');
@@ -239,16 +240,24 @@ export class ExecutionOrchestrator {
if (!result.success && result.conflicts) {
log.warn({ taskId, taskBranch, phaseBranch, conflicts: result.conflicts }, 'task merge conflict');
await this.conflictResolutionService.handleConflict(taskId, result.conflicts, {
const conflictTask = await this.conflictResolutionService.handleConflict(taskId, result.conflicts, {
sourceBranch: taskBranch,
targetBranch: phaseBranch,
});
if (conflictTask) {
await this.dispatchManager.queue(conflictTask.id);
log.info({ taskId: conflictTask.id, originalTaskId: taskId }, 'conflict resolution task queued for dispatch');
}
return;
}
log.info({ taskId, taskBranch, phaseBranch, project: project.name }, 'task branch merged into phase branch');
}
// Invalidate diff cache — phase branch HEAD has advanced after merges
phaseMetaCache.invalidateByPrefix(`${phaseId}:`);
fileDiffCache.invalidateByPrefix(`${phaseId}:`);
// Emit task:merged event
const mergedEvent: TaskMergedEvent = {
type: 'task:merged',
@@ -615,8 +624,27 @@ export class ExecutionOrchestrator {
tasksRecovered++;
log.info({ taskId: task.id, agentId: agent?.id }, 'recovered stuck in_progress task (dead agent)');
}
} else if (task.status === 'blocked' && this.agentRepository) {
// Task was blocked by merge conflict after agent had already completed.
// If the agent finished successfully, mark the task completed so the
// phase can progress.
const agent = await this.agentRepository.findByTaskId(task.id);
if (agent && (agent.status === 'idle' || agent.status === 'stopped')) {
await this.taskRepository.update(task.id, { status: 'completed' });
tasksRecovered++;
log.info({ taskId: task.id, agentId: agent.id }, 'recovered blocked task (agent completed, merge conflict)');
}
}
}
// Re-read tasks after recovery updates and check if phase is now fully done
const updatedTasks = await this.taskRepository.findByPhaseId(phase.id);
const allDone = updatedTasks.every((t) => t.status === 'completed');
if (allDone && updatedTasks.length > 0) {
log.info({ phaseId: phase.id }, 'all tasks completed in in_progress phase, triggering phase completion');
await this.handlePhaseAllTasksDone(phase.id);
phasesRecovered++;
}
}
}
}

View File

@@ -6,7 +6,7 @@
* a worktree to be checked out.
*/
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
import type { MergeResult, MergeabilityResult, BranchCommit, FileStatEntry } from './types.js';
export interface BranchManager {
/**
@@ -29,6 +29,27 @@ export interface BranchManager {
*/
diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string>;
/**
* Get per-file metadata for changes between two branches.
* Uses three-dot diff (baseBranch...headBranch) — same divergence model as diffBranches.
* Binary files are included with status 'binary' and additions/deletions both 0.
* Does NOT return hunk content.
*/
diffBranchesStat(repoPath: string, baseBranch: string, headBranch: string): Promise<FileStatEntry[]>;
/**
* Get the raw unified diff for a single file between two branches.
* Uses three-dot diff (baseBranch...headBranch).
* Returns empty string for binary files (caller must detect binary separately).
* filePath must be URL-decoded before being passed here.
*/
diffFileSingle(repoPath: string, baseBranch: string, headBranch: string, filePath: string): Promise<string>;
/**
* Returns the current HEAD commit hash (40-char SHA) for the given branch in the repo.
*/
getHeadCommitHash(repoPath: string, branch: string): Promise<string>;
/**
* Delete a branch. No-op if the branch doesn't exist.
*/

View File

@@ -65,6 +65,69 @@ async function createTestRepoWithRemote(): Promise<{
};
}
/**
* Create a repo pair for testing diff operations.
* Sets up bare + clone with a 'feature' branch that has known changes vs 'main'.
*/
async function createTestRepoForDiff(): Promise<{
clonePath: string;
cleanup: () => Promise<void>;
}> {
const tmpBase = await mkdtemp(path.join(tmpdir(), 'cw-diff-test-'));
const barePath = path.join(tmpBase, 'bare.git');
const workPath = path.join(tmpBase, 'work');
const clonePath = path.join(tmpBase, 'clone');
// Create bare repo
await simpleGit().init([barePath, '--bare']);
// Set up main branch in work dir
await simpleGit().clone(barePath, workPath);
const workGit = simpleGit(workPath);
await workGit.addConfig('user.email', 'test@example.com');
await workGit.addConfig('user.name', 'Test User');
await writeFile(path.join(workPath, 'README.md'), '# README\n');
await writeFile(path.join(workPath, 'to-delete.txt'), 'delete me\n');
await workGit.add(['README.md', 'to-delete.txt']);
await workGit.commit('Initial commit');
await workGit.push('origin', 'main');
// Clone and create feature branch with changes
await simpleGit().clone(barePath, clonePath);
const cloneGit = simpleGit(clonePath);
await cloneGit.addConfig('user.email', 'test@example.com');
await cloneGit.addConfig('user.name', 'Test User');
await cloneGit.checkoutLocalBranch('feature');
// Add new text file
await writeFile(path.join(clonePath, 'added.txt'), 'new content\n');
await cloneGit.add('added.txt');
// Modify existing file
await writeFile(path.join(clonePath, 'README.md'), '# README\n\nModified content\n');
await cloneGit.add('README.md');
// Delete a file
await cloneGit.rm(['to-delete.txt']);
// Add binary file
await writeFile(path.join(clonePath, 'image.bin'), Buffer.alloc(16));
await cloneGit.add('image.bin');
// Add file with space in name
await writeFile(path.join(clonePath, 'has space.txt'), 'content\n');
await cloneGit.add('has space.txt');
await cloneGit.commit('Feature branch changes');
return {
clonePath,
cleanup: async () => {
await rm(tmpBase, { recursive: true, force: true });
},
};
}
describe('SimpleGitBranchManager', () => {
let clonePath: string;
let cleanup: () => Promise<void>;
@@ -108,3 +171,80 @@ describe('SimpleGitBranchManager', () => {
});
});
});
describe('SimpleGitBranchManager - diffBranchesStat and diffFileSingle', () => {
let clonePath: string;
let cleanup: () => Promise<void>;
let branchManager: SimpleGitBranchManager;
beforeEach(async () => {
const setup = await createTestRepoForDiff();
clonePath = setup.clonePath;
cleanup = setup.cleanup;
branchManager = new SimpleGitBranchManager();
});
afterEach(async () => {
await cleanup();
});
describe('diffBranchesStat', () => {
it('returns correct entries for added, modified, and deleted text files', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature');
const added = entries.find(e => e.path === 'added.txt');
const modified = entries.find(e => e.path === 'README.md');
const deleted = entries.find(e => e.path === 'to-delete.txt');
expect(added?.status).toBe('added');
expect(added?.additions).toBeGreaterThan(0);
expect(modified?.status).toBe('modified');
expect(deleted?.status).toBe('deleted');
expect(deleted?.deletions).toBeGreaterThan(0);
});
it('marks binary files as status=binary with additions=0, deletions=0', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature');
const binary = entries.find(e => e.path === 'image.bin');
expect(binary?.status).toBe('binary');
expect(binary?.additions).toBe(0);
expect(binary?.deletions).toBe(0);
});
it('returns empty array when there are no changes', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'main');
expect(entries).toEqual([]);
});
it('handles files with spaces in their names', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature');
const spaced = entries.find(e => e.path === 'has space.txt');
expect(spaced).toBeDefined();
expect(spaced?.status).toBe('added');
});
});
describe('diffFileSingle', () => {
it('returns unified diff containing addition hunks for an added file', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'added.txt');
expect(diff).toContain('+');
expect(diff).toContain('added.txt');
});
it('returns unified diff with removal hunks for a deleted file', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'to-delete.txt');
expect(diff).toContain('-');
expect(diff).toContain('to-delete.txt');
});
it('returns string for a binary file', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'image.bin');
// git diff returns empty or a "Binary files differ" line — no hunk content
expect(typeof diff).toBe('string');
});
it('handles file paths with spaces', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'has space.txt');
expect(diff).toContain('has space.txt');
});
});
});

View File

@@ -11,11 +11,31 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js';
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
import type { MergeResult, MergeabilityResult, BranchCommit, FileStatEntry } from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('branch-manager');
/**
* Normalize a numstat path to the new path for rename entries.
* Handles patterns like:
* - "{old.txt => new.txt}" → "new.txt"
* - "dir/{old.txt => new.txt}" → "dir/new.txt"
* - "old_dir/file.txt => new_dir/file.txt" → "new_dir/file.txt"
*/
function normalizeNumstatPath(pathStr: string): string {
const braceMatch = pathStr.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceMatch) {
const [, prefix, , newPart, suffix] = braceMatch;
return `${prefix}${newPart}${suffix}`;
}
const arrowMatch = pathStr.match(/^.* => (.+)$/);
if (arrowMatch) {
return arrowMatch[1];
}
return pathStr;
}
export class SimpleGitBranchManager implements BranchManager {
async ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void> {
const git = simpleGit(repoPath);
@@ -97,6 +117,97 @@ export class SimpleGitBranchManager implements BranchManager {
return diff;
}
async diffBranchesStat(repoPath: string, baseBranch: string, headBranch: string): Promise<FileStatEntry[]> {
const git = simpleGit(repoPath);
const range = `${baseBranch}...${headBranch}`;
const [nameStatusRaw, numStatRaw] = await Promise.all([
git.raw(['diff', '--name-status', range]),
git.raw(['diff', '--numstat', range]),
]);
if (!nameStatusRaw.trim()) return [];
// Parse numstat: "<additions>\t<deletions>\t<path>"
// Binary files: "-\t-\t<path>"
const numStatMap = new Map<string, { additions: number; deletions: number; binary: boolean }>();
for (const line of numStatRaw.split('\n')) {
if (!line.trim()) continue;
const tabIdx1 = line.indexOf('\t');
const tabIdx2 = line.indexOf('\t', tabIdx1 + 1);
if (tabIdx1 === -1 || tabIdx2 === -1) continue;
const addStr = line.slice(0, tabIdx1);
const delStr = line.slice(tabIdx1 + 1, tabIdx2);
const pathStr = line.slice(tabIdx2 + 1);
const binary = addStr === '-' && delStr === '-';
// Normalize rename paths like "{old => new}" or "dir/{old => new}/file" to new path
const newPath = normalizeNumstatPath(pathStr);
numStatMap.set(newPath, {
additions: binary ? 0 : parseInt(addStr, 10),
deletions: binary ? 0 : parseInt(delStr, 10),
binary,
});
}
// Parse name-status: "<status>\t<path>" or "<Rxx>\t<oldPath>\t<newPath>"
const entries: FileStatEntry[] = [];
for (const line of nameStatusRaw.split('\n')) {
if (!line.trim()) continue;
const parts = line.split('\t');
if (parts.length < 2) continue;
const statusCode = parts[0];
let status: FileStatEntry['status'];
let filePath: string;
let oldPath: string | undefined;
if (statusCode.startsWith('R')) {
status = 'renamed';
oldPath = parts[1];
filePath = parts[2];
} else if (statusCode === 'A') {
status = 'added';
filePath = parts[1];
} else if (statusCode === 'M') {
status = 'modified';
filePath = parts[1];
} else if (statusCode === 'D') {
status = 'deleted';
filePath = parts[1];
} else {
status = 'modified';
filePath = parts[1];
}
const numStat = numStatMap.get(filePath);
if (numStat?.binary) {
status = 'binary';
}
const entry: FileStatEntry = {
path: filePath,
status,
additions: numStat?.additions ?? 0,
deletions: numStat?.deletions ?? 0,
};
if (oldPath !== undefined) entry.oldPath = oldPath;
entries.push(entry);
}
return entries;
}
async diffFileSingle(repoPath: string, baseBranch: string, headBranch: string, filePath: string): Promise<string> {
const git = simpleGit(repoPath);
return git.diff([`${baseBranch}...${headBranch}`, '--', filePath]);
}
async getHeadCommitHash(repoPath: string, branch: string): Promise<string> {
const git = simpleGit(repoPath);
const result = await git.raw(['rev-parse', branch]);
return result.trim();
}
async deleteBranch(repoPath: string, branch: string): Promise<void> {
const git = simpleGit(repoPath);
const exists = await this.branchExists(repoPath, branch);

View File

@@ -100,6 +100,29 @@ export interface BranchCommit {
deletions: number;
}
// =============================================================================
// File Stat Entry (per-file diff metadata)
// =============================================================================
/**
* Metadata for a single file changed between two branches.
* No hunk content — only path, status, and line-count statistics.
*/
export interface FileStatEntry {
/** New path (or old path for deletions) */
path: string;
/** Only set for renames — the path before the rename */
oldPath?: string;
/** Nature of the change */
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary';
/** Lines added (0 for binary files) */
additions: number;
/** Lines deleted (0 for binary files) */
deletions: number;
/** Which project clone this file belongs to (populated by callers in multi-project scenarios) */
projectId?: string;
}
// =============================================================================
// WorktreeManager Port Interface
// =============================================================================

View File

@@ -0,0 +1,76 @@
/**
* Unit tests for DiffCache class.
*
* Tests TTL expiry, prefix invalidation, and env-var TTL configuration.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { DiffCache } from './diff-cache.js';
afterEach(() => {
vi.restoreAllMocks();
});
describe('DiffCache', () => {
it('returns undefined for a key that was never set', () => {
const cache = new DiffCache<string>(5000);
expect(cache.get('nonexistent')).toBeUndefined();
});
it('returns value when entry has not expired', () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);
const cache = new DiffCache<string>(5000);
cache.set('key', 'value');
vi.spyOn(Date, 'now').mockReturnValue(5999); // 4999ms elapsed, TTL=5000
expect(cache.get('key')).toBe('value');
});
it('returns undefined and deletes the entry when TTL has elapsed', () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);
const cache = new DiffCache<string>(5000);
cache.set('key', 'value');
vi.spyOn(Date, 'now').mockReturnValue(6001); // 5001ms elapsed, TTL=5000
expect(cache.get('key')).toBeUndefined();
// Verify the key is no longer stored (second get also returns undefined)
vi.spyOn(Date, 'now').mockReturnValue(6001);
expect(cache.get('key')).toBeUndefined();
});
it('overwrites an existing entry and resets its TTL', () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);
const cache = new DiffCache<string>(5000);
cache.set('key', 'first');
vi.spyOn(Date, 'now').mockReturnValue(4000); // overwrite before expiry
cache.set('key', 'second');
vi.spyOn(Date, 'now').mockReturnValue(8999); // 4999ms after overwrite, TTL=5000
expect(cache.get('key')).toBe('second');
vi.spyOn(Date, 'now').mockReturnValue(9001); // 5001ms after overwrite
expect(cache.get('key')).toBeUndefined();
});
it('invalidateByPrefix removes all matching keys and preserves others', () => {
const cache = new DiffCache<string>(60_000);
cache.set('phase-1:abc', 'a');
cache.set('phase-1:abc:file.ts', 'b');
cache.set('phase-2:xyz', 'c');
cache.invalidateByPrefix('phase-1:');
expect(cache.get('phase-1:abc')).toBeUndefined();
expect(cache.get('phase-1:abc:file.ts')).toBeUndefined();
expect(cache.get('phase-2:xyz')).toBe('c');
});
it('singleton instances use REVIEW_DIFF_CACHE_TTL_MS env var for TTL', async () => {
vi.resetModules();
vi.stubEnv('REVIEW_DIFF_CACHE_TTL_MS', '1000');
const { phaseMetaCache } = await import('./diff-cache.js');
vi.spyOn(Date, 'now').mockReturnValue(0);
phaseMetaCache.set('key', {} as any);
vi.spyOn(Date, 'now').mockReturnValue(999);
expect(phaseMetaCache.get('key')).toBeDefined();
vi.spyOn(Date, 'now').mockReturnValue(1001);
expect(phaseMetaCache.get('key')).toBeUndefined();
vi.unstubAllEnvs();
});
});

View File

@@ -0,0 +1,70 @@
/**
* DiffCache — in-memory TTL cache for git diff results.
*
* Keyed by `phaseId:headHash` (or `phaseId:headHash:filePath` for per-file diffs).
* TTL defaults to 5 minutes, configurable via REVIEW_DIFF_CACHE_TTL_MS env var.
* Prefix-based invalidation clears all entries for a phase when a new commit lands.
*/
import type { FileStatEntry } from '../git/types.js';
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
export class DiffCache<T> {
private store = new Map<string, CacheEntry<T>>();
private ttlMs: number;
constructor(ttlMs: number) {
this.ttlMs = ttlMs;
}
get(key: string): T | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
set(key: string, value: T): void {
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
invalidateByPrefix(prefix: string): void {
for (const key of this.store.keys()) {
if (key.startsWith(prefix)) this.store.delete(key);
}
}
}
// ---------------------------------------------------------------------------
// Response shapes (mirror the return types of getPhaseReviewDiff / getFileDiff)
// ---------------------------------------------------------------------------
export interface PhaseMetaResponse {
phaseName: string;
sourceBranch: string;
targetBranch: string;
files: FileStatEntry[];
totalAdditions: number;
totalDeletions: number;
}
export interface FileDiffResponse {
binary: boolean;
rawDiff: string;
}
// ---------------------------------------------------------------------------
// Singleton instances — TTL is read once at module load time
// ---------------------------------------------------------------------------
const TTL = parseInt(process.env.REVIEW_DIFF_CACHE_TTL_MS ?? '300000', 10);
export const phaseMetaCache = new DiffCache<PhaseMetaResponse>(TTL);
export const fileDiffCache = new DiffCache<FileDiffResponse>(TTL);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -357,9 +357,10 @@ describe('E2E Edge Cases', () => {
await harness.coordinationManager.queueMerge(taskAId);
await harness.coordinationManager.processMerges('main');
// Verify: original task is now blocked
// Verify: original task is NOT blocked (stays completed — the pending
// resolution task prevents premature phase completion)
const originalTask = await harness.taskRepository.findById(taskAId);
expect(originalTask?.status).toBe('blocked');
expect(originalTask?.status).toBe('completed');
// Verify: task:queued event emitted for conflict resolution task
const queuedEvents = harness.getEventsByType('task:queued');

View File

@@ -85,12 +85,10 @@ describe('E2E Extended Scenarios', () => {
expect(conflictPayload.taskId).toBe(taskAId);
expect(conflictPayload.conflictingFiles).toEqual(['src/shared.ts', 'src/types.ts']);
// Verify: original task marked blocked
// Verify: original task is NOT blocked (stays completed — the pending
// resolution task prevents premature phase completion)
const originalTask = await harness.taskRepository.findById(taskAId);
expect(originalTask?.status).toBe('blocked');
// Note: CoordinationManager.handleConflict updates task status to blocked
// but does not emit task:blocked event (that's emitted by DispatchManager.blockTask)
expect(originalTask?.status).toBe('completed');
// Verify: task:queued event emitted for resolution task
const queuedEvents = harness.getEventsByType('task:queued');

View File

@@ -0,0 +1,256 @@
/**
* Integration tests for getPhaseReviewDiff and getFileDiff tRPC procedures.
*
* Uses real git repos on disk (no cassettes) + an in-memory SQLite database.
* No network calls — purely local git operations.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { simpleGit } from 'simple-git';
import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js';
import { phaseProcedures } from '../../trpc/routers/phase.js';
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
import {
DrizzleInitiativeRepository,
DrizzlePhaseRepository,
DrizzleProjectRepository,
} from '../../db/repositories/drizzle/index.js';
import { SimpleGitBranchManager } from '../../git/simple-git-branch-manager.js';
import { getProjectCloneDir } from '../../git/project-clones.js';
import type { TRPCContext } from '../../trpc/context.js';
// ============================================================================
// Test router & caller factory
// ============================================================================
const testRouter = router({
...phaseProcedures(publicProcedure),
});
const createCaller = createCallerFactory(testRouter);
// ============================================================================
// Shared test state (set up once for the whole suite)
// ============================================================================
let workspaceRoot: string;
let cleanup: () => Promise<void>;
let phaseId: string;
let pendingPhaseId: string;
/**
* Build the test git repo with the required branches and files.
*
* Repo layout on the phase branch vs main:
* file1.txt through file5.txt — added (1020 lines each)
* photo.bin — binary file (Buffer.alloc(32))
* gone.txt — deleted (existed on main)
* has space.txt — added (contains text)
*/
async function setupGitRepo(clonePath: string): Promise<void> {
await mkdir(clonePath, { recursive: true });
const git = simpleGit(clonePath);
await git.init();
await git.addConfig('user.email', 'test@example.com');
await git.addConfig('user.name', 'Test');
// Commit gone.txt on main so it can be deleted on the phase branch
await writeFile(path.join(clonePath, 'gone.txt'), Array.from({ length: 5 }, (_, i) => `line ${i + 1}`).join('\n') + '\n');
await git.add('gone.txt');
await git.commit('Initial commit on main');
// Create phase branch from main
// phaseBranchName('main', 'test-phase') => 'main-phase-test-phase'
await git.checkoutLocalBranch('main-phase-test-phase');
// Add 5 text files
for (let i = 1; i <= 5; i++) {
const lines = Array.from({ length: 15 }, (_, j) => `Line ${j + 1} in file${i}`).join('\n') + '\n';
await writeFile(path.join(clonePath, `file${i}.txt`), lines);
}
await git.add(['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt', 'file5.txt']);
// Add binary file
await writeFile(path.join(clonePath, 'photo.bin'), Buffer.alloc(32));
await git.add('photo.bin');
// Delete gone.txt
await git.rm(['gone.txt']);
// Add file with space in name
await writeFile(path.join(clonePath, 'has space.txt'), 'content with spaces\n');
await git.add('has space.txt');
await git.commit('Phase branch changes');
}
beforeAll(async () => {
// Create workspace root temp dir
workspaceRoot = await mkdtemp(path.join(tmpdir(), 'cw-phase-diff-test-'));
cleanup = async () => {
await rm(workspaceRoot, { recursive: true, force: true });
};
// Set up in-memory database
const db = createTestDatabase();
const initiativeRepo = new DrizzleInitiativeRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const projectRepo = new DrizzleProjectRepository(db);
// Create initiative with branch='main'
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
branch: 'main',
});
// Create project — we'll set up the git repo at the expected clone path
const project = await projectRepo.create({
name: 'test-repo',
url: 'file:///dev/null', // won't be cloned — we create the repo directly
defaultBranch: 'main',
});
// Link project to initiative
await projectRepo.addProjectToInitiative(initiative.id, project.id);
// Set up git repo at the expected clone path
const relPath = getProjectCloneDir(project.name, project.id);
const clonePath = path.join(workspaceRoot, relPath);
await setupGitRepo(clonePath);
// Create reviewable phase (pending_review)
const phase = await phaseRepo.create({
initiativeId: initiative.id,
name: 'test-phase',
status: 'pending_review',
});
phaseId = phase.id;
// Create a non-reviewable phase (pending) for error test
const pendingPhase = await phaseRepo.create({
initiativeId: initiative.id,
name: 'pending-phase',
status: 'pending',
});
pendingPhaseId = pendingPhase.id;
// Store db and repos so the caller can use them
// (stored in module-level vars to be accessed in test helper)
Object.assign(sharedCtx, {
initiativeRepository: initiativeRepo,
phaseRepository: phaseRepo,
projectRepository: projectRepo,
branchManager: new SimpleGitBranchManager(),
workspaceRoot,
});
});
afterAll(async () => {
await cleanup?.();
});
// ============================================================================
// Shared context (filled in beforeAll)
// ============================================================================
const sharedCtx: Partial<TRPCContext> = {
eventBus: { emit: () => {}, on: () => {}, off: () => {}, once: () => {} } as any,
serverStartedAt: null,
processCount: 0,
};
function getCaller() {
return createCaller(sharedCtx as TRPCContext);
}
// ============================================================================
// Tests: getPhaseReviewDiff
// ============================================================================
describe('getPhaseReviewDiff', () => {
it('returns files array with correct metadata and no rawDiff field', async () => {
const start = Date.now();
const result = await getCaller().getPhaseReviewDiff({ phaseId });
const elapsed = Date.now() - start;
expect(result).not.toHaveProperty('rawDiff');
expect(result.files).toBeInstanceOf(Array);
// 5 text + 1 binary + 1 deleted + 1 spaced = 8 files
expect(result.files.length).toBeGreaterThanOrEqual(7);
expect(elapsed).toBeLessThan(3000);
});
it('includes binary file with status=binary, additions=0, deletions=0', async () => {
const result = await getCaller().getPhaseReviewDiff({ phaseId });
const bin = result.files.find((f) => f.path === 'photo.bin');
expect(bin?.status).toBe('binary');
expect(bin?.additions).toBe(0);
expect(bin?.deletions).toBe(0);
});
it('includes deleted file with status=deleted and nonzero deletions', async () => {
const result = await getCaller().getPhaseReviewDiff({ phaseId });
const del = result.files.find((f) => f.path === 'gone.txt');
expect(del?.status).toBe('deleted');
expect(del?.deletions).toBeGreaterThan(0);
});
it('computes totalAdditions and totalDeletions as sums over files', async () => {
const result = await getCaller().getPhaseReviewDiff({ phaseId });
const sumAdd = result.files.reduce((s, f) => s + f.additions, 0);
const sumDel = result.files.reduce((s, f) => s + f.deletions, 0);
expect(result.totalAdditions).toBe(sumAdd);
expect(result.totalDeletions).toBe(sumDel);
});
it('throws NOT_FOUND for unknown phaseId', async () => {
const err = await getCaller().getPhaseReviewDiff({ phaseId: 'nonexistent' }).catch((e) => e);
expect(err.code).toBe('NOT_FOUND');
});
it('throws BAD_REQUEST for phase not in reviewable status', async () => {
const err = await getCaller().getPhaseReviewDiff({ phaseId: pendingPhaseId }).catch((e) => e);
expect(err.code).toBe('BAD_REQUEST');
});
});
// ============================================================================
// Tests: getFileDiff
// ============================================================================
describe('getFileDiff', () => {
it('returns rawDiff with unified diff for a normal file, under 1 second', async () => {
const start = Date.now();
const result = await getCaller().getFileDiff({ phaseId, filePath: 'file1.txt' });
const elapsed = Date.now() - start;
expect(result.binary).toBe(false);
expect(result.rawDiff).toContain('+');
expect(elapsed).toBeLessThan(1000);
});
it('returns binary=true and rawDiff="" for binary file', async () => {
const result = await getCaller().getFileDiff({ phaseId, filePath: 'photo.bin' });
expect(result.binary).toBe(true);
expect(result.rawDiff).toBe('');
});
it('returns removal hunks for a deleted file', async () => {
const result = await getCaller().getFileDiff({ phaseId, filePath: 'gone.txt' });
expect(result.binary).toBe(false);
expect(result.rawDiff).toContain('-');
});
it('handles URL-encoded file path with space', async () => {
const result = await getCaller().getFileDiff({
phaseId,
filePath: encodeURIComponent('has space.txt'),
});
expect(result.binary).toBe(false);
expect(result.rawDiff).toContain('has space.txt');
});
});

View File

@@ -88,6 +88,24 @@ class InMemoryConversationRepository implements ConversationRepository {
return updated;
}
async countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]> {
if (agentIds.length === 0) return [];
const counts = new Map<string, number>();
for (const conv of this.store.values()) {
if (agentIds.includes(conv.fromAgentId)) {
counts.set(conv.fromAgentId, (counts.get(conv.fromAgentId) ?? 0) + 1);
}
}
return [...counts.entries()].map(([agentId, count]) => ({ agentId, count }));
}
async findByFromAgentId(agentId: string): Promise<Conversation[]> {
return [...this.store.values()]
.filter((c) => c.fromAgentId === agentId)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, 200);
}
/** Test helper — return all conversations */
getAll(): Conversation[] {
return [...this.store.values()];

View File

@@ -205,7 +205,7 @@ export async function createRealProviderHarness(
const accountRepository = new DrizzleAccountRepository(db);
const initiativeRepository = new DrizzleInitiativeRepository(db);
// Create event bus with capture (parent class already sets maxListeners to 100)
// Create event bus with capture (parent class disables maxListeners warning)
const eventBus = new CapturingEventBus();
// Create REAL agent manager (not mock!)

View File

@@ -108,6 +108,7 @@ describe('getHeadquartersDashboard', () => {
expect(result.pendingReviewInitiatives).toEqual([]);
expect(result.pendingReviewPhases).toEqual([]);
expect(result.planningInitiatives).toEqual([]);
expect(result.resolvingConflicts).toEqual([]);
expect(result.blockedPhases).toEqual([]);
});
@@ -291,6 +292,115 @@ describe('getHeadquartersDashboard', () => {
expect(item.lastMessage).toBeNull();
});
it('resolvingConflicts — running conflict agent appears', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const initiativeRepo = ctx.initiativeRepository!;
const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' });
agents.addAgent({
id: 'agent-conflict',
name: 'conflict-1234567890',
status: 'running',
initiativeId: initiative.id,
userDismissedAt: null,
updatedAt: new Date('2025-06-01T12:00:00Z'),
});
const caller = createCaller(ctx);
const result = await caller.getHeadquartersDashboard();
expect(result.resolvingConflicts).toHaveLength(1);
const item = result.resolvingConflicts[0];
expect(item.initiativeId).toBe(initiative.id);
expect(item.initiativeName).toBe('Conflicting Init');
expect(item.agentId).toBe('agent-conflict');
expect(item.agentName).toBe('conflict-1234567890');
expect(item.agentStatus).toBe('running');
});
it('resolvingConflicts — waiting_for_input conflict agent appears', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const initiativeRepo = ctx.initiativeRepository!;
const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' });
agents.addAgent({
id: 'agent-conflict',
name: 'conflict-1234567890',
status: 'waiting_for_input',
initiativeId: initiative.id,
userDismissedAt: null,
updatedAt: new Date('2025-06-01T12:00:00Z'),
});
const caller = createCaller(ctx);
const result = await caller.getHeadquartersDashboard();
expect(result.resolvingConflicts).toHaveLength(1);
expect(result.resolvingConflicts[0].agentStatus).toBe('waiting_for_input');
});
it('resolvingConflicts — dismissed conflict agent is excluded', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const initiativeRepo = ctx.initiativeRepository!;
const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' });
agents.addAgent({
id: 'agent-conflict',
name: 'conflict-1234567890',
status: 'running',
initiativeId: initiative.id,
userDismissedAt: new Date(),
});
const caller = createCaller(ctx);
const result = await caller.getHeadquartersDashboard();
expect(result.resolvingConflicts).toEqual([]);
});
it('resolvingConflicts — idle conflict agent is excluded', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const initiativeRepo = ctx.initiativeRepository!;
const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' });
agents.addAgent({
id: 'agent-conflict',
name: 'conflict-1234567890',
status: 'idle',
initiativeId: initiative.id,
userDismissedAt: null,
});
const caller = createCaller(ctx);
const result = await caller.getHeadquartersDashboard();
expect(result.resolvingConflicts).toEqual([]);
});
it('resolvingConflicts — non-conflict agent is excluded', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const initiativeRepo = ctx.initiativeRepository!;
const initiative = await initiativeRepo.create({ name: 'Some Init', status: 'active' });
agents.addAgent({
id: 'agent-regular',
name: 'regular-agent',
status: 'running',
initiativeId: initiative.id,
userDismissedAt: null,
});
const caller = createCaller(ctx);
const result = await caller.getHeadquartersDashboard();
expect(result.resolvingConflicts).toEqual([]);
});
it('ordering — waitingForInput sorted oldest first', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);

View File

@@ -0,0 +1,476 @@
/**
* Unit tests for Radar tRPC procedures.
*
* Tests listForRadar, getCompactionEvents, getSubagentSpawns,
* getQuestionsAsked, and conversation.getByFromAgent.
*
* Uses in-memory Drizzle DB + inline MockAgentManager for isolation.
*/
import { describe, it, expect } from 'vitest';
import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js';
import { agentProcedures } from '../../trpc/routers/agent.js';
import { conversationProcedures } from '../../trpc/routers/conversation.js';
import type { TRPCContext } from '../../trpc/context.js';
import type { AgentManager, AgentInfo, PendingQuestions } from '../../agent/types.js';
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
import {
DrizzleAgentRepository,
DrizzleLogChunkRepository,
DrizzleConversationRepository,
DrizzleInitiativeRepository,
DrizzleTaskRepository,
} from '../../db/repositories/drizzle/index.js';
// =============================================================================
// MockAgentManager
// =============================================================================
class MockAgentManager implements AgentManager {
private agents: AgentInfo[] = [];
private questions: Map<string, PendingQuestions> = new Map();
addAgent(info: Partial<AgentInfo> & Pick<AgentInfo, 'id' | 'name' | 'status'>): void {
this.agents.push({
taskId: null,
initiativeId: null,
sessionId: null,
worktreeId: info.id,
mode: 'execute',
provider: 'claude',
accountId: null,
createdAt: new Date(),
updatedAt: new Date(),
userDismissedAt: null,
exitCode: null,
prompt: null,
...info,
});
}
setQuestions(agentId: string, questions: PendingQuestions): void {
this.questions.set(agentId, questions);
}
async list(): Promise<AgentInfo[]> {
return [...this.agents];
}
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
return this.questions.get(agentId) ?? null;
}
async spawn(): Promise<AgentInfo> { throw new Error('Not implemented'); }
async stop(): Promise<void> { throw new Error('Not implemented'); }
async get(): Promise<AgentInfo | null> { return null; }
async getByName(): Promise<AgentInfo | null> { return null; }
async resume(): Promise<void> { throw new Error('Not implemented'); }
async getResult() { return null; }
async delete(): Promise<void> { throw new Error('Not implemented'); }
async dismiss(): Promise<void> { throw new Error('Not implemented'); }
async resumeForConversation(): Promise<boolean> { return false; }
}
// =============================================================================
// Test routers
// =============================================================================
const agentRouter = router({ ...agentProcedures(publicProcedure) });
const conversationRouter = router({ ...conversationProcedures(publicProcedure) });
const createAgentCaller = createCallerFactory(agentRouter);
const createConversationCaller = createCallerFactory(conversationRouter);
// =============================================================================
// Helpers
// =============================================================================
function makeCtx(agentManager: MockAgentManager): TRPCContext {
const db = createTestDatabase();
return {
eventBus: { emit: () => {}, on: () => {}, off: () => {} } as unknown as TRPCContext['eventBus'],
serverStartedAt: null,
processCount: 0,
agentManager,
logChunkRepository: new DrizzleLogChunkRepository(db),
conversationRepository: new DrizzleConversationRepository(db),
initiativeRepository: new DrizzleInitiativeRepository(db),
taskRepository: new DrizzleTaskRepository(db),
// Expose DB-backed agent repo seeder via a non-context helper
_agentRepository: new DrizzleAgentRepository(db),
} as unknown as TRPCContext & { _agentRepository: DrizzleAgentRepository };
}
// Typed helper to access seeder repos
function getRepos(ctx: ReturnType<typeof makeCtx>) {
const c = ctx as unknown as {
_agentRepository: DrizzleAgentRepository;
logChunkRepository: DrizzleLogChunkRepository;
conversationRepository: DrizzleConversationRepository;
initiativeRepository: DrizzleInitiativeRepository;
taskRepository: DrizzleTaskRepository;
};
return {
agentRepo: c._agentRepository,
logChunkRepo: c.logChunkRepository,
convRepo: c.conversationRepository,
initiativeRepo: c.initiativeRepository,
taskRepo: c.taskRepository,
};
}
// =============================================================================
// Tests: agent.listForRadar
// =============================================================================
describe('agent.listForRadar', () => {
it('timeRange=24h — excludes agents older than 24h', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const oldDate = new Date(Date.now() - 48 * 3600_000);
const recentDate = new Date(Date.now() - 12 * 3600_000);
agents.addAgent({ id: 'agent-old', name: 'old-agent', status: 'stopped', createdAt: oldDate });
agents.addAgent({ id: 'agent-recent', name: 'recent-agent', status: 'running', createdAt: recentDate });
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: '24h' });
expect(result.map(r => r.id)).not.toContain('agent-old');
expect(result.map(r => r.id)).toContain('agent-recent');
});
it('status=running filter — only running agents returned', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const now = new Date();
agents.addAgent({ id: 'agent-running', name: 'running-agent', status: 'running', createdAt: now });
agents.addAgent({ id: 'agent-stopped', name: 'stopped-agent', status: 'stopped', createdAt: now });
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all', status: 'running' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('agent-running');
expect(result[0].status).toBe('running');
});
it('status=completed filter — maps to stopped in DB', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const now = new Date();
agents.addAgent({ id: 'agent-stopped', name: 'stopped-agent', status: 'stopped', createdAt: now });
agents.addAgent({ id: 'agent-running', name: 'running-agent', status: 'running', createdAt: now });
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all', status: 'completed' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('agent-stopped');
});
it('mode=execute filter — only execute agents returned', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const now = new Date();
agents.addAgent({ id: 'agent-exec', name: 'exec-agent', status: 'running', mode: 'execute', createdAt: now });
agents.addAgent({ id: 'agent-plan', name: 'plan-agent', status: 'running', mode: 'plan', createdAt: now });
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all', mode: 'execute' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('agent-exec');
});
it('computes messagesCount from conversations table', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { agentRepo, convRepo } = getRepos(ctx);
const now = new Date();
// Seed in DB (needed for FK on conversations)
const fromAgent = await agentRepo.create({ name: 'from-agent', worktreeId: 'wt-from', status: 'running' });
const toAgent = await agentRepo.create({ name: 'to-agent', worktreeId: 'wt-to', status: 'running' });
// Seed in MockAgentManager for agentManager.list()
agents.addAgent({ id: fromAgent.id, name: fromAgent.name, status: 'running', createdAt: now });
agents.addAgent({ id: toAgent.id, name: toAgent.name, status: 'running', createdAt: now });
// Create 3 conversations from fromAgent to toAgent
await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q1' });
await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q2' });
await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q3' });
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all' });
const fromRow = result.find(r => r.id === fromAgent.id);
expect(fromRow).toBeDefined();
expect(fromRow!.messagesCount).toBe(3);
const toRow = result.find(r => r.id === toAgent.id);
expect(toRow!.messagesCount).toBe(0);
});
it('computes subagentsCount from log chunks', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
const now = new Date();
agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now });
await logChunkRepo.insertChunk({
agentId: 'agent-1',
agentName: 'agent-one',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'Agent', input: { description: 'do stuff', prompt: 'some prompt' } }),
});
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all' });
const row = result.find(r => r.id === 'agent-1');
expect(row).toBeDefined();
expect(row!.subagentsCount).toBe(1);
expect(row!.questionsCount).toBe(0);
expect(row!.compactionsCount).toBe(0);
});
it('computes compactionsCount from log chunks', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
const now = new Date();
agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now });
await logChunkRepo.insertChunk({
agentId: 'agent-1',
agentName: 'agent-one',
sessionNumber: 1,
content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }),
});
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all' });
const row = result.find(r => r.id === 'agent-1');
expect(row).toBeDefined();
expect(row!.compactionsCount).toBe(1);
expect(row!.subagentsCount).toBe(0);
expect(row!.questionsCount).toBe(0);
});
it('computes questionsCount from log chunks — sums questions array length', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
const now = new Date();
agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now });
await logChunkRepo.insertChunk({
agentId: 'agent-1',
agentName: 'agent-one',
sessionNumber: 1,
content: JSON.stringify({
type: 'tool_use',
name: 'AskUserQuestion',
input: {
questions: [
{ question: 'First?', header: 'H1', options: [] },
{ question: 'Second?', header: 'H2', options: [] },
],
},
}),
});
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all' });
const row = result.find(r => r.id === 'agent-1');
expect(row).toBeDefined();
expect(row!.questionsCount).toBe(2);
expect(row!.subagentsCount).toBe(0);
expect(row!.compactionsCount).toBe(0);
});
it('handles malformed JSON in log chunks without throwing', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
const now = new Date();
agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now });
await logChunkRepo.insertChunk({
agentId: 'agent-1',
agentName: 'agent-one',
sessionNumber: 1,
content: 'not valid json {{{',
});
const caller = createAgentCaller(ctx);
// Should not throw
const result = await caller.listForRadar({ timeRange: 'all' });
const row = result.find(r => r.id === 'agent-1');
expect(row).toBeDefined();
expect(row!.questionsCount).toBe(0);
expect(row!.subagentsCount).toBe(0);
expect(row!.compactionsCount).toBe(0);
});
});
// =============================================================================
// Tests: agent.getCompactionEvents
// =============================================================================
describe('agent.getCompactionEvents', () => {
it('returns compaction events sorted ascending, capped at 200', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
// Seed 201 compaction chunks
for (let i = 0; i < 201; i++) {
await logChunkRepo.insertChunk({
agentId: 'agent-compact',
agentName: 'compact-agent',
sessionNumber: i + 1,
content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }),
});
}
const caller = createAgentCaller(ctx);
const result = await caller.getCompactionEvents({ agentId: 'agent-compact' });
expect(result).toHaveLength(200);
// Each result has correct shape
expect(result[0]).toHaveProperty('timestamp');
expect(result[0]).toHaveProperty('sessionNumber');
expect(typeof result[0].timestamp).toBe('string');
expect(typeof result[0].sessionNumber).toBe('number');
// Sorted ascending — sessionNumber of first should be lower than last
expect(result[0].sessionNumber).toBeLessThan(result[199].sessionNumber);
});
});
// =============================================================================
// Tests: agent.getSubagentSpawns
// =============================================================================
describe('agent.getSubagentSpawns', () => {
it('returns spawns with correct promptPreview (first 200 chars)', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
const fullPrompt = 'x'.repeat(300);
await logChunkRepo.insertChunk({
agentId: 'agent-spawn',
agentName: 'spawn-agent',
sessionNumber: 1,
content: JSON.stringify({
type: 'tool_use',
name: 'Agent',
input: { description: 'my subagent', prompt: fullPrompt },
}),
});
const caller = createAgentCaller(ctx);
const result = await caller.getSubagentSpawns({ agentId: 'agent-spawn' });
expect(result).toHaveLength(1);
expect(result[0].promptPreview).toHaveLength(200);
expect(result[0].fullPrompt).toHaveLength(300);
expect(result[0].description).toBe('my subagent');
expect(typeof result[0].timestamp).toBe('string');
});
});
// =============================================================================
// Tests: agent.getQuestionsAsked
// =============================================================================
describe('agent.getQuestionsAsked', () => {
it('returns questions arrays correctly', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
await logChunkRepo.insertChunk({
agentId: 'agent-q',
agentName: 'question-agent',
sessionNumber: 1,
content: JSON.stringify({
type: 'tool_use',
name: 'AskUserQuestion',
input: {
questions: [
{ question: 'Which way?', header: 'Direction', options: [{ label: 'Left', description: 'Go left' }] },
{ question: 'How fast?', header: 'Speed', options: [{ label: 'Fast', description: 'Go fast' }] },
],
},
}),
});
const caller = createAgentCaller(ctx);
const result = await caller.getQuestionsAsked({ agentId: 'agent-q' });
expect(result).toHaveLength(1);
expect(result[0].questions).toHaveLength(2);
expect(result[0].questions[0].question).toBe('Which way?');
expect(result[0].questions[0].header).toBe('Direction');
expect(result[0].questions[0].options).toHaveLength(1);
expect(result[0].questions[0].options[0].label).toBe('Left');
expect(result[0].questions[1].question).toBe('How fast?');
expect(typeof result[0].timestamp).toBe('string');
});
});
// =============================================================================
// Tests: conversation.getByFromAgent
// =============================================================================
describe('conversation.getByFromAgent', () => {
it('returns conversations with toAgentName resolved', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { agentRepo, convRepo } = getRepos(ctx);
// Seed agents in DB (FK requirement)
const fromAgent = await agentRepo.create({ name: 'from-agent', worktreeId: 'wt-from', status: 'running' });
const toAgent = await agentRepo.create({ name: 'to-agent', worktreeId: 'wt-to', status: 'running' });
// Seed in MockAgentManager for name resolution
agents.addAgent({ id: fromAgent.id, name: 'from-agent', status: 'running' });
agents.addAgent({ id: toAgent.id, name: 'to-agent', status: 'running' });
// Create conversation
await convRepo.create({
fromAgentId: fromAgent.id,
toAgentId: toAgent.id,
question: 'What is 2+2?',
});
const caller = createConversationCaller(ctx);
const result = await caller.getByFromAgent({ agentId: fromAgent.id });
expect(result).toHaveLength(1);
expect(result[0].toAgentName).toBe('to-agent');
expect(result[0].toAgentId).toBe(toAgent.id);
expect(result[0].question).toBe('What is 2+2?');
expect(result[0].status).toBe('pending');
expect(result[0].answer).toBeNull();
expect(typeof result[0].timestamp).toBe('string');
expect(result[0].taskId).toBeNull();
expect(result[0].phaseId).toBeNull();
});
});

View File

@@ -24,7 +24,6 @@ import { subscriptionProcedures } from './routers/subscription.js';
import { previewProcedures } from './routers/preview.js';
import { conversationProcedures } from './routers/conversation.js';
import { chatSessionProcedures } from './routers/chat-session.js';
import { errandProcedures } from './routers/errand.js';
import { headquartersProcedures } from './routers/headquarters.js';
// Re-export tRPC primitives (preserves existing import paths)
@@ -65,7 +64,6 @@ export const appRouter = router({
...previewProcedures(publicProcedure),
...conversationProcedures(publicProcedure),
...chatSessionProcedures(publicProcedure),
...errandProcedures(publicProcedure),
...headquartersProcedures(publicProcedure),
});

View File

@@ -11,7 +11,23 @@ import type { ProcedureBuilder } from '../trpc.js';
import type { TRPCContext } from '../context.js';
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
import type { AgentOutputEvent } from '../../events/types.js';
import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js';
import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository, requireConversationRepository } from './_helpers.js';
export type AgentRadarRow = {
id: string;
name: string;
mode: string;
status: string;
initiativeId: string | null;
initiativeName: string | null;
taskId: string | null;
taskName: string | null;
createdAt: string;
questionsCount: number;
messagesCount: number;
subagentsCount: number;
compactionsCount: number;
};
export const spawnAgentInputSchema = z.object({
name: z.string().min(1).optional(),
@@ -410,5 +426,177 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
return { content: truncateIfNeeded(raw) };
}),
listForRadar: publicProcedure
.input(z.object({
timeRange: z.enum(['1h', '6h', '24h', '7d', 'all']).default('24h'),
status: z.enum(['running', 'completed', 'crashed']).optional(),
initiativeId: z.string().optional(),
mode: z.enum(['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand']).optional(),
}))
.query(async ({ ctx, input }): Promise<AgentRadarRow[]> => {
const agentManager = requireAgentManager(ctx);
const allAgents = await agentManager.list();
// Compute cutoff
const cutoffMap: Record<string, number> = {
'1h': Date.now() - 3_600_000,
'6h': Date.now() - 21_600_000,
'24h': Date.now() - 86_400_000,
'7d': Date.now() - 604_800_000,
};
const cutoff = input.timeRange !== 'all' ? cutoffMap[input.timeRange] : null;
// Filter agents
let filteredAgents = allAgents;
if (cutoff !== null) {
filteredAgents = filteredAgents.filter(a => a.createdAt.getTime() >= cutoff!);
}
if (input.status !== undefined) {
const dbStatus = input.status === 'completed' ? 'stopped' : input.status;
filteredAgents = filteredAgents.filter(a => a.status === dbStatus);
}
if (input.initiativeId !== undefined) {
filteredAgents = filteredAgents.filter(a => a.initiativeId === input.initiativeId);
}
if (input.mode !== undefined) {
filteredAgents = filteredAgents.filter(a => a.mode === input.mode);
}
const matchingIds = filteredAgents.map(a => a.id);
// Batch fetch in parallel
const logChunkRepo = requireLogChunkRepository(ctx);
const conversationRepo = requireConversationRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
// Collect unique taskIds and initiativeIds for batch lookup
const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])];
const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])];
const [chunks, messageCounts, taskResults, initiativeResults] = await Promise.all([
logChunkRepo.findByAgentIds(matchingIds),
conversationRepo.countByFromAgentIds(matchingIds),
Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))),
Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))),
]);
// Build lookup maps
const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name]));
const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name]));
const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count]));
// Group chunks by agentId
const chunksByAgent = new Map<string, typeof chunks>();
for (const chunk of chunks) {
const existing = chunksByAgent.get(chunk.agentId);
if (existing) {
existing.push(chunk);
} else {
chunksByAgent.set(chunk.agentId, [chunk]);
}
}
// Build result rows
return filteredAgents.map(agent => {
const agentChunks = chunksByAgent.get(agent.id) ?? [];
let questionsCount = 0;
let subagentsCount = 0;
let compactionsCount = 0;
for (const chunk of agentChunks) {
try {
const parsed = JSON.parse(chunk.content);
if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') {
questionsCount += parsed.input?.questions?.length ?? 0;
} else if (parsed.type === 'tool_use' && parsed.name === 'Agent') {
subagentsCount++;
} else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') {
compactionsCount++;
}
} catch { /* skip malformed */ }
}
return {
id: agent.id,
name: agent.name,
mode: agent.mode,
status: agent.status,
initiativeId: agent.initiativeId,
initiativeName: agent.initiativeId ? (initiativeMap.get(agent.initiativeId) ?? null) : null,
taskId: agent.taskId,
taskName: agent.taskId ? (taskMap.get(agent.taskId) ?? null) : null,
createdAt: agent.createdAt.toISOString(),
questionsCount,
messagesCount: messagesMap.get(agent.id) ?? 0,
subagentsCount,
compactionsCount,
};
});
}),
getCompactionEvents: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const logChunkRepo = requireLogChunkRepository(ctx);
const chunks = await logChunkRepo.findByAgentId(input.agentId);
const results: { timestamp: string; sessionNumber: number }[] = [];
for (const chunk of chunks) {
try {
const parsed = JSON.parse(chunk.content);
if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') {
results.push({ timestamp: chunk.createdAt.toISOString(), sessionNumber: chunk.sessionNumber });
}
} catch { /* skip malformed */ }
if (results.length >= 200) break;
}
return results;
}),
getSubagentSpawns: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const logChunkRepo = requireLogChunkRepository(ctx);
const chunks = await logChunkRepo.findByAgentId(input.agentId);
const results: { timestamp: string; description: string; promptPreview: string; fullPrompt: string }[] = [];
for (const chunk of chunks) {
try {
const parsed = JSON.parse(chunk.content);
if (parsed.type === 'tool_use' && parsed.name === 'Agent') {
const fullPrompt: string = parsed.input?.prompt ?? '';
const description: string = parsed.input?.description ?? '';
results.push({
timestamp: chunk.createdAt.toISOString(),
description,
promptPreview: fullPrompt.slice(0, 200),
fullPrompt,
});
}
} catch { /* skip malformed */ }
if (results.length >= 200) break;
}
return results;
}),
getQuestionsAsked: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const logChunkRepo = requireLogChunkRepository(ctx);
const chunks = await logChunkRepo.findByAgentId(input.agentId);
type QuestionItem = { question: string; header: string; options: { label: string; description: string }[] };
const results: { timestamp: string; questions: QuestionItem[] }[] = [];
for (const chunk of chunks) {
try {
const parsed = JSON.parse(chunk.content);
if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') {
const questions: QuestionItem[] = parsed.input?.questions ?? [];
results.push({ timestamp: chunk.createdAt.toISOString(), questions });
}
} catch { /* skip malformed */ }
if (results.length >= 200) break;
}
return results;
}),
};
}

View File

@@ -302,5 +302,31 @@ export function conversationProcedures(publicProcedure: ProcedureBuilder) {
cleanup();
}
}),
getByFromAgent: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requireConversationRepository(ctx);
const agentManager = requireAgentManager(ctx);
const convs = await repo.findByFromAgentId(input.agentId);
// Build toAgent name map without N+1
const toAgentIds = [...new Set(convs.map(c => c.toAgentId))];
const allAgents = toAgentIds.length > 0 ? await agentManager.list() : [];
const agentNameMap = new Map(allAgents.map(a => [a.id, a.name]));
return convs.map(c => ({
id: c.id,
timestamp: c.createdAt.toISOString(),
toAgentName: agentNameMap.get(c.toAgentId) ?? c.toAgentId,
toAgentId: c.toAgentId,
question: c.question,
answer: c.answer ?? null,
status: c.status as 'pending' | 'answered',
taskId: c.taskId ?? null,
phaseId: c.phaseId ?? null,
}));
}),
};
}

View File

@@ -1,707 +0,0 @@
/**
* Errand Router Tests
*
* Tests all 9 errand tRPC procedures using in-memory SQLite, MockAgentManager,
* and vi.mock for git operations.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { nanoid } from 'nanoid';
import type { BranchManager } from '../../git/branch-manager.js';
import type { MergeResult, MergeabilityResult } from '../../git/types.js';
import { MockAgentManager } from '../../agent/mock-manager.js';
import { EventEmitterBus } from '../../events/bus.js';
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
import { createRepositories } from '../../container.js';
import { appRouter, createCallerFactory } from '../router.js';
import { createContext } from '../context.js';
// ---------------------------------------------------------------------------
// vi.hoisted mock handles for git module mocks (hoisted before vi.mock calls)
// ---------------------------------------------------------------------------
const { mockCreate, mockRemove, mockEnsureProjectClone, mockWriteErrandManifest } = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockRemove: vi.fn(),
mockEnsureProjectClone: vi.fn(),
mockWriteErrandManifest: vi.fn(),
}));
vi.mock('../../git/manager.js', () => ({
SimpleGitWorktreeManager: class MockWorktreeManager {
create = mockCreate;
remove = mockRemove;
},
}));
vi.mock('../../git/project-clones.js', () => ({
ensureProjectClone: mockEnsureProjectClone,
getProjectCloneDir: vi.fn().mockReturnValue('repos/test-project-abc123'),
}));
vi.mock('../../agent/file-io.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
writeErrandManifest: mockWriteErrandManifest,
};
});
// ---------------------------------------------------------------------------
// MockBranchManager
// ---------------------------------------------------------------------------
class MockBranchManager implements BranchManager {
private ensureBranchError: Error | null = null;
private mergeResultOverride: MergeResult | null = null;
private diffResult = '';
public deletedBranches: string[] = [];
public ensuredBranches: string[] = [];
setEnsureBranchError(err: Error | null): void { this.ensureBranchError = err; }
setMergeResult(result: MergeResult): void { this.mergeResultOverride = result; }
setDiffResult(diff: string): void { this.diffResult = diff; }
async ensureBranch(_repoPath: string, branch: string, _baseBranch: string): Promise<void> {
if (this.ensureBranchError) throw this.ensureBranchError;
this.ensuredBranches.push(branch);
}
async mergeBranch(_repoPath: string, _src: string, _target: string): Promise<MergeResult> {
return this.mergeResultOverride ?? { success: true, message: 'Merged successfully' };
}
async diffBranches(_repoPath: string, _base: string, _head: string): Promise<string> {
return this.diffResult;
}
async deleteBranch(_repoPath: string, branch: string): Promise<void> {
this.deletedBranches.push(branch);
}
async branchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
async remoteBranchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
async listCommits(_repoPath: string, _base: string, _head: string) { return []; }
async diffCommit(_repoPath: string, _hash: string): Promise<string> { return ''; }
async getMergeBase(_repoPath: string, _b1: string, _b2: string): Promise<string> { return ''; }
async pushBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
async checkMergeability(_repoPath: string, _src: string, _target: string): Promise<MergeabilityResult> {
return { mergeable: true, conflicts: [] };
}
async fetchRemote(_repoPath: string, _remote?: string): Promise<void> {}
async fastForwardBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
const createCaller = createCallerFactory(appRouter);
function createTestHarness() {
const db = createTestDatabase();
const eventBus = new EventEmitterBus();
const repos = createRepositories(db);
const agentManager = new MockAgentManager({ eventBus, agentRepository: repos.agentRepository });
const branchManager = new MockBranchManager();
const ctx = createContext({
eventBus,
serverStartedAt: new Date(),
processCount: 0,
agentManager,
errandRepository: repos.errandRepository,
projectRepository: repos.projectRepository,
branchManager,
workspaceRoot: '/tmp/test-workspace',
});
const caller = createCaller(ctx);
return {
db,
caller,
agentManager,
branchManager,
repos,
};
}
async function createProject(repos: ReturnType<typeof createRepositories>) {
const suffix = nanoid().slice(0, 6);
return repos.projectRepository.create({
name: `test-project-${suffix}`,
url: `https://github.com/test/project-${suffix}`,
defaultBranch: 'main',
});
}
async function createErrandDirect(
repos: ReturnType<typeof createRepositories>,
agentManager: MockAgentManager,
overrides: Partial<{
description: string;
branch: string;
baseBranch: string;
agentId: string | null;
projectId: string;
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
}> = {},
) {
const project = await createProject(repos);
// Spawn an agent to get a real agent ID (unique name to avoid name collision)
const agent = await agentManager.spawn({
prompt: 'Test errand',
name: `errand-agent-${nanoid().slice(0, 6)}`,
mode: 'errand',
cwd: '/tmp/fake-worktree',
taskId: null,
});
const errand = await repos.errandRepository.create({
id: nanoid(),
description: overrides.description ?? 'Fix typo in README',
branch: overrides.branch ?? 'cw/errand/fix-typo-abc12345',
baseBranch: overrides.baseBranch ?? 'main',
agentId: overrides.agentId !== undefined ? overrides.agentId : agent.id,
projectId: overrides.projectId ?? project.id,
status: overrides.status ?? 'active',
});
return { errand, project, agent };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('errand procedures', () => {
let h: ReturnType<typeof createTestHarness>;
beforeEach(() => {
h = createTestHarness();
// Reset mock call counts and set default passing behavior
mockCreate.mockClear();
mockRemove.mockClear();
mockEnsureProjectClone.mockClear();
mockWriteErrandManifest.mockClear();
mockEnsureProjectClone.mockResolvedValue('/tmp/fake-clone');
mockCreate.mockResolvedValue({ id: 'errand-id', branch: 'cw/errand/test', path: '/tmp/worktree', isMainWorktree: false });
mockRemove.mockResolvedValue(undefined);
mockWriteErrandManifest.mockResolvedValue(undefined);
h.branchManager.setEnsureBranchError(null);
h.branchManager.deletedBranches.splice(0);
h.branchManager.ensuredBranches.splice(0);
});
// =========================================================================
// errand.create
// =========================================================================
describe('errand.create', () => {
it('creates errand with valid input and returns id, branch, agentId', async () => {
const project = await createProject(h.repos);
const result = await h.caller.errand.create({
description: 'Fix typo in README',
projectId: project.id,
});
expect(result).toMatchObject({
id: expect.any(String),
branch: expect.stringMatching(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/),
agentId: expect.any(String),
});
});
it('generates correct slug from description', async () => {
const project = await createProject(h.repos);
const result = await h.caller.errand.create({
description: 'fix typo in README',
projectId: project.id,
});
expect(result.branch).toMatch(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/);
});
it('uses fallback slug "errand" when description has only special chars', async () => {
const project = await createProject(h.repos);
const result = await h.caller.errand.create({
description: '!!!',
projectId: project.id,
});
expect(result.branch).toMatch(/^cw\/errand\/errand-[a-zA-Z0-9_-]{8}$/);
});
it('stores errand in database with correct fields', async () => {
const project = await createProject(h.repos);
const result = await h.caller.errand.create({
description: 'Fix typo in README',
projectId: project.id,
baseBranch: 'develop',
});
const errand = await h.repos.errandRepository.findById(result.id);
expect(errand).not.toBeNull();
expect(errand!.description).toBe('Fix typo in README');
expect(errand!.baseBranch).toBe('develop');
expect(errand!.projectId).toBe(project.id);
expect(errand!.status).toBe('active');
expect(errand!.agentId).toBe(result.agentId);
});
it('throws BAD_REQUEST when description exceeds 200 chars', async () => {
const project = await createProject(h.repos);
const longDesc = 'a'.repeat(201);
await expect(h.caller.errand.create({
description: longDesc,
projectId: project.id,
})).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: `description must be ≤200 characters (201 given)`,
});
// No DB record created
const errands = await h.repos.errandRepository.findAll();
expect(errands).toHaveLength(0);
});
it('throws NOT_FOUND for non-existent projectId', async () => {
await expect(h.caller.errand.create({
description: 'Fix something',
projectId: 'nonexistent-project',
})).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Project not found',
});
// No DB record created
const errands = await h.repos.errandRepository.findAll();
expect(errands).toHaveLength(0);
});
it('throws INTERNAL_SERVER_ERROR when branch creation fails', async () => {
const project = await createProject(h.repos);
h.branchManager.setEnsureBranchError(new Error('Git error: branch locked'));
await expect(h.caller.errand.create({
description: 'Fix something',
projectId: project.id,
})).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Git error: branch locked',
});
// No DB record, no worktree created
const errands = await h.repos.errandRepository.findAll();
expect(errands).toHaveLength(0);
expect(mockCreate).not.toHaveBeenCalled();
});
it('throws INTERNAL_SERVER_ERROR when worktree creation fails, cleans up branch and DB record', async () => {
const project = await createProject(h.repos);
mockCreate.mockRejectedValueOnce(new Error('Worktree creation failed'));
await expect(h.caller.errand.create({
description: 'Fix something',
projectId: project.id,
})).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Worktree creation failed',
});
// No DB record (was created then deleted)
const errands = await h.repos.errandRepository.findAll();
expect(errands).toHaveLength(0);
// Branch was deleted
expect(h.branchManager.deletedBranches.length).toBe(1);
});
it('throws INTERNAL_SERVER_ERROR when agent spawn fails, cleans up worktree, DB record, and branch', async () => {
const project = await createProject(h.repos);
// Make spawn fail by using a scenario that throws immediately
vi.spyOn(h.agentManager, 'spawn').mockRejectedValueOnce(new Error('Spawn failed'));
await expect(h.caller.errand.create({
description: 'Fix something',
projectId: project.id,
})).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Spawn failed',
});
// No DB record (was created then deleted)
const errands = await h.repos.errandRepository.findAll();
expect(errands).toHaveLength(0);
// Worktree was removed, branch deleted
expect(mockRemove).toHaveBeenCalledOnce();
expect(h.branchManager.deletedBranches.length).toBe(1);
});
});
// =========================================================================
// errand.list
// =========================================================================
describe('errand.list', () => {
it('returns all errands ordered newest first', async () => {
const { errand: e1 } = await createErrandDirect(h.repos, h.agentManager, { description: 'First' });
const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { description: 'Second', projectId: project2.id, branch: 'cw/errand/second-xyz12345' });
const result = await h.caller.errand.list({});
expect(result.length).toBe(2);
// Both errands are present (repository orders by createdAt DESC)
const ids = result.map(r => r.id);
expect(ids).toContain(e1.id);
expect(ids).toContain(e2.id);
});
it('filters by projectId', async () => {
const { errand: e1, project } = await createErrandDirect(h.repos, h.agentManager);
const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
const agent2 = await h.agentManager.spawn({ prompt: 'x', mode: 'errand', cwd: '/tmp/x', taskId: null });
await h.repos.errandRepository.create({ id: nanoid(), description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active' });
const result = await h.caller.errand.list({ projectId: project.id });
expect(result.length).toBe(1);
expect(result[0].id).toBe(e1.id);
});
it('filters by status', async () => {
await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', branch: 'cw/errand/merged-abc12345' });
const result = await h.caller.errand.list({ status: 'merged' });
expect(result.length).toBe(1);
expect(result[0].id).toBe(e2.id);
});
it('returns empty array when no errands exist', async () => {
const result = await h.caller.errand.list({});
expect(result).toEqual([]);
});
it('each record includes agentAlias', async () => {
await createErrandDirect(h.repos, h.agentManager);
const result = await h.caller.errand.list({});
expect(result[0]).toHaveProperty('agentAlias');
});
});
// =========================================================================
// errand.get
// =========================================================================
describe('errand.get', () => {
it('returns errand with agentAlias and projectPath', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager);
const result = await h.caller.errand.get({ id: errand.id });
expect(result.id).toBe(errand.id);
expect(result).toHaveProperty('agentAlias');
expect(result).toHaveProperty('projectPath');
});
it('throws NOT_FOUND for unknown id', async () => {
await expect(h.caller.errand.get({ id: 'nonexistent' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Errand not found',
});
});
});
// =========================================================================
// errand.diff
// =========================================================================
describe('errand.diff', () => {
it('returns diff string for an existing errand', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager);
h.branchManager.setDiffResult('diff --git a/README.md b/README.md\n...');
const result = await h.caller.errand.diff({ id: errand.id });
expect(result.diff).toBe('diff --git a/README.md b/README.md\n...');
});
it('returns empty diff string when branch has no commits', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager);
h.branchManager.setDiffResult('');
const result = await h.caller.errand.diff({ id: errand.id });
expect(result.diff).toBe('');
});
it('throws NOT_FOUND for unknown id', async () => {
await expect(h.caller.errand.diff({ id: 'nonexistent' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Errand not found',
});
});
});
// =========================================================================
// errand.complete
// =========================================================================
describe('errand.complete', () => {
it('transitions active errand to pending_review and stops agent', async () => {
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
const stopSpy = vi.spyOn(h.agentManager, 'stop');
const result = await h.caller.errand.complete({ id: errand.id });
expect(result!.status).toBe('pending_review');
expect(stopSpy).toHaveBeenCalledWith(agent.id);
});
it('throws BAD_REQUEST when status is pending_review', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: "Cannot complete an errand with status 'pending_review'",
});
});
it('throws BAD_REQUEST when status is merged', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null });
await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: "Cannot complete an errand with status 'merged'",
});
});
});
// =========================================================================
// errand.merge
// =========================================================================
describe('errand.merge', () => {
it('merges clean pending_review errand, removes worktree, sets status to merged', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
h.branchManager.setMergeResult({ success: true, message: 'Merged' });
const result = await h.caller.errand.merge({ id: errand.id });
expect(result).toEqual({ status: 'merged' });
expect(mockRemove).toHaveBeenCalledOnce();
const updated = await h.repos.errandRepository.findById(errand.id);
expect(updated!.status).toBe('merged');
});
it('merges clean conflict errand (re-merge after resolve)', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
status: 'conflict',
});
h.branchManager.setMergeResult({ success: true, message: 'Merged' });
const result = await h.caller.errand.merge({ id: errand.id });
expect(result).toEqual({ status: 'merged' });
});
it('merges into target branch override', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
const mergeSpy = vi.spyOn(h.branchManager, 'mergeBranch');
await h.caller.errand.merge({ id: errand.id, target: 'develop' });
expect(mergeSpy).toHaveBeenCalledWith(
expect.any(String),
errand.branch,
'develop',
);
});
it('throws BAD_REQUEST and sets status to conflict on merge conflict', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
h.branchManager.setMergeResult({
success: false,
conflicts: ['src/a.ts', 'src/b.ts'],
message: 'Conflict detected',
});
await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'Merge conflict in 2 file(s)',
});
const updated = await h.repos.errandRepository.findById(errand.id);
expect(updated!.status).toBe('conflict');
});
it('throws BAD_REQUEST when status is active', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: "Cannot merge an errand with status 'active'",
});
});
it('throws BAD_REQUEST when status is abandoned', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null });
await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: "Cannot merge an errand with status 'abandoned'",
});
});
});
// =========================================================================
// errand.delete
// =========================================================================
describe('errand.delete', () => {
it('deletes active errand: stops agent, removes worktree, deletes branch and DB record', async () => {
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
const stopSpy = vi.spyOn(h.agentManager, 'stop');
const result = await h.caller.errand.delete({ id: errand.id });
expect(result).toEqual({ success: true });
expect(stopSpy).toHaveBeenCalledWith(agent.id);
expect(mockRemove).toHaveBeenCalledOnce();
expect(h.branchManager.deletedBranches).toContain(errand.branch);
const deleted = await h.repos.errandRepository.findById(errand.id);
expect(deleted).toBeUndefined();
});
it('deletes non-active errand: skips agent stop', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
const stopSpy = vi.spyOn(h.agentManager, 'stop');
const result = await h.caller.errand.delete({ id: errand.id });
expect(result).toEqual({ success: true });
expect(stopSpy).not.toHaveBeenCalled();
const deleted = await h.repos.errandRepository.findById(errand.id);
expect(deleted).toBeUndefined();
});
it('succeeds when worktree already removed (no-op)', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
mockRemove.mockRejectedValueOnce(new Error('Worktree not found'));
// Should not throw
const result = await h.caller.errand.delete({ id: errand.id });
expect(result).toEqual({ success: true });
const deleted = await h.repos.errandRepository.findById(errand.id);
expect(deleted).toBeUndefined();
});
it('succeeds when branch already deleted (no-op)', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
// DeleteBranch doesn't throw (BranchManager interface says no-op if not found)
const result = await h.caller.errand.delete({ id: errand.id });
expect(result).toEqual({ success: true });
});
it('throws NOT_FOUND for unknown id', async () => {
await expect(h.caller.errand.delete({ id: 'nonexistent' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Errand not found',
});
});
});
// =========================================================================
// errand.sendMessage
// =========================================================================
describe('errand.sendMessage', () => {
it('sends message to active running errand agent', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
const sendSpy = vi.spyOn(h.agentManager, 'sendUserMessage');
const result = await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' });
expect(result).toEqual({ success: true });
expect(sendSpy).toHaveBeenCalledWith(errand.agentId, 'Hello agent');
});
it('does NOT create a conversations record', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' });
// No pending conversation records should exist for the agent
const convs = await h.repos.conversationRepository.findPendingForAgent(errand.agentId!);
expect(convs).toHaveLength(0);
});
it('throws BAD_REQUEST when agent is stopped', async () => {
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
// Stop the agent to set its status to stopped
await h.agentManager.stop(agent.id);
await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'Agent is not running (status: stopped)',
});
});
it('throws BAD_REQUEST when errand is not active', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'Errand is not active',
});
});
});
// =========================================================================
// errand.abandon
// =========================================================================
describe('errand.abandon', () => {
it('abandons active errand: stops agent, removes worktree, deletes branch, sets status', async () => {
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
const stopSpy = vi.spyOn(h.agentManager, 'stop');
const result = await h.caller.errand.abandon({ id: errand.id });
expect(result!.status).toBe('abandoned');
expect(result!.agentId).toBe(agent.id); // agentId preserved
expect(stopSpy).toHaveBeenCalledWith(agent.id);
expect(mockRemove).toHaveBeenCalledOnce();
expect(h.branchManager.deletedBranches).toContain(errand.branch);
// DB record preserved with abandoned status
const found = await h.repos.errandRepository.findById(errand.id);
expect(found!.status).toBe('abandoned');
});
it('abandons pending_review errand: skips agent stop', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
const stopSpy = vi.spyOn(h.agentManager, 'stop');
const result = await h.caller.errand.abandon({ id: errand.id });
expect(result!.status).toBe('abandoned');
expect(stopSpy).not.toHaveBeenCalled();
});
it('abandons conflict errand: skips agent stop, removes worktree, deletes branch', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
status: 'conflict',
agentId: null,
});
const result = await h.caller.errand.abandon({ id: errand.id });
expect(result!.status).toBe('abandoned');
});
it('throws BAD_REQUEST when status is merged', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null });
await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: "Cannot abandon an errand with status 'merged'",
});
});
it('throws BAD_REQUEST when status is abandoned', async () => {
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null });
await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: "Cannot abandon an errand with status 'abandoned'",
});
});
});
});

View File

@@ -145,7 +145,40 @@ export function headquartersProcedures(publicProcedure: ProcedureBuilder) {
planningInitiatives.sort((a, b) => a.since.localeCompare(b.since));
// -----------------------------------------------------------------------
// Section 4: blockedPhases
// Section 4: resolvingConflicts
// -----------------------------------------------------------------------
const resolvingConflicts: Array<{
initiativeId: string;
initiativeName: string;
agentId: string;
agentName: string;
agentStatus: string;
since: string;
}> = [];
for (const agent of activeAgents) {
if (
agent.name?.startsWith('conflict-') &&
(agent.status === 'running' || agent.status === 'waiting_for_input') &&
agent.initiativeId
) {
const initiative = initiativeMap.get(agent.initiativeId);
if (initiative) {
resolvingConflicts.push({
initiativeId: initiative.id,
initiativeName: initiative.name,
agentId: agent.id,
agentName: agent.name,
agentStatus: agent.status,
since: agent.updatedAt.toISOString(),
});
}
}
}
resolvingConflicts.sort((a, b) => a.since.localeCompare(b.since));
// -----------------------------------------------------------------------
// Section 5: blockedPhases
// -----------------------------------------------------------------------
const blockedPhases: Array<{
initiativeId: string;
@@ -207,6 +240,7 @@ export function headquartersProcedures(publicProcedure: ProcedureBuilder) {
pendingReviewInitiatives,
pendingReviewPhases,
planningInitiatives,
resolvingConflicts,
blockedPhases,
};
}),

View File

@@ -0,0 +1,249 @@
/**
* Integration tests for getPhaseReviewDiff caching behaviour.
*
* Verifies that git diff is only invoked once per HEAD hash and that
* cache invalidation after a task merge triggers a re-run.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
import { phaseProcedures } from './phase.js';
import type { TRPCContext } from '../context.js';
import type { BranchManager } from '../../git/branch-manager.js';
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
import {
DrizzleInitiativeRepository,
DrizzlePhaseRepository,
DrizzleProjectRepository,
} from '../../db/repositories/drizzle/index.js';
import { phaseMetaCache, fileDiffCache } from '../../review/diff-cache.js';
// =============================================================================
// Mock ensureProjectClone — prevents actual git cloning
// =============================================================================
vi.mock('../../git/project-clones.js', () => ({
ensureProjectClone: vi.fn().mockResolvedValue('/fake/clone/path'),
getProjectCloneDir: vi.fn().mockReturnValue('repos/fake-project-id'),
}));
// =============================================================================
// Test router
// =============================================================================
const testRouter = router({
...phaseProcedures(publicProcedure),
});
const createCaller = createCallerFactory(testRouter);
// =============================================================================
// MockBranchManager
// =============================================================================
function makeMockBranchManager(): BranchManager {
return {
ensureBranch: vi.fn().mockResolvedValue(undefined),
mergeBranch: vi.fn().mockResolvedValue({ success: true, conflictFiles: [] }),
diffBranches: vi.fn().mockResolvedValue('diff --git a/file.ts'),
diffBranchesStat: vi.fn().mockResolvedValue([]),
diffFileSingle: vi.fn().mockResolvedValue('diff --git a/file.ts'),
deleteBranch: vi.fn().mockResolvedValue(undefined),
branchExists: vi.fn().mockResolvedValue(true),
remoteBranchExists: vi.fn().mockResolvedValue(true),
listCommits: vi.fn().mockResolvedValue([]),
diffCommit: vi.fn().mockResolvedValue(''),
getMergeBase: vi.fn().mockResolvedValue('mergebase123'),
pushBranch: vi.fn().mockResolvedValue(undefined),
checkMergeability: vi.fn().mockResolvedValue({ canMerge: true, conflicts: [] }),
fetchRemote: vi.fn().mockResolvedValue(undefined),
fastForwardBranch: vi.fn().mockResolvedValue(undefined),
updateRef: vi.fn().mockResolvedValue(undefined),
getHeadCommitHash: vi.fn().mockResolvedValue('abc123def456'),
};
}
// =============================================================================
// Helpers
// =============================================================================
function createMockEventBus(): TRPCContext['eventBus'] {
return {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
}
interface SeedResult {
phaseId: string;
initiativeId: string;
projectId: string;
}
async function seedDatabase(): Promise<{
repos: {
initiativeRepo: DrizzleInitiativeRepository;
phaseRepo: DrizzlePhaseRepository;
projectRepo: DrizzleProjectRepository;
};
data: SeedResult;
}> {
const db = createTestDatabase();
const initiativeRepo = new DrizzleInitiativeRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const projectRepo = new DrizzleProjectRepository(db);
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
status: 'active',
branch: 'main',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Test Phase',
status: 'pending_review',
});
const project = await projectRepo.create({
name: 'Test Project',
url: 'https://github.com/test/repo',
});
await projectRepo.addProjectToInitiative(initiative.id, project.id);
return {
repos: { initiativeRepo, phaseRepo, projectRepo },
data: { phaseId: phase.id, initiativeId: initiative.id, projectId: project.id },
};
}
async function seedDatabaseNoProjects(): Promise<{
repos: {
initiativeRepo: DrizzleInitiativeRepository;
phaseRepo: DrizzlePhaseRepository;
projectRepo: DrizzleProjectRepository;
};
data: { phaseId: string };
}> {
const db = createTestDatabase();
const initiativeRepo = new DrizzleInitiativeRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const projectRepo = new DrizzleProjectRepository(db);
const initiative = await initiativeRepo.create({
name: 'Test Initiative No Projects',
status: 'active',
branch: 'main',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Empty Phase',
status: 'pending_review',
});
return {
repos: { initiativeRepo, phaseRepo, projectRepo },
data: { phaseId: phase.id },
};
}
function makeCaller(
branchManager: BranchManager,
repos: {
initiativeRepo: DrizzleInitiativeRepository;
phaseRepo: DrizzlePhaseRepository;
projectRepo: DrizzleProjectRepository;
},
) {
const ctx: TRPCContext = {
eventBus: createMockEventBus(),
serverStartedAt: null,
processCount: 0,
branchManager,
initiativeRepository: repos.initiativeRepo,
phaseRepository: repos.phaseRepo,
projectRepository: repos.projectRepo,
workspaceRoot: '/fake/workspace',
};
return createCaller(ctx);
}
// =============================================================================
// Tests
// =============================================================================
beforeEach(() => {
// Clear caches between tests to ensure isolation
phaseMetaCache.invalidateByPrefix('');
fileDiffCache.invalidateByPrefix('');
});
describe('getPhaseReviewDiff caching', () => {
it('second call for same phase/HEAD returns cached result without calling git again', async () => {
const { repos, data } = await seedDatabase();
const branchManager = makeMockBranchManager();
const diffBranchesSpy = vi.spyOn(branchManager, 'diffBranchesStat');
const caller = makeCaller(branchManager, repos);
await caller.getPhaseReviewDiff({ phaseId: data.phaseId });
await caller.getPhaseReviewDiff({ phaseId: data.phaseId });
expect(diffBranchesSpy).toHaveBeenCalledTimes(1);
});
it('after cache invalidation, next call re-runs git diff', async () => {
const { repos, data } = await seedDatabase();
const branchManager = makeMockBranchManager();
const diffBranchesSpy = vi.spyOn(branchManager, 'diffBranchesStat');
const caller = makeCaller(branchManager, repos);
await caller.getPhaseReviewDiff({ phaseId: data.phaseId });
expect(diffBranchesSpy).toHaveBeenCalledTimes(1);
// Simulate a task merge → cache invalidated
phaseMetaCache.invalidateByPrefix(`${data.phaseId}:`);
await caller.getPhaseReviewDiff({ phaseId: data.phaseId });
expect(diffBranchesSpy).toHaveBeenCalledTimes(2);
});
it('different HEAD hashes for same phase are treated as distinct cache entries', async () => {
const { repos, data } = await seedDatabase();
const branchManager = makeMockBranchManager();
const diffBranchesSpy = vi.spyOn(branchManager, 'diffBranchesStat');
const caller = makeCaller(branchManager, repos);
// First call with headHash = 'abc123'
vi.spyOn(branchManager, 'getHeadCommitHash').mockResolvedValueOnce('abc123');
await caller.getPhaseReviewDiff({ phaseId: data.phaseId });
// Second call with headHash = 'def456' (simulates a new commit)
vi.spyOn(branchManager, 'getHeadCommitHash').mockResolvedValueOnce('def456');
await caller.getPhaseReviewDiff({ phaseId: data.phaseId });
expect(diffBranchesSpy).toHaveBeenCalledTimes(2);
});
it('throws NOT_FOUND for nonexistent phaseId', async () => {
const { repos } = await seedDatabase();
const caller = makeCaller(makeMockBranchManager(), repos);
await expect(caller.getPhaseReviewDiff({ phaseId: 'nonexistent' }))
.rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('phase with no projects returns empty result without calling git', async () => {
const { repos, data } = await seedDatabaseNoProjects();
const branchManager = makeMockBranchManager();
const diffBranchesSpy = vi.spyOn(branchManager, 'diffBranchesStat');
const caller = makeCaller(branchManager, repos);
const result = await caller.getPhaseReviewDiff({ phaseId: data.phaseId });
expect(diffBranchesSpy).not.toHaveBeenCalled();
expect(result).toHaveProperty('phaseName');
});
});

View File

@@ -4,11 +4,14 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { simpleGit } from 'simple-git';
import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository, requireChangeSetRepository } from './_helpers.js';
import { phaseBranchName } from '../../git/branch-naming.js';
import { ensureProjectClone } from '../../git/project-clones.js';
import type { FileStatEntry } from '../../git/types.js';
import { phaseMetaCache, fileDiffCache } from '../../review/diff-cache.js';
export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return {
@@ -230,26 +233,124 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
// For completed phases, use stored merge base; for pending_review, use initiative branch
const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch;
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
let rawDiff = '';
if (projects.length === 0) {
return {
phaseName: phase.name,
sourceBranch: phBranch,
targetBranch: initBranch,
files: [],
totalAdditions: 0,
totalDeletions: 0,
};
}
const firstClone = await ensureProjectClone(projects[0], ctx.workspaceRoot!);
const headHash = await branchManager.getHeadCommitHash(firstClone, phBranch);
const cacheKey = `${input.phaseId}:${headHash}`;
type PhaseReviewDiffResult = { phaseName: string; sourceBranch: string; targetBranch: string; files: FileStatEntry[]; totalAdditions: number; totalDeletions: number };
const cached = phaseMetaCache.get(cacheKey) as PhaseReviewDiffResult | undefined;
if (cached) return cached;
const files: FileStatEntry[] = [];
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const diff = await branchManager.diffBranches(clonePath, diffBase, phBranch);
if (diff) {
rawDiff += diff + '\n';
const entries = await branchManager.diffBranchesStat(clonePath, diffBase, phBranch);
for (const entry of entries) {
const tagged: FileStatEntry = { ...entry, projectId: project.id };
if (projects.length > 1) {
tagged.path = `${project.name}/${entry.path}`;
if (entry.oldPath) {
tagged.oldPath = `${project.name}/${entry.oldPath}`;
}
}
files.push(tagged);
}
}
return {
const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0);
const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0);
const result = {
phaseName: phase.name,
sourceBranch: phBranch,
targetBranch: initBranch,
rawDiff,
files,
totalAdditions,
totalDeletions,
};
phaseMetaCache.set(cacheKey, result);
return result;
}),
getFileDiff: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
filePath: z.string().min(1),
projectId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const phaseRepo = requirePhaseRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
}
if (phase.status !== 'pending_review' && phase.status !== 'completed') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` });
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative?.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch;
const decodedPath = decodeURIComponent(input.filePath);
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
const firstClone = await ensureProjectClone(projects[0], ctx.workspaceRoot!);
const headHash = await branchManager.getHeadCommitHash(firstClone, phBranch);
const cacheKey = `${input.phaseId}:${headHash}:${input.filePath}`;
const cached = fileDiffCache.get(cacheKey);
if (cached) return cached;
let clonePath: string;
if (input.projectId) {
const project = projects.find((p) => p.id === input.projectId);
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Project '${input.projectId}' not found for this phase` });
}
clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
} else {
clonePath = firstClone;
}
const git = simpleGit(clonePath);
// Binary files appear as "-\t-\t<path>" in --numstat output
const numstatOut = await git.raw(['diff', '--numstat', `${diffBase}...${phBranch}`, '--', decodedPath]);
if (numstatOut.trim() && numstatOut.startsWith('-\t-\t')) {
const binaryResult = { binary: true, rawDiff: '' };
fileDiffCache.set(cacheKey, binaryResult);
return binaryResult;
}
const rawDiff = await branchManager.diffFileSingle(clonePath, diffBase, phBranch, decodedPath);
const result = { binary: false, rawDiff };
fileDiffCache.set(cacheKey, result);
return result;
}),
approvePhaseReview: publicProcedure

View File

@@ -27,12 +27,14 @@
"@tiptap/suggestion": "^3.19.0",
"@trpc/client": "^11.9.0",
"@trpc/react-query": "^11.9.0",
"@types/react-window": "^1.8.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^0.563.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-window": "^2.2.7",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7"

View File

@@ -0,0 +1,52 @@
import { useNavigate } from '@tanstack/react-router'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { StatusDot } from '@/components/StatusDot'
import { formatRelativeTime } from '@/lib/utils'
import type { ResolvingConflictsItem } from './types'
interface Props {
items: ResolvingConflictsItem[]
}
export function HQResolvingConflictsSection({ items }: Props) {
const navigate = useNavigate()
return (
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Resolving Conflicts
</h2>
<div className="space-y-2">
{items.map((item) => (
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm">
<StatusDot status="resolving_conflict" variant="urgent" size="sm" pulse />
<span className="font-semibold">{item.initiativeName}</span>
<Badge variant="urgent" size="xs">{item.agentStatus === 'waiting_for_input' ? 'Needs Input' : 'Running'}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
{item.agentName} · started {formatRelativeTime(item.since)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate({
to: '/initiatives/$id',
params: { id: item.initiativeId },
search: { tab: 'execution' },
})
}
>
View
</Button>
</Card>
))}
</div>
</div>
)
}

View File

@@ -20,6 +20,7 @@ vi.mock('@/lib/utils', () => ({
import { HQWaitingForInputSection } from './HQWaitingForInputSection'
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
import { HQResolvingConflictsSection } from './HQResolvingConflictsSection'
import { HQBlockedSection } from './HQBlockedSection'
import { HQEmptyState } from './HQEmptyState'
@@ -268,6 +269,77 @@ describe('HQNeedsApprovalSection', () => {
})
})
// ─── HQResolvingConflictsSection ──────────────────────────────────────────────
describe('HQResolvingConflictsSection', () => {
beforeEach(() => vi.clearAllMocks())
it('renders "Resolving Conflicts" heading', () => {
render(<HQResolvingConflictsSection items={[]} />)
expect(screen.getByText('Resolving Conflicts')).toBeInTheDocument()
})
it('shows initiative name and "Running" badge for running agent', () => {
render(
<HQResolvingConflictsSection
items={[
{
initiativeId: 'init-1',
initiativeName: 'My Initiative',
agentId: 'a1',
agentName: 'conflict-1234567890',
agentStatus: 'running',
since,
},
]}
/>
)
expect(screen.getByText('My Initiative')).toBeInTheDocument()
expect(screen.getByText('Running')).toBeInTheDocument()
})
it('shows "Needs Input" badge for waiting_for_input agent', () => {
render(
<HQResolvingConflictsSection
items={[
{
initiativeId: 'init-1',
initiativeName: 'My Initiative',
agentId: 'a1',
agentName: 'conflict-1234567890',
agentStatus: 'waiting_for_input',
since,
},
]}
/>
)
expect(screen.getByText('Needs Input')).toBeInTheDocument()
})
it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
render(
<HQResolvingConflictsSection
items={[
{
initiativeId: 'init-1',
initiativeName: 'My Initiative',
agentId: 'a1',
agentName: 'conflict-1234567890',
agentStatus: 'running',
since,
},
]}
/>
)
fireEvent.click(screen.getByRole('button', { name: /view/i }))
expect(mockNavigate).toHaveBeenCalledWith({
to: '/initiatives/$id',
params: { id: 'init-1' },
search: { tab: 'execution' },
})
})
})
// ─── HQBlockedSection ────────────────────────────────────────────────────────
describe('HQBlockedSection', () => {

View File

@@ -5,4 +5,5 @@ export type WaitingForInputItem = HQDashboard['waitingForInput'][number]
export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number]
export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number]
export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number]
export type ResolvingConflictsItem = HQDashboard['resolvingConflicts'][number]
export type BlockedPhaseItem = HQDashboard['blockedPhases'][number]

View File

@@ -0,0 +1,157 @@
import { useState, useEffect } from 'react'
import { trpc } from '@/lib/trpc'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { useSubscriptionWithErrorHandling } from '@/hooks'
import type { DrilldownDialogProps } from './types'
const RELEVANT_EVENTS = ['agent:waiting']
function formatTimestamp(ts: string): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
let relative: string
if (diffSecs < 60) {
relative = `${diffSecs}s ago`
} else if (diffMins < 60) {
relative = `${diffMins}m ago`
} else if (diffHours < 24) {
relative = `${diffHours}h ago`
} else {
relative = `${diffDays}d ago`
}
const absolute = date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
return `${relative} · ${absolute}`
}
export function CompactionEventsDialog({
open,
onOpenChange,
agentId,
agentName,
isAgentRunning,
}: DrilldownDialogProps) {
const { data, isLoading, refetch } = trpc.agent.getCompactionEvents.useQuery(
{ agentId },
{ enabled: open }
)
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null)
const [secondsAgo, setSecondsAgo] = useState(0)
useSubscriptionWithErrorHandling(
() => trpc.onEvent.useSubscription(undefined),
{
enabled: open && !!isAgentRunning,
onData: (event: any) => {
const eventType: string = event?.data?.type ?? event?.type ?? ''
const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? ''
if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) {
void refetch().then(() => {
setLastRefreshedAt(new Date())
setSecondsAgo(0)
})
}
},
}
)
useEffect(() => {
if (!open || !isAgentRunning || !lastRefreshedAt) return
const interval = setInterval(() => {
setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000))
}, 1000)
return () => clearInterval(interval)
}, [open, isAgentRunning, lastRefreshedAt])
useEffect(() => {
if (!open) {
setLastRefreshedAt(null)
setSecondsAgo(0)
}
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{`Compaction Events — ${agentName}`}</DialogTitle>
<DialogDescription>
Each row is a context-window compaction the model&apos;s history was summarized to
free up space. Frequent compactions indicate a long-running agent with large context.
</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto">
{isLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i}>
<div className="flex items-center gap-4 py-2">
<Skeleton className="w-40 h-4" />
<Skeleton className="w-12 h-4" />
</div>
{i < 2 && <div className="border-b" />}
</div>
))}
</div>
) : !data || data.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No data found</p>
) : (
<>
{data.length >= 200 && (
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
)}
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
<th className="text-center py-2 font-medium">Session #</th>
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-2 pr-4 text-muted-foreground">
{formatTimestamp(row.timestamp)}
</td>
<td className="py-2 text-center">{row.sessionNumber}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
{isAgentRunning && lastRefreshedAt && (
<p className="mt-2 text-xs text-muted-foreground text-right">
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
</p>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect, Fragment } from 'react'
import { trpc } from '@/lib/trpc'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { useSubscriptionWithErrorHandling } from '@/hooks'
import type { DrilldownDialogProps } from './types'
const RELEVANT_EVENTS = ['conversation:created', 'conversation:answered']
function formatTimestamp(ts: string): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
let relative: string
if (diffSecs < 60) {
relative = `${diffSecs}s ago`
} else if (diffMins < 60) {
relative = `${diffMins}m ago`
} else if (diffHours < 24) {
relative = `${diffHours}h ago`
} else {
relative = `${diffDays}d ago`
}
const absolute = date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
return `${relative} · ${absolute}`
}
export function InterAgentMessagesDialog({
open,
onOpenChange,
agentId,
agentName,
isAgentRunning,
}: DrilldownDialogProps) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
const { data, isLoading, refetch } = trpc.conversation.getByFromAgent.useQuery(
{ agentId },
{ enabled: open }
)
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null)
const [secondsAgo, setSecondsAgo] = useState(0)
useSubscriptionWithErrorHandling(
() => trpc.onEvent.useSubscription(undefined),
{
enabled: open && !!isAgentRunning,
onData: (event: any) => {
const eventType: string = event?.data?.type ?? event?.type ?? ''
const eventAgentId: string = event?.data?.fromAgentId ?? event?.data?.agentId ?? event?.agentId ?? ''
if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) {
void refetch().then(() => {
setLastRefreshedAt(new Date())
setSecondsAgo(0)
})
}
},
}
)
useEffect(() => {
if (!open || !isAgentRunning || !lastRefreshedAt) return
const interval = setInterval(() => {
setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000))
}, 1000)
return () => clearInterval(interval)
}, [open, isAgentRunning, lastRefreshedAt])
useEffect(() => {
if (!open) {
setExpandedIndex(null)
setLastRefreshedAt(null)
setSecondsAgo(0)
}
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{`Inter-Agent Messages — ${agentName}`}</DialogTitle>
<DialogDescription>
Each row is a conversation this agent initiated with another agent. Click a row to see
the full question and answer.
</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto">
{isLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i}>
<div className="flex items-center gap-4 py-2">
<Skeleton className="w-28 h-4" />
<Skeleton className="w-32 h-4" />
<Skeleton className="w-20 h-5" />
</div>
{i < 2 && <div className="border-b" />}
</div>
))}
</div>
) : !data || data.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No data found</p>
) : (
<>
{data.length >= 200 && (
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
)}
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
<th className="text-left py-2 pr-4 font-medium">Target Agent</th>
<th className="text-left py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<Fragment key={i}>
<tr
className="border-b last:border-0 cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedIndex(i === expandedIndex ? null : i)}
>
<td className="py-2 pr-4 text-muted-foreground whitespace-nowrap">
{formatTimestamp(row.timestamp)}
</td>
<td className="py-2 pr-4">{row.toAgentName}</td>
<td className="py-2">
{row.status === 'answered' ? (
<Badge variant="secondary">answered</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
pending
</Badge>
)}
</td>
</tr>
{expandedIndex === i && (
<tr>
<td colSpan={3} className="pb-2">
<div className="bg-muted/10 p-3 rounded mt-1 mb-1 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide mb-1">
Question
</p>
<div
style={{ maxHeight: '200px', overflowY: 'auto' }}
className="bg-muted/50 p-2 rounded font-mono text-sm whitespace-pre-wrap"
>
{row.question}
</div>
{row.status === 'answered' ? (
<>
<p className="text-xs font-semibold uppercase tracking-wide mb-1">
Answer
</p>
<div
style={{ maxHeight: '200px', overflowY: 'auto' }}
className="bg-muted/50 p-2 rounded font-mono text-sm whitespace-pre-wrap"
>
{row.answer}
</div>
</>
) : (
<p className="text-muted-foreground italic text-sm">No answer yet</p>
)}
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</>
)}
</div>
{isAgentRunning && lastRefreshedAt && (
<p className="mt-2 text-xs text-muted-foreground text-right">
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
</p>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,201 @@
import { useState, useEffect, Fragment } from 'react'
import { trpc } from '@/lib/trpc'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { useSubscriptionWithErrorHandling } from '@/hooks'
import type { DrilldownDialogProps } from './types'
const RELEVANT_EVENTS = ['agent:waiting']
function formatTimestamp(ts: string): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
let relative: string
if (diffSecs < 60) {
relative = `${diffSecs}s ago`
} else if (diffMins < 60) {
relative = `${diffMins}m ago`
} else if (diffHours < 24) {
relative = `${diffHours}h ago`
} else {
relative = `${diffDays}d ago`
}
const absolute = date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
return `${relative} · ${absolute}`
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max) + '…' : text
}
export function QuestionsAskedDialog({
open,
onOpenChange,
agentId,
agentName,
isAgentRunning,
}: DrilldownDialogProps) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
const { data, isLoading, refetch } = trpc.agent.getQuestionsAsked.useQuery(
{ agentId },
{ enabled: open }
)
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null)
const [secondsAgo, setSecondsAgo] = useState(0)
useSubscriptionWithErrorHandling(
() => trpc.onEvent.useSubscription(undefined),
{
enabled: open && !!isAgentRunning,
onData: (event: any) => {
const eventType: string = event?.data?.type ?? event?.type ?? ''
const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? ''
if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) {
void refetch().then(() => {
setLastRefreshedAt(new Date())
setSecondsAgo(0)
})
}
},
}
)
useEffect(() => {
if (!open || !isAgentRunning || !lastRefreshedAt) return
const interval = setInterval(() => {
setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000))
}, 1000)
return () => clearInterval(interval)
}, [open, isAgentRunning, lastRefreshedAt])
useEffect(() => {
if (!open) {
setExpandedIndex(null)
setLastRefreshedAt(null)
setSecondsAgo(0)
}
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{`Questions Asked — ${agentName}`}</DialogTitle>
<DialogDescription>
Each row is a question this agent sent to the user via the AskUserQuestion tool.
</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto">
{isLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i}>
<div className="flex items-center gap-4 py-2">
<Skeleton className="w-28 h-4" />
<Skeleton className="w-20 h-4" />
<Skeleton className="w-36 h-4" />
</div>
{i < 2 && <div className="border-b" />}
</div>
))}
</div>
) : !data || data.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No data found</p>
) : (
<>
{data.length >= 200 && (
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
)}
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
<th className="text-left py-2 pr-4 font-medium"># Questions</th>
<th className="text-left py-2 font-medium">First Question Header</th>
</tr>
</thead>
<tbody>
{data.map((row, i) => {
const n = row.questions.length
const countLabel = `${n} question${n !== 1 ? 's' : ''}`
const firstHeader = truncate(row.questions[0]?.header ?? '', 40)
return (
<Fragment key={i}>
<tr
className="border-b last:border-0 cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedIndex(i === expandedIndex ? null : i)}
>
<td className="py-2 pr-4 text-muted-foreground whitespace-nowrap">
{formatTimestamp(row.timestamp)}
</td>
<td className="py-2 pr-4">{countLabel}</td>
<td className="py-2">{firstHeader}</td>
</tr>
{expandedIndex === i && (
<tr>
<td colSpan={3} className="pb-2">
<div className="bg-muted/30 p-3 rounded mt-1 mb-1">
<ol className="space-y-3 list-decimal list-inside">
{row.questions.map((q, qi) => (
<li key={qi}>
<span className="font-bold bg-muted px-1 py-0.5 rounded text-sm mr-2">
{q.header}
</span>
{q.question}
<ul className="ml-4 mt-1 space-y-0.5">
{q.options.map((opt, oi) => (
<li key={oi} className="text-sm text-muted-foreground">
{`${opt.label}${opt.description}`}
</li>
))}
</ul>
</li>
))}
</ol>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</>
)}
</div>
{isAgentRunning && lastRefreshedAt && (
<p className="mt-2 text-xs text-muted-foreground text-right">
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
</p>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect, Fragment } from 'react'
import { trpc } from '@/lib/trpc'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { useSubscriptionWithErrorHandling } from '@/hooks'
import type { DrilldownDialogProps } from './types'
const RELEVANT_EVENTS = ['agent:waiting']
function formatTimestamp(ts: string): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
let relative: string
if (diffSecs < 60) {
relative = `${diffSecs}s ago`
} else if (diffMins < 60) {
relative = `${diffMins}m ago`
} else if (diffHours < 24) {
relative = `${diffHours}h ago`
} else {
relative = `${diffDays}d ago`
}
const absolute = date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
return `${relative} · ${absolute}`
}
export function SubagentSpawnsDialog({
open,
onOpenChange,
agentId,
agentName,
isAgentRunning,
}: DrilldownDialogProps) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
const { data, isLoading, refetch } = trpc.agent.getSubagentSpawns.useQuery(
{ agentId },
{ enabled: open }
)
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null)
const [secondsAgo, setSecondsAgo] = useState(0)
useSubscriptionWithErrorHandling(
() => trpc.onEvent.useSubscription(undefined),
{
enabled: open && !!isAgentRunning,
onData: (event: any) => {
const eventType: string = event?.data?.type ?? event?.type ?? ''
const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? ''
if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) {
void refetch().then(() => {
setLastRefreshedAt(new Date())
setSecondsAgo(0)
})
}
},
}
)
useEffect(() => {
if (!open || !isAgentRunning || !lastRefreshedAt) return
const interval = setInterval(() => {
setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000))
}, 1000)
return () => clearInterval(interval)
}, [open, isAgentRunning, lastRefreshedAt])
useEffect(() => {
if (!open) {
setExpandedIndex(null)
setLastRefreshedAt(null)
setSecondsAgo(0)
}
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{`Subagent Spawns — ${agentName}`}</DialogTitle>
<DialogDescription>
Each row is an Agent tool call a subagent spawned by this agent. The description and
first 200 characters of the prompt are shown.
</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto">
{isLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i}>
<div className="flex items-center gap-4 py-2">
<Skeleton className="w-32 h-4" />
<Skeleton className="w-48 h-4" />
<Skeleton className="w-64 h-4" />
</div>
{i < 2 && <div className="border-b" />}
</div>
))}
</div>
) : !data || data.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No data found</p>
) : (
<>
{data.length >= 200 && (
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
)}
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
<th className="text-left py-2 pr-4 font-medium">Description</th>
<th className="text-left py-2 font-medium">Prompt Preview</th>
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<Fragment key={i}>
<tr
className="border-b last:border-0 cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedIndex(i === expandedIndex ? null : i)}
>
<td className="py-2 pr-4 text-muted-foreground whitespace-nowrap">
{formatTimestamp(row.timestamp)}
</td>
<td className="py-2 pr-4">{row.description}</td>
<td className="py-2">
{row.promptPreview}
{row.fullPrompt.length > row.promptPreview.length && (
<span></span>
)}
</td>
</tr>
{expandedIndex === i && (
<tr>
<td colSpan={3} className="pb-2">
<div
className="bg-muted/30 p-3 rounded"
style={{ maxHeight: '300px', overflowY: 'auto', fontFamily: 'monospace' }}
>
<pre>{row.fullPrompt}</pre>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</>
)}
</div>
{isAgentRunning && lastRefreshedAt && (
<p className="mt-2 text-xs text-muted-foreground text-right">
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
</p>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,151 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { render, screen, act, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn())
vi.mock('@/hooks', () => ({
useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling,
}))
let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise<unknown> } = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
vi.mock('@/lib/trpc', () => ({
trpc: {
agent: {
getCompactionEvents: {
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
},
},
onEvent: {
useSubscription: vi.fn(),
},
},
}))
import { CompactionEventsDialog } from '../CompactionEventsDialog'
const defaultProps = {
open: true,
onOpenChange: vi.fn(),
agentId: 'agent-123',
agentName: 'test-agent',
}
describe('CompactionEventsDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQueryReturn = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
mockUseSubscriptionWithErrorHandling.mockReturnValue({
isConnected: false,
isConnecting: false,
error: null,
reconnectAttempts: 0,
lastEventId: null,
reconnect: vi.fn(),
reset: vi.fn(),
})
})
it('does not render dialog content when open=false', () => {
render(<CompactionEventsDialog {...defaultProps} open={false} />)
expect(screen.queryByText(/Compaction Events/)).toBeNull()
})
it('shows skeleton rows when loading', () => {
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
render(<CompactionEventsDialog {...defaultProps} />)
const skeletons = document.querySelectorAll('.animate-pulse')
expect(skeletons.length).toBeGreaterThanOrEqual(3)
expect(screen.queryByRole('table')).toBeNull()
})
it('shows "No data found" when data is empty', () => {
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
render(<CompactionEventsDialog {...defaultProps} />)
expect(screen.getByText('No data found')).toBeInTheDocument()
})
it('renders data rows correctly', () => {
mockUseQueryReturn = {
data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 3 }],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<CompactionEventsDialog {...defaultProps} />)
expect(screen.getByText('3')).toBeInTheDocument()
// Timestamp includes year 2026
expect(screen.getByText(/2026/)).toBeInTheDocument()
expect(screen.queryByText('Showing first 200 instances.')).toBeNull()
})
it('shows 200-instance note when data length is 200', () => {
mockUseQueryReturn = {
data: Array(200).fill({ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }),
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<CompactionEventsDialog {...defaultProps} />)
expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument()
})
it('renders dialog title and subtitle', () => {
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
render(<CompactionEventsDialog {...defaultProps} />)
expect(screen.getByText(/Compaction Events — test-agent/)).toBeInTheDocument()
expect(screen.getByText(/context-window compaction/)).toBeInTheDocument()
})
describe('isAgentRunning behavior', () => {
it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => {
let capturedOnData: ((event: any) => void) | undefined
mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => {
capturedOnData = opts.onData
return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() }
})
const mockRefetch = vi.fn().mockResolvedValue({ data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }] })
mockUseQueryReturn = {
data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }],
isLoading: false,
refetch: mockRefetch,
}
render(<CompactionEventsDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
await act(async () => {
capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } })
})
await waitFor(() => {
expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument()
})
})
it('does not show "Last refreshed" when isAgentRunning=false', () => {
render(<CompactionEventsDialog {...defaultProps} isAgentRunning={false} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
render(<CompactionEventsDialog {...defaultProps} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
render(<CompactionEventsDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ enabled: false })
)
})
})
})

View File

@@ -0,0 +1,244 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn())
vi.mock('@/hooks', () => ({
useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling,
}))
let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise<unknown> } = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
vi.mock('@/lib/trpc', () => ({
trpc: {
conversation: {
getByFromAgent: {
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
},
},
onEvent: {
useSubscription: vi.fn(),
},
},
}))
import { InterAgentMessagesDialog } from '../InterAgentMessagesDialog'
const defaultProps = {
open: true,
onOpenChange: vi.fn(),
agentId: 'agent-123',
agentName: 'test-agent',
}
describe('InterAgentMessagesDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQueryReturn = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
mockUseSubscriptionWithErrorHandling.mockReturnValue({
isConnected: false,
isConnecting: false,
error: null,
reconnectAttempts: 0,
lastEventId: null,
reconnect: vi.fn(),
reset: vi.fn(),
})
})
it('does not render dialog content when open=false', () => {
render(<InterAgentMessagesDialog {...defaultProps} open={false} />)
expect(screen.queryByText(/Inter-Agent Messages/)).toBeNull()
})
it('shows skeleton rows when loading', () => {
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
render(<InterAgentMessagesDialog {...defaultProps} />)
const skeletons = document.querySelectorAll('.animate-pulse')
expect(skeletons.length).toBeGreaterThanOrEqual(3)
expect(screen.queryByRole('table')).toBeNull()
})
it('shows "No data found" when data is empty', () => {
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
render(<InterAgentMessagesDialog {...defaultProps} />)
expect(screen.getByText('No data found')).toBeInTheDocument()
})
it('renders data rows for answered conversation', () => {
mockUseQueryReturn = {
data: [
{
id: 'c1',
timestamp: '2026-03-06T10:00:00.000Z',
toAgentName: 'target-agent',
toAgentId: 'agent-2',
question: 'What is the export path?',
answer: 'It is src/api/index.ts',
status: 'answered',
taskId: null,
phaseId: null,
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<InterAgentMessagesDialog {...defaultProps} />)
expect(screen.getByText('target-agent')).toBeInTheDocument()
expect(screen.getByText('answered')).toBeInTheDocument()
expect(screen.queryByText('What is the export path?')).toBeNull()
})
it('expands answered row to show question and answer', () => {
mockUseQueryReturn = {
data: [
{
id: 'c1',
timestamp: '2026-03-06T10:00:00.000Z',
toAgentName: 'target-agent',
toAgentId: 'agent-2',
question: 'What is the export path?',
answer: 'It is src/api/index.ts',
status: 'answered',
taskId: null,
phaseId: null,
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<InterAgentMessagesDialog {...defaultProps} />)
fireEvent.click(screen.getByText('target-agent').closest('tr')!)
expect(screen.getByText('What is the export path?')).toBeInTheDocument()
expect(screen.getByText('It is src/api/index.ts')).toBeInTheDocument()
expect(screen.queryByText('No answer yet')).toBeNull()
})
it('expands pending row to show question and "No answer yet"', () => {
mockUseQueryReturn = {
data: [
{
id: 'c2',
timestamp: '2026-03-06T10:00:00.000Z',
toAgentName: 'target-agent',
toAgentId: 'agent-2',
question: 'What is the export path?',
answer: null,
status: 'pending',
taskId: null,
phaseId: null,
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<InterAgentMessagesDialog {...defaultProps} />)
fireEvent.click(screen.getByText('target-agent').closest('tr')!)
expect(screen.getByText('What is the export path?')).toBeInTheDocument()
expect(screen.getByText('No answer yet')).toBeInTheDocument()
expect(screen.queryByText('It is src/api/index.ts')).toBeNull()
})
it('collapses row when clicked again', () => {
mockUseQueryReturn = {
data: [
{
id: 'c1',
timestamp: '2026-03-06T10:00:00.000Z',
toAgentName: 'target-agent',
toAgentId: 'agent-2',
question: 'What is the export path?',
answer: 'It is src/api/index.ts',
status: 'answered',
taskId: null,
phaseId: null,
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<InterAgentMessagesDialog {...defaultProps} />)
const row = screen.getByText('target-agent').closest('tr')!
fireEvent.click(row)
expect(screen.getByText('What is the export path?')).toBeInTheDocument()
fireEvent.click(row)
expect(screen.queryByText('What is the export path?')).toBeNull()
})
it('shows 200-instance note when data length is 200', () => {
mockUseQueryReturn = {
data: Array(200).fill({
id: 'c1',
timestamp: '2026-03-06T10:00:00.000Z',
toAgentName: 'target-agent',
toAgentId: 'agent-2',
question: 'What is the export path?',
answer: null,
status: 'pending',
taskId: null,
phaseId: null,
}),
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<InterAgentMessagesDialog {...defaultProps} />)
expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument()
})
describe('isAgentRunning behavior', () => {
it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => {
let capturedOnData: ((event: any) => void) | undefined
mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => {
capturedOnData = opts.onData
return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() }
})
const mockRefetch = vi.fn().mockResolvedValue({ data: [] })
mockUseQueryReturn = {
data: [],
isLoading: false,
refetch: mockRefetch,
}
render(<InterAgentMessagesDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
await act(async () => {
capturedOnData?.({ data: { type: 'conversation:created', fromAgentId: 'agent-1' } })
})
await waitFor(() => {
expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument()
})
})
it('does not show "Last refreshed" when isAgentRunning=false', () => {
render(<InterAgentMessagesDialog {...defaultProps} isAgentRunning={false} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
render(<InterAgentMessagesDialog {...defaultProps} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
render(<InterAgentMessagesDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ enabled: false })
)
})
})
})

View File

@@ -0,0 +1,219 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn())
vi.mock('@/hooks', () => ({
useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling,
}))
let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise<unknown> } = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
vi.mock('@/lib/trpc', () => ({
trpc: {
agent: {
getQuestionsAsked: {
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
},
},
onEvent: {
useSubscription: vi.fn(),
},
},
}))
import { QuestionsAskedDialog } from '../QuestionsAskedDialog'
const defaultProps = {
open: true,
onOpenChange: vi.fn(),
agentId: 'agent-123',
agentName: 'test-agent',
}
describe('QuestionsAskedDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQueryReturn = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
mockUseSubscriptionWithErrorHandling.mockReturnValue({
isConnected: false,
isConnecting: false,
error: null,
reconnectAttempts: 0,
lastEventId: null,
reconnect: vi.fn(),
reset: vi.fn(),
})
})
it('does not render dialog content when open=false', () => {
render(<QuestionsAskedDialog {...defaultProps} open={false} />)
expect(screen.queryByText(/Questions Asked/)).toBeNull()
})
it('shows skeleton rows when loading', () => {
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
render(<QuestionsAskedDialog {...defaultProps} />)
const skeletons = document.querySelectorAll('.animate-pulse')
expect(skeletons.length).toBeGreaterThanOrEqual(3)
expect(screen.queryByRole('table')).toBeNull()
})
it('shows "No data found" when data is empty', () => {
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
render(<QuestionsAskedDialog {...defaultProps} />)
expect(screen.getByText('No data found')).toBeInTheDocument()
})
it('renders data rows correctly', () => {
mockUseQueryReturn = {
data: [
{
timestamp: '2026-03-06T10:00:00.000Z',
questions: [
{ question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] },
{ question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] },
],
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<QuestionsAskedDialog {...defaultProps} />)
expect(screen.getByText('2 questions')).toBeInTheDocument()
expect(screen.getByText('Method')).toBeInTheDocument()
expect(screen.queryByText('Pick a method')).toBeNull()
})
it('expands row to show all sub-questions on click', () => {
mockUseQueryReturn = {
data: [
{
timestamp: '2026-03-06T10:00:00.000Z',
questions: [
{ question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] },
{ question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] },
],
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<QuestionsAskedDialog {...defaultProps} />)
fireEvent.click(screen.getByText('2 questions').closest('tr')!)
expect(screen.getByText('Pick a method')).toBeInTheDocument()
expect(screen.getByText('Pick a strategy')).toBeInTheDocument()
expect(screen.getByText('• A — Option A')).toBeInTheDocument()
})
it('collapses row when clicked again', () => {
mockUseQueryReturn = {
data: [
{
timestamp: '2026-03-06T10:00:00.000Z',
questions: [
{ question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] },
{ question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] },
],
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<QuestionsAskedDialog {...defaultProps} />)
const row = screen.getByText('2 questions').closest('tr')!
fireEvent.click(row)
expect(screen.getByText('Pick a method')).toBeInTheDocument()
fireEvent.click(row)
expect(screen.queryByText('Pick a method')).toBeNull()
})
it('shows 200-instance note when data length is 200', () => {
mockUseQueryReturn = {
data: Array(200).fill({
timestamp: '2026-03-06T10:00:00.000Z',
questions: [
{ question: 'Pick a method', header: 'Method', options: [] },
],
}),
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<QuestionsAskedDialog {...defaultProps} />)
expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument()
})
it('shows singular "1 question" for single-question rows', () => {
mockUseQueryReturn = {
data: [
{
timestamp: '2026-03-06T10:00:00.000Z',
questions: [
{ question: 'Only one', header: 'Single', options: [] },
],
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<QuestionsAskedDialog {...defaultProps} />)
expect(screen.getByText('1 question')).toBeInTheDocument()
expect(screen.queryByText('1 questions')).toBeNull()
})
describe('isAgentRunning behavior', () => {
it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => {
let capturedOnData: ((event: any) => void) | undefined
mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => {
capturedOnData = opts.onData
return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() }
})
const mockRefetch = vi.fn().mockResolvedValue({ data: [] })
mockUseQueryReturn = {
data: [],
isLoading: false,
refetch: mockRefetch,
}
render(<QuestionsAskedDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
await act(async () => {
capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } })
})
await waitFor(() => {
expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument()
})
})
it('does not show "Last refreshed" when isAgentRunning=false', () => {
render(<QuestionsAskedDialog {...defaultProps} isAgentRunning={false} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
render(<QuestionsAskedDialog {...defaultProps} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
render(<QuestionsAskedDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ enabled: false })
)
})
})
})

View File

@@ -0,0 +1,198 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn())
vi.mock('@/hooks', () => ({
useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling,
}))
let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise<unknown> } = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
vi.mock('@/lib/trpc', () => ({
trpc: {
agent: {
getSubagentSpawns: {
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
},
},
onEvent: {
useSubscription: vi.fn(),
},
},
}))
import { SubagentSpawnsDialog } from '../SubagentSpawnsDialog'
const defaultProps = {
open: true,
onOpenChange: vi.fn(),
agentId: 'agent-123',
agentName: 'test-agent',
}
describe('SubagentSpawnsDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQueryReturn = {
data: undefined,
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
mockUseSubscriptionWithErrorHandling.mockReturnValue({
isConnected: false,
isConnecting: false,
error: null,
reconnectAttempts: 0,
lastEventId: null,
reconnect: vi.fn(),
reset: vi.fn(),
})
})
it('does not render dialog content when open=false', () => {
render(<SubagentSpawnsDialog {...defaultProps} open={false} />)
expect(screen.queryByText(/Subagent Spawns/)).toBeNull()
})
it('shows skeleton rows when loading', () => {
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
render(<SubagentSpawnsDialog {...defaultProps} />)
const skeletons = document.querySelectorAll('.animate-pulse')
expect(skeletons.length).toBeGreaterThanOrEqual(3)
expect(screen.queryByRole('table')).toBeNull()
})
it('shows "No data found" when data is empty', () => {
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
render(<SubagentSpawnsDialog {...defaultProps} />)
expect(screen.getByText('No data found')).toBeInTheDocument()
})
it('renders data rows correctly', () => {
mockUseQueryReturn = {
data: [
{
timestamp: '2026-03-06T10:00:00.000Z',
description: 'my task',
promptPreview: 'hello',
fullPrompt: 'hello world full',
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<SubagentSpawnsDialog {...defaultProps} />)
expect(screen.getByText('my task')).toBeInTheDocument()
expect(screen.getByText('hello')).toBeInTheDocument()
expect(screen.queryByText('hello world full')).toBeNull()
})
it('expands and collapses row on click', () => {
mockUseQueryReturn = {
data: [
{
timestamp: '2026-03-06T10:00:00.000Z',
description: 'my task',
promptPreview: 'hello',
fullPrompt: 'hello world full',
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<SubagentSpawnsDialog {...defaultProps} />)
// Click the row — should expand
fireEvent.click(screen.getByText('my task').closest('tr')!)
expect(screen.getByText('hello world full')).toBeInTheDocument()
// Click again — should collapse
fireEvent.click(screen.getByText('my task').closest('tr')!)
expect(screen.queryByText('hello world full')).toBeNull()
})
it('shows ellipsis suffix when fullPrompt is longer than promptPreview', () => {
const fullPrompt = 'A'.repeat(201)
const promptPreview = fullPrompt.slice(0, 200)
mockUseQueryReturn = {
data: [
{
timestamp: '2026-03-06T10:00:00.000Z',
description: 'truncated task',
promptPreview,
fullPrompt,
},
],
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<SubagentSpawnsDialog {...defaultProps} />)
expect(screen.getByText('…')).toBeInTheDocument()
})
it('shows 200-instance note when data length is 200', () => {
mockUseQueryReturn = {
data: Array(200).fill({
timestamp: '2026-03-06T10:00:00.000Z',
description: 'task',
promptPreview: 'prompt',
fullPrompt: 'full prompt',
}),
isLoading: false,
refetch: vi.fn().mockResolvedValue({}),
}
render(<SubagentSpawnsDialog {...defaultProps} />)
expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument()
})
describe('isAgentRunning behavior', () => {
it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => {
let capturedOnData: ((event: any) => void) | undefined
mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => {
capturedOnData = opts.onData
return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() }
})
const mockRefetch = vi.fn().mockResolvedValue({ data: [] })
mockUseQueryReturn = {
data: [],
isLoading: false,
refetch: mockRefetch,
}
render(<SubagentSpawnsDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
await act(async () => {
capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } })
})
await waitFor(() => {
expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument()
})
})
it('does not show "Last refreshed" when isAgentRunning=false', () => {
render(<SubagentSpawnsDialog {...defaultProps} isAgentRunning={false} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
render(<SubagentSpawnsDialog {...defaultProps} />)
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
})
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
render(<SubagentSpawnsDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ enabled: false })
)
})
})
})

View File

@@ -0,0 +1,7 @@
export interface DrilldownDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
agentId: string
agentName: string
isAgentRunning?: boolean
}

View File

@@ -11,7 +11,7 @@ interface ConflictResolutionPanelProps {
}
export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) {
const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
const { state, agent: _agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
const [showManual, setShowManual] = useState(false);
const prevStateRef = useRef<string | null>(null);

View File

@@ -0,0 +1,192 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { render, screen, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { DiffViewer } from "./DiffViewer";
import type { FileDiff } from "./types";
// ── Module mocks ──────────────────────────────────────────────────────────────
vi.mock("./FileCard", () => ({
FileCard: ({ file }: { file: FileDiff }) => (
<div data-testid="file-card" data-path={file.newPath} />
),
}));
// Hoist the fetch mock so it can be referenced inside vi.mock factories
const { mockGetFileDiffFetch } = vi.hoisted(() => ({
mockGetFileDiffFetch: vi.fn().mockResolvedValue({ rawDiff: "" }),
}));
vi.mock("@/lib/trpc", () => ({
trpc: {
useUtils: () => ({
getFileDiff: { fetch: mockGetFileDiffFetch },
}),
},
}));
// DiffViewer calls useQueryClient() (even though the return value is unused).
// Provide a minimal mock so the hook doesn't throw outside a QueryClientProvider.
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@tanstack/react-query")>();
return { ...actual, useQueryClient: () => ({}) };
});
// ── IntersectionObserver mock ─────────────────────────────────────────────────
let observerCallback: IntersectionObserverCallback | null = null;
const observedElements = new Set<Element>();
// Class (not arrow function) so it can be used with `new IntersectionObserver(...)`
class MockIntersectionObserver {
constructor(cb: IntersectionObserverCallback) {
observerCallback = cb;
}
observe(el: Element) {
observedElements.add(el);
}
unobserve(el: Element) {
observedElements.delete(el);
}
disconnect() {
observedElements.clear();
}
}
beforeEach(() => {
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
observedElements.clear();
observerCallback = null;
mockGetFileDiffFetch.mockClear();
mockGetFileDiffFetch.mockResolvedValue({ rawDiff: "" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Fire the IntersectionObserver callback with a set of intersecting and
* non-intersecting file paths. The target element is simulated by an object
* whose dataset.filePath matches the DiffViewer's data-file-path attribute.
*/
function fireIntersection(
intersectingPaths: string[],
nonIntersectingPaths: string[] = [],
) {
if (!observerCallback) return;
const entries = [
...intersectingPaths.map((p) => ({
isIntersecting: true,
target: { dataset: { filePath: p } } as unknown as Element,
})),
...nonIntersectingPaths.map((p) => ({
isIntersecting: false,
target: { dataset: { filePath: p } } as unknown as Element,
})),
] as IntersectionObserverEntry[];
act(() => {
observerCallback!(entries, {} as IntersectionObserver);
});
}
function makeFiles(count: number): FileDiff[] {
return Array.from({ length: count }, (_, i) => ({
oldPath: `file${i}.ts`,
newPath: `file${i}.ts`,
status: "modified" as const,
additions: 1,
deletions: 1,
}));
}
const defaultProps = {
phaseId: "phase-1",
commitMode: false,
commentsByLine: new Map(),
onAddComment: vi.fn(),
onResolveComment: vi.fn(),
onUnresolveComment: vi.fn(),
};
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("DiffViewer", () => {
it("renders all FileCards when 5 files are all in viewport", () => {
const files = makeFiles(5);
render(<DiffViewer files={files} {...defaultProps} />);
// Trigger all five as intersecting
fireIntersection(files.map((f) => f.newPath));
expect(screen.getAllByTestId("file-card")).toHaveLength(5);
});
it("shows only intersecting FileCards for 300 files, placeholders for the rest", () => {
const files = makeFiles(300);
render(<DiffViewer files={files} {...defaultProps} />);
// Only first 5 files enter the viewport
fireIntersection(files.slice(0, 5).map((f) => f.newPath));
expect(screen.getAllByTestId("file-card")).toHaveLength(5);
// The remaining 295 should be 48px placeholder divs marked aria-hidden
const placeholders = document.querySelectorAll(
'[aria-hidden][style*="height: 48px"]',
);
expect(placeholders.length).toBeGreaterThanOrEqual(295);
});
it("skips IntersectionObserver for single-file diff and renders FileCard directly", () => {
render(<DiffViewer files={makeFiles(1)} {...defaultProps} />);
// Single-file path: isVisible is always true, no intersection event needed
expect(screen.getAllByTestId("file-card")).toHaveLength(1);
});
it("calls scrollIntoView on the wrapper div when onRegisterRef is used for sidebar navigation", () => {
const files = makeFiles(5);
const registeredRefs = new Map<string, HTMLDivElement>();
const onRegisterRef = (filePath: string, el: HTMLDivElement | null) => {
if (el) registeredRefs.set(filePath, el);
};
render(<DiffViewer files={files} {...defaultProps} onRegisterRef={onRegisterRef} />);
// All wrapper divs should have been registered (including the last one)
const targetFile = files[4].newPath;
expect(registeredRefs.has(targetFile)).toBe(true);
const wrapperEl = registeredRefs.get(targetFile)!;
const scrollSpy = vi.fn();
Object.defineProperty(wrapperEl, "scrollIntoView", { value: scrollSpy });
// Simulate a sidebar click that calls scrollIntoView on the wrapper
act(() => {
wrapperEl.scrollIntoView({ behavior: "smooth", block: "start" });
});
expect(scrollSpy).toHaveBeenCalledOnce();
});
it("fires getFileDiff queries in batches of 10 when expandAll is toggled", async () => {
const files = makeFiles(25); // 3 batches: 10, 10, 5
const { rerender } = render(
<DiffViewer files={files} {...defaultProps} expandAll={false} />,
);
rerender(<DiffViewer files={files} {...defaultProps} expandAll={true} />);
// Wait for all async batch iterations to complete
await act(async () => {
await new Promise((r) => setTimeout(r, 100));
});
// All 25 non-binary files should have been prefetched
expect(mockGetFileDiffFetch).toHaveBeenCalledTimes(25);
});
});

View File

@@ -1,9 +1,25 @@
import type { FileDiff, DiffLine, ReviewComment } from "./types";
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
import { FileCard } from "./FileCard";
import { trpc } from "@/lib/trpc";
function getFileCommentMap(
commentsByLine: Map<string, ReviewComment[]>,
filePath: string,
): Map<string, ReviewComment[]> {
const result = new Map<string, ReviewComment[]>();
for (const [key, val] of commentsByLine) {
if (key.startsWith(`${filePath}:`)) result.set(key, val);
}
return result;
}
interface DiffViewerProps {
files: FileDiff[];
comments: ReviewComment[];
files: (FileDiff | FileDiffDetail)[];
phaseId: string;
commitMode: boolean;
commentsByLine: Map<string, ReviewComment[]>;
onAddComment: (
filePath: string,
lineNumber: number,
@@ -17,11 +33,14 @@ interface DiffViewerProps {
viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void;
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
expandAll?: boolean;
}
export function DiffViewer({
files,
comments,
phaseId,
commitMode,
commentsByLine,
onAddComment,
onResolveComment,
onUnresolveComment,
@@ -30,24 +49,156 @@ export function DiffViewer({
viewedFiles,
onToggleViewed,
onRegisterRef,
expandAll,
}: DiffViewerProps) {
// Set of file paths currently intersecting (or near) the viewport
const visibleFiles = useRef<Set<string>>(new Set());
// Map from filePath → wrapper div ref
const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Increment to trigger re-render when visibility changes
const [visibilityVersion, setVisibilityVersion] = useState(0);
// Single IntersectionObserver for all wrappers
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (files.length === 1) return; // skip for single file
observerRef.current = new IntersectionObserver(
(entries) => {
let changed = false;
for (const entry of entries) {
const filePath = (entry.target as HTMLDivElement).dataset['filePath'];
if (!filePath) continue;
if (entry.isIntersecting) {
if (!visibleFiles.current.has(filePath)) {
visibleFiles.current.add(filePath);
changed = true;
}
} else {
if (visibleFiles.current.has(filePath)) {
visibleFiles.current.delete(filePath);
changed = true;
}
}
}
if (changed) setVisibilityVersion((v) => v + 1);
},
{ rootMargin: '100% 0px 100% 0px' }, // 1× viewport above and below
);
// Observe all current wrapper divs
for (const el of wrapperRefs.current.values()) {
observerRef.current.observe(el);
}
return () => {
observerRef.current?.disconnect();
};
}, [files]); // re-create observer when file list changes
// Register wrapper ref — observes the div, registers with parent
const registerWrapper = useCallback(
(filePath: string, el: HTMLDivElement | null) => {
if (el) {
wrapperRefs.current.set(filePath, el);
observerRef.current?.observe(el);
} else {
const prev = wrapperRefs.current.get(filePath);
if (prev) observerRef.current?.unobserve(prev);
wrapperRefs.current.delete(filePath);
}
onRegisterRef?.(filePath, el);
},
[onRegisterRef],
);
// expandAll batch loading
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
const utils = trpc.useUtils();
useEffect(() => {
if (!expandAll || files.length === 0) return;
const BATCH = 10;
let cancelled = false;
async function batchExpand() {
const chunks: (FileDiff | FileDiffDetail)[][] = [];
for (let i = 0; i < files.length; i += BATCH) {
chunks.push(files.slice(i, i + BATCH));
}
for (const chunk of chunks) {
if (cancelled) break;
// Mark this batch as expanded (triggers FileCard renders + queries)
setExpandedFiles((prev) => {
const next = new Set(prev);
for (const f of chunk) {
if (f.status !== 'binary') next.add(f.newPath);
}
return next;
});
// Eagerly prefetch via React Query to saturate network
await Promise.all(
chunk
.filter((f) => f.status !== 'binary' && !('hunks' in f))
.map((f) =>
utils.getFileDiff
.fetch({ phaseId, filePath: encodeURIComponent(f.newPath) })
.catch(() => null), // swallow per-file errors; FileCard shows its own error state
),
);
}
}
batchExpand();
return () => {
cancelled = true;
};
}, [expandAll]); // only re-run when expandAll toggles
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only on expandAll
// Suppress unused variable warning — used only to force re-render on visibility change
void visibilityVersion;
void queryClient; // imported for type alignment; actual prefetch goes through trpc utils
const isSingleFile = files.length === 1;
return (
<div className="space-y-4">
{files.map((file) => (
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
<FileCard
file={file}
comments={comments.filter((c) => c.filePath === file.newPath)}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)}
/>
</div>
))}
{files.map((file) => {
const isVisible = isSingleFile || visibleFiles.current.has(file.newPath);
const isExpandedOverride = expandedFiles.has(file.newPath) ? true : undefined;
return (
<div
key={file.newPath}
ref={(el) => registerWrapper(file.newPath, el)}
data-file-path={file.newPath}
>
{isVisible ? (
<FileCard
file={file as FileDiff}
detail={'hunks' in file ? (file as FileDiffDetail) : undefined}
phaseId={phaseId}
commitMode={commitMode}
commentsByLine={getFileCommentMap(commentsByLine, file.newPath)}
isExpandedOverride={isExpandedOverride}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)}
/>
) : (
<div style={{ height: '48px' }} aria-hidden />
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,270 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { FileCard } from "./FileCard";
import type { FileDiff, FileDiffDetail } from "./types";
// ── Module mocks ──────────────────────────────────────────────────────────────
vi.mock("./HunkRows", () => ({
HunkRows: ({ hunk }: { hunk: { header: string } }) => (
<tr data-testid="hunk-row">
<td>{hunk.header}</td>
</tr>
),
}));
vi.mock("./use-syntax-highlight", () => ({
useHighlightedFile: () => null,
}));
// Hoist mocks so they can be referenced in vi.mock factories
const { mockGetFileDiff, mockParseUnifiedDiff } = vi.hoisted(() => ({
mockGetFileDiff: vi.fn(),
mockParseUnifiedDiff: vi.fn(),
}));
vi.mock("@/lib/trpc", () => ({
trpc: {
getFileDiff: {
useQuery: (
input: unknown,
opts: { enabled: boolean; staleTime?: number },
) => mockGetFileDiff(input, opts),
},
},
}));
vi.mock("./parse-diff", () => ({
parseUnifiedDiff: (rawDiff: string) => mockParseUnifiedDiff(rawDiff),
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeFile(overrides: Partial<FileDiff> = {}): FileDiff {
return {
oldPath: "src/foo.ts",
newPath: "src/foo.ts",
status: "modified",
additions: 10,
deletions: 5,
...overrides,
};
}
const defaultProps = {
phaseId: "phase-1",
commitMode: false,
commentsByLine: new Map(),
onAddComment: vi.fn(),
onResolveComment: vi.fn(),
onUnresolveComment: vi.fn(),
};
beforeEach(() => {
mockGetFileDiff.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
});
// Default: return empty parse result
mockParseUnifiedDiff.mockReturnValue([]);
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("FileCard", () => {
it("starts collapsed and does not enable getFileDiff query", () => {
render(<FileCard file={makeFile()} {...defaultProps} />);
// Query must be called with enabled: false while card is collapsed
expect(mockGetFileDiff).toHaveBeenCalledWith(
expect.objectContaining({
filePath: encodeURIComponent("src/foo.ts"),
}),
expect.objectContaining({ enabled: false }),
);
// No hunk rows rendered in the collapsed state
expect(screen.queryByTestId("hunk-row")).toBeNull();
});
it("enables query and shows loading spinner when expanded", () => {
mockGetFileDiff.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
refetch: vi.fn(),
});
render(<FileCard file={makeFile()} {...defaultProps} />);
fireEvent.click(screen.getByRole("button"));
// After expanding, query should be called with enabled: true
expect(mockGetFileDiff).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ enabled: true }),
);
// Loading spinner should be visible
expect(screen.getByText(/Loading diff/i)).toBeInTheDocument();
});
it("renders HunkRows when query succeeds", async () => {
mockGetFileDiff.mockReturnValue({
data: {
binary: false,
rawDiff:
"diff --git a/src/foo.ts b/src/foo.ts\n@@ -1,3 +1,3 @@\n context\n",
},
isLoading: false,
isError: false,
refetch: vi.fn(),
});
mockParseUnifiedDiff.mockReturnValue([
{
oldPath: "src/foo.ts",
newPath: "src/foo.ts",
status: "modified",
additions: 0,
deletions: 0,
hunks: [
{
header: "@@ -1,3 +1,3 @@",
oldStart: 1,
oldCount: 3,
newStart: 1,
newCount: 3,
lines: [],
},
],
},
]);
render(<FileCard file={makeFile()} {...defaultProps} />);
fireEvent.click(screen.getByRole("button"));
await waitFor(() => {
expect(screen.getByTestId("hunk-row")).toBeInTheDocument();
});
});
it("shows error state with Retry button; clicking retry calls refetch", () => {
const refetch = vi.fn();
mockGetFileDiff.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
refetch,
});
render(<FileCard file={makeFile()} {...defaultProps} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByText(/Failed to load diff/i)).toBeInTheDocument();
const retryBtn = screen.getByRole("button", { name: /retry/i });
fireEvent.click(retryBtn);
expect(refetch).toHaveBeenCalledOnce();
});
it("shows binary message on expand and does not enable getFileDiff query", () => {
render(<FileCard file={makeFile({ status: "binary" })} {...defaultProps} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByText(/Binary file/i)).toBeInTheDocument();
// Query must never be enabled for binary files
expect(mockGetFileDiff).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ enabled: false }),
);
});
it("shows No content changes when parsed hunks array is empty", async () => {
mockGetFileDiff.mockReturnValue({
data: {
binary: false,
rawDiff: "diff --git a/src/foo.ts b/src/foo.ts\nsome content\n",
},
isLoading: false,
isError: false,
refetch: vi.fn(),
});
mockParseUnifiedDiff.mockReturnValue([
{
oldPath: "src/foo.ts",
newPath: "src/foo.ts",
status: "modified",
additions: 0,
deletions: 0,
hunks: [],
},
]);
render(<FileCard file={makeFile()} {...defaultProps} />);
fireEvent.click(screen.getByRole("button"));
await waitFor(() => {
expect(screen.getByText(/No content changes/i)).toBeInTheDocument();
});
});
it("renders pre-parsed hunks from detail prop without fetching", () => {
const detail: FileDiffDetail = {
oldPath: "src/foo.ts",
newPath: "src/foo.ts",
status: "modified",
additions: 5,
deletions: 2,
hunks: [
{
header: "@@ -1 +1 @@",
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [],
},
],
};
render(<FileCard file={makeFile()} detail={detail} {...defaultProps} />);
// Should start expanded because detail prop is provided
expect(screen.getByTestId("hunk-row")).toBeInTheDocument();
// Query must not be enabled when detail prop is present
expect(mockGetFileDiff).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ enabled: false }),
);
});
it("does not refetch when collapsing and re-expanding", () => {
// Simulate data already available (as if previously fetched and cached)
mockGetFileDiff.mockReturnValue({
data: { binary: false, rawDiff: "" },
isLoading: false,
isError: false,
refetch: vi.fn(),
});
render(<FileCard file={makeFile()} {...defaultProps} />);
const headerBtn = screen.getByRole("button");
// Expand: query enabled, data shown immediately (no loading)
fireEvent.click(headerBtn);
expect(screen.queryByText(/Loading diff/i)).toBeNull();
// Collapse
fireEvent.click(headerBtn);
// Re-expand: should not enter loading state (data still available)
fireEvent.click(headerBtn);
expect(screen.queryByText(/Loading diff/i)).toBeNull();
});
});

View File

@@ -6,16 +6,16 @@ import {
Minus,
CheckCircle2,
Circle,
Loader2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { FileDiff, FileChangeType, DiffLine, ReviewComment } from "./types";
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
import { HunkRows } from "./HunkRows";
import { useHighlightedFile } from "./use-syntax-highlight";
import { parseUnifiedDiff } from "./parse-diff";
import { trpc } from "@/lib/trpc";
const changeTypeBadge: Record<
FileChangeType,
{ label: string; classes: string } | null
> = {
const statusBadge: Record<FileDiff['status'], { label: string; classes: string } | null> = {
added: {
label: "NEW",
classes:
@@ -32,18 +32,27 @@ const changeTypeBadge: Record<
"bg-status-active-bg text-status-active-fg border-status-active-border",
},
modified: null,
binary: {
label: "BINARY",
classes: "bg-muted text-muted-foreground border-border",
},
};
const leftBorderClass: Record<FileChangeType, string> = {
const leftBorderClass: Record<FileDiff['status'], string> = {
added: "border-l-2 border-l-status-success-fg",
deleted: "border-l-2 border-l-status-error-fg",
renamed: "border-l-2 border-l-status-active-fg",
modified: "border-l-2 border-l-primary/40",
binary: "border-l-2 border-l-primary/40",
};
interface FileCardProps {
file: FileDiff;
comments: ReviewComment[];
detail?: FileDiffDetail;
phaseId: string;
commitMode: boolean;
commentsByLine: Map<string, ReviewComment[]>;
isExpandedOverride?: boolean;
onAddComment: (
filePath: string,
lineNumber: number,
@@ -60,7 +69,11 @@ interface FileCardProps {
export function FileCard({
file,
comments,
detail,
phaseId,
commitMode,
commentsByLine,
isExpandedOverride,
onAddComment,
onResolveComment,
onUnresolveComment,
@@ -69,26 +82,65 @@ export function FileCard({
isViewed = false,
onToggleViewed = () => {},
}: FileCardProps) {
const [expanded, setExpanded] = useState(true);
const commentCount = comments.length;
const badge = changeTypeBadge[file.changeType];
// Uncontrolled expand for normal file clicks.
// Start expanded if detail prop is provided (commit mode).
const [isExpandedLocal, setIsExpandedLocal] = useState(() => !!detail);
// Flatten all hunk lines for syntax highlighting
const allLines = useMemo(
() => file.hunks.flatMap((h) => h.lines),
[file.hunks],
// Merge with override from DiffViewer expandAll
const isExpanded = isExpandedOverride ?? isExpandedLocal;
const fileDiffQuery = trpc.getFileDiff.useQuery(
{ phaseId, filePath: encodeURIComponent(file.newPath) },
{
enabled: isExpanded && !commitMode && file.status !== 'binary' && !detail,
staleTime: Infinity,
},
);
// Compute hunks from query data (phase mode)
const parsedHunks = useMemo(() => {
if (!fileDiffQuery.data?.rawDiff) return null;
const parsed = parseUnifiedDiff(fileDiffQuery.data.rawDiff);
return parsed[0] ?? null;
}, [fileDiffQuery.data]);
// Collect all lines for syntax highlighting
const allLines = useMemo(() => {
if (detail) return detail.hunks.flatMap((h) => h.lines);
if (parsedHunks) return parsedHunks.hunks.flatMap((h) => h.lines);
return [];
}, [detail, parsedHunks]);
const tokenMap = useHighlightedFile(file.newPath, allLines);
const commentCount = useMemo(() => {
let count = 0;
for (const [key, arr] of commentsByLine) {
if (key.startsWith(`${file.newPath}:`)) count += arr.length;
}
return count;
}, [commentsByLine, file.newPath]);
const badge = statusBadge[file.status];
const handlers = {
onAddComment,
onResolveComment,
onUnresolveComment,
onReplyComment,
onEditComment,
tokenMap,
};
return (
<div className="rounded-lg border border-border overflow-clip">
{/* File header — sticky so it stays visible when scrolling */}
{/* File header */}
<button
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.status]}`}
style={{ top: 'var(--review-header-h, 0px)' }}
onClick={() => setExpanded(!expanded)}
onClick={() => setIsExpandedLocal(!isExpandedLocal)}
>
{expanded ? (
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -149,26 +201,63 @@ export function FileCard({
</button>
{/* Diff content */}
{expanded && (
{isExpanded && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{file.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
comments={comments}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokenMap={tokenMap}
/>
))}
</tbody>
</table>
{detail ? (
// Commit mode: pre-parsed hunks from detail prop
detail.hunks.length === 0 ? (
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
) : (
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{detail.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
commentsByLine={commentsByLine}
{...handlers}
/>
))}
</tbody>
</table>
)
) : file.status === 'binary' ? (
<div className="px-4 py-3 text-xs text-muted-foreground">Binary file diff not shown</div>
) : fileDiffQuery.isLoading ? (
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading diff
</div>
) : fileDiffQuery.isError ? (
<div className="flex items-center gap-2 px-4 py-3 text-xs text-destructive">
Failed to load diff.
<button
className="underline hover:no-underline"
onClick={() => fileDiffQuery.refetch()}
>
Retry
</button>
</div>
) : fileDiffQuery.data ? (
!parsedHunks || parsedHunks.hunks.length === 0 ? (
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
) : (
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{parsedHunks.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
commentsByLine={commentsByLine}
{...handlers}
/>
))}
</tbody>
</table>
)
) : null}
</div>
)}
</div>

View File

@@ -6,7 +6,7 @@ import type { LineTokenMap } from "./use-syntax-highlight";
interface HunkRowsProps {
hunk: { header: string; lines: DiffLine[] };
filePath: string;
comments: ReviewComment[];
commentsByLine: Map<string, ReviewComment[]>;
onAddComment: (
filePath: string,
lineNumber: number,
@@ -23,7 +23,7 @@ interface HunkRowsProps {
export function HunkRows({
hunk,
filePath,
comments,
commentsByLine,
onAddComment,
onResolveComment,
onUnresolveComment,
@@ -81,9 +81,9 @@ export function HunkRows({
{hunk.lines.map((line, li) => {
const lineKey = line.newLineNumber ?? line.oldLineNumber ?? li;
const lineComments = comments.filter(
(c) => c.lineNumber === lineKey && c.lineType === line.type,
);
// O(1) map lookup — replaces the previous O(n) filter
const lineComments =
commentsByLine.get(`${filePath}:${lineKey}:${line.type}`) ?? [];
const isCommenting =
commentingLine?.lineNumber === lineKey &&
commentingLine?.lineType === line.type;

View File

@@ -308,7 +308,7 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
) : (
<DiffViewer
files={files}
comments={[]}
commentsByLine={new Map()}
onAddComment={() => {}}
onResolveComment={() => {}}
onUnresolveComment={() => {}}

View File

@@ -42,6 +42,9 @@ interface ReviewHeaderProps {
preview: PreviewState | null;
viewedCount?: number;
totalCount?: number;
totalAdditions?: number;
totalDeletions?: number;
onExpandAll?: () => void;
}
export function ReviewHeader({
@@ -62,9 +65,12 @@ export function ReviewHeader({
preview,
viewedCount,
totalCount,
totalAdditions: totalAdditionsProp,
totalDeletions: totalDeletionsProp,
onExpandAll,
}: ReviewHeaderProps) {
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
const totalAdditions = totalAdditionsProp ?? files.reduce((s, f) => s + f.additions, 0);
const totalDeletions = totalDeletionsProp ?? files.reduce((s, f) => s + f.deletions, 0);
const [showConfirmation, setShowConfirmation] = useState(false);
const [showRequestConfirm, setShowRequestConfirm] = useState(false);
const confirmRef = useRef<HTMLDivElement>(null);
@@ -186,6 +192,16 @@ export function ReviewHeader({
{/* Right: preview + actions */}
<div className="flex items-center gap-3 shrink-0">
{onExpandAll && (
<Button
variant="ghost"
size="sm"
onClick={onExpandAll}
className="h-7 text-xs px-2 text-muted-foreground"
>
Expand all
</Button>
)}
{/* Preview controls */}
{preview && <PreviewControls preview={preview} />}

View File

@@ -0,0 +1,193 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ReviewSidebar } from './ReviewSidebar';
import type { FileDiff, ReviewComment, CommitInfo } from './types';
// Mock ResizeObserver — not provided by happy-dom.
// react-window 2.x uses `new ResizeObserver()` internally.
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', MockResizeObserver);
// Mock react-window to avoid ESM/CJS duplicate-React-instance errors in Vitest.
// The mock renders only the first 15 rows, simulating windowed rendering.
// It also exposes a `listRef`-compatible imperative handle so scroll-save/restore logic runs.
vi.mock('react-window', () => ({
List: vi.fn(({ rowComponent: RowComponent, rowCount, rowProps, listRef }: any) => {
// Expose the imperative API via the ref (synchronous assignment is safe in tests).
if (listRef && typeof listRef === 'object' && 'current' in listRef) {
listRef.current = { element: { scrollTop: 0 }, scrollToRow: vi.fn() };
}
const renderCount = Math.min(rowCount ?? 0, 15);
return (
<div data-testid="virtual-list">
{Array.from({ length: renderCount }, (_, i) => (
<RowComponent key={i} index={i} style={{}} ariaAttributes={{}} {...rowProps} />
))}
</div>
);
}),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeFile(path: string): FileDiff {
return {
oldPath: path,
newPath: path,
hunks: [],
additions: 1,
deletions: 0,
changeType: 'modified',
};
}
function makeFiles(count: number, prefix = 'src/components/'): FileDiff[] {
return Array.from({ length: count }, (_, i) =>
makeFile(`${prefix}file${String(i).padStart(4, '0')}.ts`),
);
}
const NO_COMMENTS: ReviewComment[] = [];
const NO_COMMITS: CommitInfo[] = [];
function renderSidebar(files: FileDiff[]) {
return render(
<ReviewSidebar
files={files}
comments={NO_COMMENTS}
onFileClick={vi.fn()}
selectedCommit={null}
activeFiles={files}
commits={NO_COMMITS}
onSelectCommit={vi.fn()}
/>,
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('ReviewSidebar FilesView virtualization', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
// 1. Virtual list NOT used for ≤50 files (fallback path)
it('does not use virtual list when files count is ≤50', () => {
renderSidebar(makeFiles(10));
expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument();
// All 10 file rows are in the DOM directly
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(10);
});
// 2. Virtual list IS used for >50 files (virtualized path)
it('uses virtual list when files count is >50', () => {
renderSidebar(makeFiles(1000));
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
// Mock renders only 15 rows — far fewer than 1000
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeLessThan(50);
});
// 3. Directory collapse removes file rows from the virtual list
it('removes file rows from virtual list when directory is collapsed', async () => {
// 100 files all in "src/" — produces 101 rows (1 dir-header + 100 files), which is >50
const files = Array.from({ length: 100 }, (_, i) => makeFile(`src/file${i}.ts`));
renderSidebar(files);
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
const dirHeader = screen.getByRole('button', { name: /src\// });
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
await act(async () => {
fireEvent.click(dirHeader);
});
// After collapse: only the dir-header row remains in the virtual list
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
expect(document.querySelectorAll('[data-testid="dir-header"]').length).toBe(1);
});
// 3a. Expanding a collapsed directory restores file rows
it('restores file rows when a collapsed directory is expanded again', async () => {
const files = makeFiles(60, 'src/components/');
renderSidebar(files);
const dirHeader = screen.getByRole('button', { name: /src\/components\// });
// Collapse
await act(async () => {
fireEvent.click(dirHeader);
});
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
// Expand again
const freshDirHeader = screen.getByRole('button', { name: /src\/components\// });
await act(async () => {
fireEvent.click(freshDirHeader);
});
// File rows are back (virtual list renders up to 15)
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
expect(document.querySelectorAll('[data-testid="dir-header"]').length).toBeGreaterThan(0);
});
// 4. Scroll position saved and restored on Files ↔ Commits tab switch
it('restores file rows when returning to Files tab after switching to Commits tab', async () => {
renderSidebar(makeFiles(200));
// Files tab is default — file rows are visible
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
// Switch to Commits tab — FilesView unmounts (scroll offset is saved)
await act(async () => {
fireEvent.click(screen.getByTitle('Commits'));
});
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
// Switch back to Files tab — FilesView remounts (scroll offset is restored)
await act(async () => {
fireEvent.click(screen.getByTitle('Files'));
});
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
});
// 5. Clicking a file calls onFileClick with the correct path
it('calls onFileClick when a file row is clicked', () => {
const onFileClick = vi.fn();
const files = makeFiles(5);
render(
<ReviewSidebar
files={files}
comments={NO_COMMENTS}
onFileClick={onFileClick}
selectedCommit={null}
activeFiles={files}
commits={NO_COMMITS}
onSelectCommit={vi.fn()}
/>,
);
const fileButtons = document.querySelectorAll('[data-testid="file-row"]');
expect(fileButtons.length).toBeGreaterThan(0);
fireEvent.click(fileButtons[0]);
// First file after alphabetical sort within the directory
expect(onFileClick).toHaveBeenCalledWith(files[0].newPath);
});
// 6. Root-level files (no subdirectory) render without a directory header
it('root-level files render without a directory header', () => {
const files = makeFiles(10, ''); // no prefix → root-level files
renderSidebar(files);
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(10);
expect(document.querySelectorAll('[data-testid="dir-header"]').length).toBe(0);
});
});

View File

@@ -1,8 +1,15 @@
import { useMemo, useState } from "react";
import { useMemo, useState, useRef, useEffect, useCallback } from "react";
// Using react-window 2.x (installed version). The task spec was written for react-window 1.x
// (VariableSizeList API). react-window 2.x provides a `List` component with a different but
// equivalent API: it handles ResizeObserver internally (no explicit height/width props needed),
// uses `rowComponent`/`rowProps` for rendering, and exposes `scrollToRow` via `listRef`.
import { List } from "react-window";
import type { RowComponentProps, ListImperativeAPI } from "react-window";
import {
MessageSquare,
FileCode,
FolderOpen,
ChevronRight,
Plus,
Minus,
Circle,
@@ -38,6 +45,8 @@ export function ReviewSidebar({
viewedFiles = new Set(),
}: ReviewSidebarProps) {
const [view, setView] = useState<SidebarView>("files");
// Persist Files-tab scroll offset across Files ↔ Commits switches
const filesScrollOffsetRef = useRef<number>(0);
return (
<div className="flex h-full">
@@ -58,8 +67,8 @@ export function ReviewSidebar({
/>
</div>
{/* Content panel */}
<div className="flex-1 min-w-0 overflow-y-auto p-4">
{/* Content panel — flex column so FilesView can stretch and manage its own scroll */}
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{view === "files" ? (
<FilesView
files={files}
@@ -69,13 +78,16 @@ export function ReviewSidebar({
selectedCommit={selectedCommit}
activeFiles={activeFiles}
viewedFiles={viewedFiles}
scrollOffsetRef={filesScrollOffsetRef}
/>
) : (
<CommitsView
commits={commits}
selectedCommit={selectedCommit}
onSelectCommit={onSelectCommit}
/>
<div className="overflow-y-auto p-4 flex-1">
<CommitsView
commits={commits}
selectedCommit={selectedCommit}
onSelectCommit={onSelectCommit}
/>
</div>
)}
</div>
</div>
@@ -171,6 +183,109 @@ const changeTypeDotColor: Record<string, string> = {
renamed: "bg-status-active-fg",
};
// ─── Row type for virtualized list ───
type Row =
| { kind: "dir-header"; dirName: string; fileCount: number; isCollapsed: boolean }
| { kind: "file"; file: FileDiff; dirName: string; isViewed: boolean; commentCount: number };
// Item heights: dir-header ≈ 32px (py-0.5 + icon), file row ≈ 40px (py-1 + text)
const DIR_HEADER_HEIGHT = 32;
const FILE_ROW_HEIGHT = 40;
// ─── Virtualized row component (must be stable — defined outside FilesView) ───
type VirtualRowProps = {
rows: Row[];
selectedCommit: string | null;
activeFilePaths: Set<string>;
onFileClick: (filePath: string) => void;
onToggleDir: (dirName: string) => void;
};
function VirtualRowItem({
index,
style,
rows,
selectedCommit,
activeFilePaths,
onFileClick,
onToggleDir,
}: RowComponentProps<VirtualRowProps>) {
const row = rows[index];
if (!row) return null;
if (row.kind === "dir-header") {
return (
<button
data-testid="dir-header"
style={style}
className="flex w-full items-center gap-1 text-[10px] font-mono text-muted-foreground/70 px-2 hover:bg-accent/30 transition-colors"
onClick={() => onToggleDir(row.dirName)}
title={row.isCollapsed ? "Expand directory" : "Collapse directory"}
>
<ChevronRight
className={`h-3 w-3 shrink-0 transition-transform ${row.isCollapsed ? "" : "rotate-90"}`}
/>
<FolderOpen className="h-3 w-3 shrink-0" />
<span className="truncate">{row.dirName}</span>
</button>
);
}
// kind === "file"
const { file, dirName, isViewed, commentCount } = row;
const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView;
const dotColor = changeTypeDotColor[file.changeType];
return (
<button
data-testid="file-row"
style={style}
className={`
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
hover:bg-accent/50 transition-colors group
${dirName ? "pl-4 pr-2" : "px-2"}
${dimmed ? "opacity-35" : ""}
`}
onClick={() => onFileClick(file.newPath)}
>
{isViewed ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
)}
{dotColor && (
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
)}
<span className="truncate flex-1 font-mono">
{getFileName(file.newPath)}
</span>
<span className="flex items-center gap-1 shrink-0">
{commentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{commentCount}
</span>
)}
{file.additions > 0 && (
<span className="text-diff-add-fg text-[10px]">
<Plus className="h-2.5 w-2.5 inline" />
{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="text-diff-remove-fg text-[10px]">
<Minus className="h-2.5 w-2.5 inline" />
{file.deletions}
</span>
)}
</span>
</button>
);
}
function FilesView({
files,
comments,
@@ -179,6 +294,7 @@ function FilesView({
selectedCommit,
activeFiles,
viewedFiles,
scrollOffsetRef,
}: {
files: FileDiff[];
comments: ReviewComment[];
@@ -187,10 +303,14 @@ function FilesView({
selectedCommit: string | null;
activeFiles: FileDiff[];
viewedFiles: Set<string>;
scrollOffsetRef: React.MutableRefObject<number>;
}) {
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length;
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
const activeFilePaths = useMemo(
() => new Set(activeFiles.map((f) => f.newPath)),
[activeFiles],
);
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
@@ -198,169 +318,308 @@ function FilesView({
const totalCount = files.length;
const progressPercent = totalCount > 0 ? (viewedCount / totalCount) * 100 : 0;
// ─── Collapse state ───
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set());
const toggleDir = useCallback((dirName: string) => {
setCollapsedDirs((prev) => {
const next = new Set(prev);
if (next.has(dirName)) next.delete(dirName);
else next.add(dirName);
return next;
});
}, []);
// ─── Flat row list for virtualization ───
const rows = useMemo<Row[]>(() => {
const result: Row[] = [];
for (const group of directoryGroups) {
const isCollapsed = collapsedDirs.has(group.directory);
// Root-level files (directory === "") get no dir-header, preserving existing behavior
if (group.directory) {
result.push({
kind: "dir-header",
dirName: group.directory,
fileCount: group.files.length,
isCollapsed,
});
}
if (!isCollapsed) {
for (const file of group.files) {
const commentCount = comments.filter(
(c) => c.filePath === file.newPath && !c.parentCommentId,
).length;
result.push({
kind: "file",
file,
dirName: group.directory,
isViewed: viewedFiles.has(file.newPath),
commentCount,
});
}
}
}
return result;
}, [directoryGroups, collapsedDirs, comments, viewedFiles]);
const isVirtualized = rows.length > 50;
// ─── react-window 2.x imperative ref ───
const listRef = useRef<ListImperativeAPI | null>(null);
// Fallback container ref for non-virtualized path
const containerRef = useRef<HTMLDivElement | null>(null);
// Restore scroll position on mount (both paths)
useEffect(() => {
const offset = scrollOffsetRef.current;
if (!offset) return;
if (isVirtualized) {
// react-window 2.x: scroll via the outermost DOM element
const el = listRef.current?.element;
if (el) el.scrollTop = offset;
} else if (containerRef.current) {
containerRef.current.scrollTop = offset;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // only on mount
// Save scroll position on unmount (both paths)
useEffect(() => {
return () => {
if (isVirtualized) {
scrollOffsetRef.current = listRef.current?.element?.scrollTop ?? 0;
} else {
scrollOffsetRef.current = containerRef.current?.scrollTop ?? 0;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVirtualized]);
// Row height function for react-window 2.x List
const rowHeight = useCallback(
(index: number) => (rows[index]?.kind === "dir-header" ? DIR_HEADER_HEIGHT : FILE_ROW_HEIGHT),
[rows],
);
// Handle file click: call onFileClick and scroll virtual list to row
const handleFileClick = useCallback(
(filePath: string) => {
onFileClick(filePath);
const rowIndex = rows.findIndex(
(r) => r.kind === "file" && r.file.newPath === filePath,
);
if (rowIndex >= 0) {
listRef.current?.scrollToRow({ index: rowIndex, align: "smart" });
}
},
[onFileClick, rows, listRef],
);
// Stable row props for the virtual row component
const rowProps = useMemo<VirtualRowProps>(
() => ({
rows,
selectedCommit,
activeFilePaths,
onFileClick: handleFileClick,
onToggleDir: toggleDir,
}),
[rows, selectedCommit, activeFilePaths, handleFileClick, toggleDir],
);
return (
<div className="space-y-4">
{/* Review progress */}
{totalCount > 0 && (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Review Progress
</h4>
<div className="h-1 rounded-full bg-muted w-full">
<div
className="h-full rounded-full bg-status-success-fg transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground">
{viewedCount}/{totalCount} files viewed
</span>
</div>
)}
{/* Discussions — individual threads */}
{comments.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
<span>Discussions</span>
<span className="flex items-center gap-2 font-normal normal-case">
{unresolvedCount > 0 && (
<span className="flex items-center gap-0.5 text-status-warning-fg">
<Circle className="h-2.5 w-2.5" />
{unresolvedCount}
</span>
)}
{resolvedCount > 0 && (
<span className="flex items-center gap-0.5 text-status-success-fg">
<CheckCircle2 className="h-2.5 w-2.5" />
{resolvedCount}
</span>
)}
<div className="flex flex-col h-full min-h-0">
{/* Fixed header — review progress + discussions */}
<div className="p-4 space-y-4 shrink-0">
{/* Review progress */}
{totalCount > 0 && (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Review Progress
</h4>
<div className="h-1 rounded-full bg-muted w-full">
<div
className="h-full rounded-full bg-status-success-fg transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground">
{viewedCount}/{totalCount} files viewed
</span>
</h4>
<div className="space-y-0.5">
{comments
.filter((c) => !c.parentCommentId)
.map((thread) => {
const replyCount = comments.filter(
(c) => c.parentCommentId === thread.id,
).length;
return (
<button
key={thread.id}
className={`
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
transition-colors hover:bg-accent/50
${thread.resolved ? "opacity-50" : ""}
`}
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
>
<div className="flex items-center gap-1.5 w-full min-w-0">
{thread.resolved ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
)}
<span className="text-[10px] font-mono text-muted-foreground truncate">
{getFileName(thread.filePath)}:{thread.lineNumber}
</span>
{replyCount > 0 && (
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
{replyCount}
</span>
)}
</div>
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
{thread.body.length > 60
? thread.body.slice(0, 57) + "..."
: thread.body}
</span>
</button>
);
})}
</div>
</div>
)}
)}
{/* Directory-grouped file tree */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Files
{selectedCommit && (
<span className="font-normal ml-1 normal-case">
({activeFiles.length} in commit)
</span>
)}
</h4>
{directoryGroups.map((group) => (
<div key={group.directory}>
{/* Directory header */}
{group.directory && (
<div className="text-[10px] font-mono text-muted-foreground/70 mt-2 first:mt-0 px-2 py-0.5 flex items-center gap-1">
<FolderOpen className="h-3 w-3 shrink-0" />
<span className="truncate">{group.directory}</span>
</div>
)}
{/* Files in directory */}
{/* Discussions — individual threads */}
{comments.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
<span>Discussions</span>
<span className="flex items-center gap-2 font-normal normal-case">
{unresolvedCount > 0 && (
<span className="flex items-center gap-0.5 text-status-warning-fg">
<Circle className="h-2.5 w-2.5" />
{unresolvedCount}
</span>
)}
{resolvedCount > 0 && (
<span className="flex items-center gap-0.5 text-status-success-fg">
<CheckCircle2 className="h-2.5 w-2.5" />
{resolvedCount}
</span>
)}
</span>
</h4>
<div className="space-y-0.5">
{group.files.map((file) => {
const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath && !c.parentCommentId,
).length;
const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView;
const isViewed = viewedFiles.has(file.newPath);
const dotColor = changeTypeDotColor[file.changeType];
return (
<button
key={file.newPath}
className={`
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
hover:bg-accent/50 transition-colors group
${group.directory ? "pl-4 pr-2" : "px-2"}
${dimmed ? "opacity-35" : ""}
`}
onClick={() => onFileClick(file.newPath)}
>
{isViewed ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
)}
{dotColor && (
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
)}
<span className="truncate flex-1 font-mono">
{getFileName(file.newPath)}
</span>
<span className="flex items-center gap-1 shrink-0">
{fileCommentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{fileCommentCount}
{comments
.filter((c) => !c.parentCommentId)
.map((thread) => {
const replyCount = comments.filter(
(c) => c.parentCommentId === thread.id,
).length;
return (
<button
key={thread.id}
className={`
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
transition-colors hover:bg-accent/50
${thread.resolved ? "opacity-50" : ""}
`}
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
>
<div className="flex items-center gap-1.5 w-full min-w-0">
{thread.resolved ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
)}
<span className="text-[10px] font-mono text-muted-foreground truncate">
{getFileName(thread.filePath)}:{thread.lineNumber}
</span>
)}
{file.additions > 0 && (
<span className="text-diff-add-fg text-[10px]">
<Plus className="h-2.5 w-2.5 inline" />
{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="text-diff-remove-fg text-[10px]">
<Minus className="h-2.5 w-2.5 inline" />
{file.deletions}
</span>
)}
</span>
</button>
);
})}
{replyCount > 0 && (
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
{replyCount}
</span>
)}
</div>
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
{thread.body.length > 60
? thread.body.slice(0, 57) + "..."
: thread.body}
</span>
</button>
);
})}
</div>
</div>
))}
)}
{/* Files section heading */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Files
{selectedCommit && (
<span className="font-normal ml-1 normal-case">
({activeFiles.length} in commit)
</span>
)}
</h4>
</div>
</div>
{/* Scrollable file tree — virtualized (react-window 2.x List) when >50 rows */}
{isVirtualized ? (
<List
listRef={listRef}
rowCount={rows.length}
rowHeight={rowHeight}
rowComponent={VirtualRowItem}
rowProps={rowProps}
defaultHeight={600}
style={{ flex: 1, minHeight: 0 }}
/>
) : (
<div ref={containerRef} className="overflow-y-auto px-4 pb-4">
{directoryGroups.map((group) => (
<div key={group.directory}>
{/* Directory header — collapsible */}
{group.directory && (
<button
data-testid="dir-header"
className="flex w-full items-center gap-1 text-[10px] font-mono text-muted-foreground/70 mt-2 first:mt-0 px-2 py-0.5 hover:bg-accent/30 transition-colors"
onClick={() => toggleDir(group.directory)}
title={collapsedDirs.has(group.directory) ? "Expand directory" : "Collapse directory"}
>
<ChevronRight
className={`h-3 w-3 shrink-0 transition-transform ${collapsedDirs.has(group.directory) ? "" : "rotate-90"}`}
/>
<FolderOpen className="h-3 w-3 shrink-0" />
<span className="truncate">{group.directory}</span>
</button>
)}
{/* Files in directory */}
{!collapsedDirs.has(group.directory) && (
<div className="space-y-0.5">
{group.files.map((file) => {
const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath && !c.parentCommentId,
).length;
const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView;
const isViewed = viewedFiles.has(file.newPath);
const dotColor = changeTypeDotColor[file.changeType];
return (
<button
key={file.newPath}
data-testid="file-row"
className={`
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
hover:bg-accent/50 transition-colors group
${group.directory ? "pl-4 pr-2" : "px-2"}
${dimmed ? "opacity-35" : ""}
`}
onClick={() => onFileClick(file.newPath)}
>
{isViewed ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
)}
{dotColor && (
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
)}
<span className="truncate flex-1 font-mono">
{getFileName(file.newPath)}
</span>
<span className="flex items-center gap-1 shrink-0">
{fileCommentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{fileCommentCount}
</span>
)}
{file.additions > 0 && (
<span className="text-diff-add-fg text-[10px]">
<Plus className="h-2.5 w-2.5 inline" />
{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="text-diff-remove-fg text-[10px]">
<Minus className="h-2.5 w-2.5 inline" />
{file.deletions}
</span>
)}
</span>
</button>
);
})}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,226 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { render, screen, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach } from "vitest";
// ── Capture props passed to stubs ─────────────────────────────────────────────
// These are module-level so the vi.mock factories (which are hoisted) can close over them.
let diffViewerProps: Record<string, unknown> = {};
let reviewSidebarProps: Record<string, unknown> = {};
vi.mock("./DiffViewer", () => ({
DiffViewer: (props: Record<string, unknown>) => {
diffViewerProps = props;
return <div data-testid="diff-viewer" />;
},
}));
vi.mock("./ReviewSidebar", () => ({
ReviewSidebar: (props: Record<string, unknown>) => {
reviewSidebarProps = props;
return <div data-testid="review-sidebar" />;
},
}));
vi.mock("./ReviewHeader", () => ({
ReviewHeader: (props: Record<string, unknown>) => (
<div data-testid="review-header">
{props.onExpandAll && (
<button
data-testid="expand-all-btn"
onClick={props.onExpandAll as () => void}
>
Expand all
</button>
)}
</div>
),
}));
vi.mock("./InitiativeReview", () => ({
InitiativeReview: () => <div data-testid="initiative-review" />,
}));
vi.mock("./comment-index", () => ({
buildCommentIndex: vi.fn(() => new Map()),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// ── parseUnifiedDiff spy ───────────────────────────────────────────────────────
const mockParseUnifiedDiff = vi.fn((_raw: string) => [
{
oldPath: "a.ts",
newPath: "a.ts",
status: "modified" as const,
additions: 3,
deletions: 1,
hunks: [],
},
]);
vi.mock("./parse-diff", () => ({
get parseUnifiedDiff() {
return mockParseUnifiedDiff;
},
}));
// ── tRPC mock factory ─────────────────────────────────────────────────────────
const noopMutation = () => ({
mutate: vi.fn(),
isPending: false,
});
const noopQuery = (data: unknown = undefined) => ({
data,
isLoading: false,
isError: false,
refetch: vi.fn(),
});
const mockUtils = {
listReviewComments: { invalidate: vi.fn() },
};
// Server format (FileStatEntry): uses `path` not `newPath`
const PHASE_FILES = [
{
path: "a.ts",
status: "modified" as const,
additions: 5,
deletions: 2,
},
];
// trpcMock is a let so tests can override it. The getter in the mock reads the current value.
let trpcMock = buildTrpcMock();
function buildTrpcMock(overrides: Record<string, unknown> = {}) {
return {
getInitiative: { useQuery: vi.fn(() => noopQuery({ status: "in_progress" })) },
listPhases: {
useQuery: vi.fn(() =>
noopQuery([{ id: "phase-1", name: "Phase 1", status: "pending_review" }])
),
},
getInitiativeProjects: { useQuery: vi.fn(() => noopQuery([{ id: "proj-1" }])) },
getPhaseReviewDiff: {
useQuery: vi.fn(() =>
noopQuery({
phaseName: "Phase 1",
sourceBranch: "cw/phase-1",
targetBranch: "main",
files: PHASE_FILES,
totalAdditions: 5,
totalDeletions: 2,
})
),
},
getPhaseReviewCommits: {
useQuery: vi.fn(() =>
noopQuery({ commits: [], sourceBranch: "cw/phase-1", targetBranch: "main" })
),
},
getCommitDiff: {
useQuery: vi.fn(() => noopQuery({ rawDiff: "" })),
},
listPreviews: { useQuery: vi.fn(() => noopQuery([])) },
getPreviewStatus: { useQuery: vi.fn(() => noopQuery(null)) },
listReviewComments: { useQuery: vi.fn(() => noopQuery([])) },
startPreview: { useMutation: vi.fn(() => noopMutation()) },
stopPreview: { useMutation: vi.fn(() => noopMutation()) },
createReviewComment: { useMutation: vi.fn(() => noopMutation()) },
resolveReviewComment: { useMutation: vi.fn(() => noopMutation()) },
unresolveReviewComment: { useMutation: vi.fn(() => noopMutation()) },
replyToReviewComment: { useMutation: vi.fn(() => noopMutation()) },
updateReviewComment: { useMutation: vi.fn(() => noopMutation()) },
approvePhaseReview: { useMutation: vi.fn(() => noopMutation()) },
requestPhaseChanges: { useMutation: vi.fn(() => noopMutation()) },
useUtils: vi.fn(() => mockUtils),
...overrides,
};
}
vi.mock("@/lib/trpc", () => ({
get trpc() {
return trpcMock;
},
}));
// ── Import component after mocks ──────────────────────────────────────────────
import { ReviewTab } from "./ReviewTab";
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ReviewTab", () => {
beforeEach(() => {
diffViewerProps = {};
reviewSidebarProps = {};
mockParseUnifiedDiff.mockClear();
trpcMock = buildTrpcMock();
});
it("1. phase diff loads metadata: DiffViewer receives files array and commitMode=false", () => {
render(<ReviewTab initiativeId="init-1" />);
expect(screen.getByTestId("diff-viewer")).toBeInTheDocument();
const files = diffViewerProps.files as unknown[];
expect(files).toHaveLength(1);
expect(diffViewerProps.commitMode).toBe(false);
});
it("2. no rawDiff parsing in phase mode: parseUnifiedDiff is NOT called", () => {
render(<ReviewTab initiativeId="init-1" />);
expect(mockParseUnifiedDiff).not.toHaveBeenCalled();
});
it("3. commit view parses rawDiff: parseUnifiedDiff called and DiffViewer gets commitMode=true", async () => {
trpcMock = buildTrpcMock({
getCommitDiff: {
useQuery: vi.fn(() =>
noopQuery({ rawDiff: "diff --git a/a.ts b/a.ts\nindex 000..111 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,1 +1,1 @@\n-old\n+new\n" })
),
},
});
render(<ReviewTab initiativeId="init-1" />);
// Select a commit via the sidebar stub's onSelectCommit prop
const { onSelectCommit } = reviewSidebarProps as {
onSelectCommit: (hash: string | null) => void;
};
await act(async () => {
onSelectCommit("abc123");
});
expect(diffViewerProps.commitMode).toBe(true);
expect(mockParseUnifiedDiff).toHaveBeenCalled();
});
it("4. allFiles uses metadata for sidebar: ReviewSidebar receives files from diffQuery.data.files", () => {
render(<ReviewTab initiativeId="init-1" />);
const sidebarFiles = reviewSidebarProps.files as Array<{ newPath: string }>;
expect(sidebarFiles).toHaveLength(1);
expect(sidebarFiles[0].newPath).toBe("a.ts");
});
it("5. expandAll prop passed: clicking Expand all button causes DiffViewer to receive expandAll=true", async () => {
render(<ReviewTab initiativeId="init-1" />);
// Before clicking, expandAll should be false
expect(diffViewerProps.expandAll).toBe(false);
const expandBtn = screen.getByTestId("expand-all-btn");
await act(async () => {
expandBtn.click();
});
expect(diffViewerProps.expandAll).toBe(true);
});
});

View File

@@ -7,7 +7,8 @@ import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar";
import { ReviewHeader } from "./ReviewHeader";
import { InitiativeReview } from "./InitiativeReview";
import type { ReviewStatus, DiffLine } from "./types";
import { buildCommentIndex } from "./comment-index";
import type { ReviewStatus, DiffLine, FileDiff, FileDiffDetail } from "./types";
interface ReviewTabProps {
initiativeId: string;
@@ -17,6 +18,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [status, setStatus] = useState<ReviewStatus>("pending");
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
const [expandAll, setExpandAll] = useState(false);
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const headerRef = useRef<HTMLDivElement>(null);
const [headerHeight, setHeaderHeight] = useState(0);
@@ -73,7 +75,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
// Fetch full branch diff for active phase
// Fetch full branch diff for active phase (metadata only, no rawDiff)
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId && !isInitiativePendingReview },
@@ -95,7 +97,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
// Preview state
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
const existingPreview = previewsQuery.data?.find(
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
(p: { phaseId?: string; initiativeId?: string }) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
@@ -106,12 +108,12 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => {
onSuccess: (data: { id: string; url: string }) => {
setActivePreviewId(data.id);
previewsQuery.refetch();
toast.success(`Preview running at ${data.url}`);
},
onError: (err) => toast.error(`Preview failed: ${err.message}`),
onError: (err: { message: string }) => toast.error(`Preview failed: ${err.message}`),
});
const stopPreview = trpc.stopPreview.useMutation({
@@ -120,7 +122,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
toast.success("Preview stopped");
previewsQuery.refetch();
},
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
onError: (err: { message: string }) => toast.error(`Failed to stop: ${err.message}`),
});
const previewState = firstProjectId && sourceBranch
@@ -156,7 +158,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
{ enabled: !!activePhaseId && !isInitiativePendingReview },
);
const comments = useMemo(() => {
return (commentsQuery.data ?? []).map((c) => ({
return (commentsQuery.data ?? []).map((c: {
id: string;
filePath: string;
lineNumber: number | null;
lineType: string;
body: string;
author: string;
createdAt: string | number;
resolved: boolean;
parentCommentId?: string | null;
}) => ({
id: c.id,
filePath: c.filePath,
lineNumber: c.lineNumber,
@@ -169,11 +181,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
}));
}, [commentsQuery.data]);
const commentsByLine = useMemo(
() => buildCommentIndex(comments),
[comments],
);
const createCommentMutation = trpc.createReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to save comment: ${err.message}`),
onError: (err: { message: string }) => toast.error(`Failed to save comment: ${err.message}`),
});
const resolveCommentMutation = trpc.resolveReviewComment.useMutation({
@@ -192,14 +209,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
onError: (err: { message: string }) => toast.error(`Failed to post reply: ${err.message}`),
});
const editCommentMutation = trpc.updateReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to update comment: ${err.message}`),
onError: (err: { message: string }) => toast.error(`Failed to update comment: ${err.message}`),
});
const approveMutation = trpc.approvePhaseReview.useMutation({
@@ -208,23 +225,48 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
toast.success("Phase approved and merged");
phasesQuery.refetch();
},
onError: (err) => toast.error(err.message),
onError: (err: { message: string }) => toast.error(err.message),
});
// Determine which diff to display
const activeDiffRaw = selectedCommit
? commitDiffQuery.data?.rawDiff
: diffQuery.data?.rawDiff;
// Phase branch diff — metadata only, no parsing
const phaseFiles: FileDiff[] = useMemo(
() => {
const serverFiles = diffQuery.data?.files ?? [];
// Map server FileStatEntry (path) to frontend FileDiff (newPath)
return serverFiles.map((f: {
path: string;
oldPath?: string;
status: FileDiff['status'];
additions: number;
deletions: number;
projectId?: string;
}) => ({
newPath: f.path,
oldPath: f.oldPath ?? f.path,
status: f.status,
additions: f.additions,
deletions: f.deletions,
projectId: f.projectId,
}));
},
[diffQuery.data?.files],
);
const files = useMemo(() => {
if (!activeDiffRaw) return [];
return parseUnifiedDiff(activeDiffRaw);
}, [activeDiffRaw]);
// Commit diff — still raw, parse client-side
const commitFiles: FileDiffDetail[] = useMemo(() => {
if (!commitDiffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(commitDiffQuery.data.rawDiff);
}, [commitDiffQuery.data?.rawDiff]);
const isDiffLoading = selectedCommit
? commitDiffQuery.isLoading
: diffQuery.isLoading;
// All files for sidebar — always from phase metadata
const allFiles = phaseFiles;
const activeFiles: FileDiff[] | FileDiffDetail[] = selectedCommit ? commitFiles : phaseFiles;
const handleAddComment = useCallback(
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
if (!activePhaseId) return;
@@ -267,7 +309,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
toast.success("Changes requested — revision task dispatched");
phasesQuery.refetch();
},
onError: (err) => toast.error(err.message),
onError: (err: { message: string }) => toast.error(err.message),
});
const handleRequestChanges = useCallback(() => {
@@ -297,6 +339,11 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
setSelectedCommit(null);
setStatus("pending");
setViewedFiles(new Set());
setExpandAll(false);
}, []);
const handleExpandAll = useCallback(() => {
setExpandAll(v => !v);
}, []);
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
@@ -306,12 +353,6 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
"Phase";
// All files from the full branch diff (for sidebar file list)
const allFiles = useMemo(() => {
if (!diffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(diffQuery.data.rawDiff);
}, [diffQuery.data?.rawDiff]);
// Initiative-level review takes priority
if (isInitiativePendingReview) {
return (
@@ -357,6 +398,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
preview={previewState}
viewedCount={viewedFiles.size}
totalCount={allFiles.length}
totalAdditions={selectedCommit ? undefined : diffQuery.data?.totalAdditions}
totalDeletions={selectedCommit ? undefined : diffQuery.data?.totalDeletions}
onExpandAll={handleExpandAll}
/>
{/* Main content area — sidebar always rendered to preserve state */}
@@ -376,7 +420,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onFileClick={handleFileClick}
onCommentClick={handleCommentClick}
selectedCommit={selectedCommit}
activeFiles={files}
activeFiles={activeFiles}
commits={commits}
onSelectCommit={setSelectedCommit}
viewedFiles={viewedFiles}
@@ -391,7 +435,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
<Loader2 className="h-4 w-4 animate-spin" />
Loading diff...
</div>
) : files.length === 0 ? (
) : activeFiles.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
{selectedCommit
? "No changes in this commit"
@@ -399,8 +443,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
</div>
) : (
<DiffViewer
files={files}
comments={comments}
files={activeFiles}
phaseId={activePhaseId!}
commitMode={!!selectedCommit}
commentsByLine={commentsByLine}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
@@ -409,6 +455,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
viewedFiles={viewedFiles}
onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef}
expandAll={expandAll}
/>
)}
</div>

View File

@@ -0,0 +1,134 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { vi, describe, it, expect } from "vitest";
import { buildCommentIndex } from "./comment-index";
import type { ReviewComment } from "./types";
// ── Stub CommentThread and CommentForm so LineWithComments renders without deps ──
vi.mock("./CommentThread", () => ({
CommentThread: () => <div data-testid="comment-thread" />,
}));
vi.mock("./CommentForm", () => ({
CommentForm: vi.fn().mockReturnValue(<div data-testid="comment-form" />),
}));
vi.mock("./use-syntax-highlight", () => ({
useHighlightedFile: () => null,
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeComment(overrides: Partial<ReviewComment> & { id: string }): ReviewComment {
return {
id: overrides.id,
filePath: overrides.filePath ?? "src/foo.ts",
lineNumber: overrides.lineNumber !== undefined ? overrides.lineNumber : 1,
lineType: overrides.lineType ?? "added",
body: overrides.body ?? "comment body",
author: overrides.author ?? "alice",
createdAt: overrides.createdAt ?? "2024-01-01T00:00:00Z",
resolved: overrides.resolved ?? false,
parentCommentId: overrides.parentCommentId ?? null,
};
}
// ── buildCommentIndex — pure function tests ───────────────────────────────────
describe("buildCommentIndex", () => {
it("happy path — basic indexing", () => {
const c1 = makeComment({ id: "1", filePath: "src/foo.ts", lineNumber: 10, lineType: "added" });
const c2 = makeComment({ id: "2", filePath: "src/bar.ts", lineNumber: 5, lineType: "context" });
const map = buildCommentIndex([c1, c2]);
expect(map.get("src/foo.ts:10:added")).toEqual([c1]);
expect(map.get("src/bar.ts:5:context")).toEqual([c2]);
expect(map.size).toBe(2);
});
it("same-line accumulation — two comments land in same array", () => {
const c1 = makeComment({ id: "a", filePath: "src/x.ts", lineNumber: 20, lineType: "added" });
const c2 = makeComment({ id: "b", filePath: "src/x.ts", lineNumber: 20, lineType: "added" });
const map = buildCommentIndex([c1, c2]);
expect(map.get("src/x.ts:20:added")).toEqual([c1, c2]);
expect(map.size).toBe(1);
});
it("cross-type isolation — same lineNumber but different lineType produces separate entries", () => {
const added = makeComment({ id: "a", filePath: "src/x.ts", lineNumber: 10, lineType: "added" });
const removed = makeComment({ id: "r", filePath: "src/x.ts", lineNumber: 10, lineType: "removed" });
const map = buildCommentIndex([added, removed]);
expect(map.get("src/x.ts:10:added")).toEqual([added]);
expect(map.get("src/x.ts:10:removed")).toEqual([removed]);
expect(map.size).toBe(2);
});
it("null lineNumber — file-level comment stored under filePath:file", () => {
const fileComment = makeComment({ id: "f", filePath: "src/z.ts", lineNumber: null, lineType: "context" });
const map = buildCommentIndex([fileComment]);
expect(map.get("src/z.ts:file")).toEqual([fileComment]);
});
it("empty input — returns empty map", () => {
expect(buildCommentIndex([])).toEqual(new Map());
});
});
// ── LineWithComments — component tests ───────────────────────────────────────
import { LineWithComments } from "./LineWithComments";
import type { DiffLine } from "./types";
const addedLine: DiffLine = {
type: "added",
content: "const x = 1;",
oldLineNumber: null,
newLineNumber: 5,
};
const noop = () => {};
describe("LineWithComments", () => {
it("renders comment button with title when lineComments is non-empty", () => {
const lineComments = [
makeComment({ id: "c1", filePath: "src/foo.ts", lineNumber: 5, lineType: "added" }),
];
render(
<table>
<tbody>
<LineWithComments
line={addedLine}
lineKey={5}
lineComments={lineComments}
isCommenting={false}
onStartComment={noop}
onCancelComment={noop}
onSubmitComment={noop}
onResolveComment={noop}
onUnresolveComment={noop}
/>
</tbody>
</table>,
);
expect(screen.getByTitle(/1 comment/)).toBeInTheDocument();
});
it("does not render comment thread row when lineComments is empty", () => {
render(
<table>
<tbody>
<LineWithComments
line={addedLine}
lineKey={5}
lineComments={[]}
isCommenting={false}
onStartComment={noop}
onCancelComment={noop}
onSubmitComment={noop}
onResolveComment={noop}
onUnresolveComment={noop}
/>
</tbody>
</table>,
);
expect(document.querySelector("[data-comment-id]")).toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
import type { ReviewComment } from "./types";
/**
* Build a Map keyed by `"${filePath}:${lineNumber}:${lineType}"` for line-level
* comments, or `"${filePath}:file"` for file-level comments (lineNumber === null).
*
* The compound key (filePath + lineNumber + lineType) is required because
* added and removed lines can share the same numeric position in a replacement
* hunk (e.g., old line 10 removed, new line 10 added).
*/
export function buildCommentIndex(
comments: ReviewComment[],
): Map<string, ReviewComment[]> {
const map = new Map<string, ReviewComment[]>();
for (const comment of comments) {
const key =
comment.lineNumber != null
? `${comment.filePath}:${comment.lineNumber}:${comment.lineType}`
: `${comment.filePath}:file`;
const existing = map.get(key);
if (existing) existing.push(comment);
else map.set(key, [comment]);
}
return map;
}

View File

@@ -0,0 +1,39 @@
import type { ThemedToken } from 'shiki';
export interface HighlightRequest {
id: string;
filePath: string;
language: string; // resolved lang name (e.g. "typescript") or "text"
code: string; // full joined content of new-side lines to highlight
lineNumbers: number[]; // new-side line numbers to map tokens back to
}
export interface HighlightResponse {
id: string;
tokens: Array<{ lineNumber: number; tokens: ThemedToken[] }>;
error?: string;
}
self.addEventListener('message', async (event: MessageEvent<HighlightRequest>) => {
const { id, language, code, lineNumbers } = event.data;
try {
const { codeToTokens } = await import('shiki');
const result = await codeToTokens(code, {
lang: language as Parameters<typeof codeToTokens>[1]['lang'],
theme: 'github-dark-default',
});
const tokens: HighlightResponse['tokens'] = result.tokens.map((lineTokens, idx) => ({
lineNumber: lineNumbers[idx] ?? idx,
tokens: lineTokens,
}));
const response: HighlightResponse = { id, tokens };
self.postMessage(response);
} catch (err) {
const response: HighlightResponse = {
id,
tokens: [],
error: err instanceof Error ? err.message : String(err),
};
self.postMessage(response);
}
});

View File

@@ -1,10 +1,10 @@
import type { FileDiff, FileChangeType, DiffHunk, DiffLine } from "./types";
import type { FileDiffDetail, FileDiff, DiffHunk, DiffLine } from "./types";
/**
* Parse a unified diff string into structured FileDiff objects.
* Parse a unified diff string into structured FileDiffDetail objects.
*/
export function parseUnifiedDiff(raw: string): FileDiff[] {
const files: FileDiff[] = [];
export function parseUnifiedDiff(raw: string): FileDiffDetail[] {
const files: FileDiffDetail[] = [];
const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
for (const chunk of fileChunks) {
@@ -90,19 +90,19 @@ export function parseUnifiedDiff(raw: string): FileDiff[] {
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
}
// Derive changeType from header markers and path comparison
let changeType: FileChangeType;
// Derive status from header markers and path comparison
let status: FileDiff['status'];
if (hasOldDevNull) {
changeType = "added";
status = "added";
} else if (hasNewDevNull) {
changeType = "deleted";
status = "deleted";
} else if (oldPath !== newPath) {
changeType = "renamed";
status = "renamed";
} else {
changeType = "modified";
status = "modified";
}
files.push({ oldPath, newPath, hunks, additions, deletions, changeType });
files.push({ oldPath, newPath, hunks, additions, deletions, status });
}
return files;

View File

@@ -0,0 +1,29 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest';
import type { FileDiff, FileDiffDetail } from './types';
describe('FileDiff types', () => {
it('FileDiff accepts binary status', () => {
const f: FileDiff = {
oldPath: 'a.png',
newPath: 'a.png',
status: 'binary',
additions: 0,
deletions: 0,
};
expect(f.status).toBe('binary');
});
it('FileDiffDetail extends FileDiff with hunks', () => {
const d: FileDiffDetail = {
oldPath: 'a.ts',
newPath: 'a.ts',
status: 'modified',
additions: 5,
deletions: 2,
hunks: [],
};
expect(d.hunks).toEqual([]);
expect(d.additions).toBe(5);
});
});

View File

@@ -14,21 +14,26 @@ export interface DiffLine {
newLineNumber: number | null;
}
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
/** Metadata returned by getPhaseReviewDiff — no hunk content */
export interface FileDiff {
oldPath: string;
newPath: string;
hunks: DiffHunk[];
/** 'binary' is new — prior changeType used FileChangeType which had no 'binary' */
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary';
additions: number;
deletions: number;
changeType: FileChangeType;
projectId?: string; // present in multi-project initiatives
}
/** Full diff with parsed hunks — returned by getFileDiff, parsed client-side */
export interface FileDiffDetail extends FileDiff {
hunks: DiffHunk[];
}
export interface ReviewComment {
id: string;
filePath: string;
lineNumber: number; // new-side line number (or old-side for deletions)
lineNumber: number | null; // null = file-level comment
lineType: "added" | "removed" | "context";
body: string;
author: string;

View File

@@ -0,0 +1,131 @@
// @vitest-environment happy-dom
// This file tests the chunked main-thread fallback path when Worker
// construction is blocked (e.g. by CSP). It runs in isolation from the
// worker-path tests so that module-level state (workersInitialized, workers)
// starts clean.
import '@testing-library/jest-dom/vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeAll, afterAll } from 'vitest'
const MOCK_TOKEN_A = { content: 'const', color: '#569cd6', offset: 0 }
const MOCK_TOKEN_B = { content: 'x', color: '#9cdcfe', offset: 0 }
// Mock shiki's createHighlighter for the fallback path
const mockCodeToTokens = vi.fn()
vi.mock('shiki', () => ({
createHighlighter: vi.fn().mockResolvedValue({
codeToTokens: mockCodeToTokens,
}),
}))
// Stub Worker to throw (simulating CSP) BEFORE the hook module is loaded.
// initWorkers() catches the exception and leaves workers = [].
beforeAll(() => {
// Use a class so Vitest doesn't warn about constructing vi.fn() without a class impl
class BlockedWorker {
constructor() {
throw new Error('CSP blocks workers')
}
}
vi.stubGlobal('Worker', BlockedWorker)
mockCodeToTokens.mockReturnValue({
tokens: [[MOCK_TOKEN_A], [MOCK_TOKEN_B]],
})
})
afterAll(() => {
vi.unstubAllGlobals()
})
// Dynamic import ensures this file's module instance is fresh (workersInitialized = false).
// We import inside tests below rather than at the top level.
describe('useHighlightedFile — fallback path (Worker unavailable)', () => {
it('falls back to chunked main-thread highlighting when Worker construction throws', async () => {
const { useHighlightedFile } = await import('./use-syntax-highlight')
const lines = [
{ content: 'const x = 1', newLineNumber: 1, type: 'added' as const },
{ content: 'let y = 2', newLineNumber: 2, type: 'context' as const },
]
const { result } = renderHook(() => useHighlightedFile('app.ts', lines))
// Initially null while chunked highlighting runs
expect(result.current).toBeNull()
// Fallback createHighlighter path eventually resolves tokens
await waitFor(
() => {
expect(result.current).not.toBeNull()
},
{ timeout: 5000 },
)
expect(result.current?.get(1)).toEqual([MOCK_TOKEN_A])
expect(result.current?.get(2)).toEqual([MOCK_TOKEN_B])
})
it('returns a complete token map with no lines missing for ≤200-line input (single-chunk equivalence)', async () => {
const { useHighlightedFile } = await import('./use-syntax-highlight')
// 5 lines — well within the 200-line chunk size, so a single codeToTokens call handles all
const MOCK_TOKENS = [
[{ content: 'line1', color: '#fff', offset: 0 }],
[{ content: 'line2', color: '#fff', offset: 0 }],
[{ content: 'line3', color: '#fff', offset: 0 }],
[{ content: 'line4', color: '#fff', offset: 0 }],
[{ content: 'line5', color: '#fff', offset: 0 }],
]
mockCodeToTokens.mockReturnValueOnce({ tokens: MOCK_TOKENS })
const lines = [1, 2, 3, 4, 5].map((n) => ({
content: `line${n}`,
newLineNumber: n,
type: 'context' as const,
}))
const { result } = renderHook(() => useHighlightedFile('src/bar.ts', lines))
await waitFor(
() => {
expect(result.current).not.toBeNull()
},
{ timeout: 5000 },
)
// All 5 line numbers must be present — no lines missing
expect(result.current!.size).toBe(5)
for (let n = 1; n <= 5; n++) {
expect(result.current!.get(n)).toEqual(MOCK_TOKENS[n - 1])
}
})
it('calls AbortController.abort() when component unmounts during chunked fallback', async () => {
const { useHighlightedFile } = await import('./use-syntax-highlight')
const abortSpy = vi.spyOn(AbortController.prototype, 'abort')
// Delay the mock so the hook is still in-flight when we unmount
mockCodeToTokens.mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ tokens: [[MOCK_TOKEN_A]] }), 500),
),
)
const lines = [{ content: 'const x = 1', newLineNumber: 1, type: 'added' as const }]
const { unmount } = renderHook(() => useHighlightedFile('unmount.ts', lines))
// Unmount while the async chunked highlight is still pending
unmount()
// The cleanup function calls abortController.abort()
expect(abortSpy).toHaveBeenCalled()
abortSpy.mockRestore()
})
})

View File

@@ -0,0 +1,240 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { vi, describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
// ── Worker mock infrastructure ─────────────────────────────────────────────
//
// We stub Worker BEFORE importing use-syntax-highlight so that initWorkers()
// (called from useEffect on first render) picks up our mock.
// Module-level state (workers, pending, workersInitialized) is shared across
// all tests in this file — we control behaviour through the mock instances.
type WorkerHandler = (event: { data: unknown }) => void
class MockWorker {
static instances: MockWorker[] = []
messageHandler: WorkerHandler | null = null
postMessage = vi.fn()
constructor() {
MockWorker.instances.push(this)
}
addEventListener(type: string, handler: WorkerHandler) {
if (type === 'message') this.messageHandler = handler
}
/** Simulate a message arriving from the worker thread */
simulateResponse(data: unknown) {
this.messageHandler?.({ data })
}
}
// Stub Worker before the hook module is loaded.
// initWorkers() is lazy (called inside useEffect), so the stub is in place
// by the time any test renders a hook.
beforeAll(() => {
vi.stubGlobal('Worker', MockWorker)
})
afterAll(() => {
vi.unstubAllGlobals()
})
beforeEach(() => {
// Reset call history between tests; keep instances (pool is created once)
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
})
// Import the hook AFTER the beforeAll stub is registered (hoisted evaluation
// of the module will not call initWorkers() — that happens in useEffect).
import { useHighlightedFile } from './use-syntax-highlight'
// ── Helpers ────────────────────────────────────────────────────────────────
const MOCK_TOKEN_A = { content: 'const', color: '#569cd6', offset: 0 }
const MOCK_TOKEN_B = { content: 'x', color: '#9cdcfe', offset: 0 }
function makeLine(
content: string,
newLineNumber: number,
type: 'added' | 'context' | 'removed' = 'added',
) {
return { content, newLineNumber, type } as const
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('useHighlightedFile — worker path', () => {
// ── Test 1: Correct message format ───────────────────────────────────────
it('posts a message to a worker with filePath, language, code, and lineNumbers', async () => {
const lines = [
makeLine('const x = 1', 1, 'added'),
makeLine('const y = 2', 2, 'context'),
]
renderHook(() => useHighlightedFile('src/index.ts', lines))
// Wait for initWorkers() to fire and postMessage to be called
await waitFor(() => {
const totalCalls = MockWorker.instances.reduce(
(n, w) => n + w.postMessage.mock.calls.length,
0,
)
expect(totalCalls).toBeGreaterThan(0)
})
// Find which worker received the message
const calledWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(calledWorker).toBeDefined()
expect(calledWorker!.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
filePath: 'src/index.ts',
language: 'typescript',
code: 'const x = 1\nconst y = 2',
lineNumbers: [1, 2],
}),
)
})
// ── Test 2: Response builds token map ─────────────────────────────────────
it('returns null initially and a LineTokenMap after worker responds', async () => {
const lines = [makeLine('const x = 1', 10, 'added')]
const { result } = renderHook(() => useHighlightedFile('component.ts', lines))
// Immediately null while worker is pending
expect(result.current).toBeNull()
// Capture the request id from whichever worker received it
let requestId = ''
let respondingWorker: MockWorker | undefined
await waitFor(() => {
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(respondingWorker).toBeDefined()
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
expect(requestId).not.toBe('')
})
// Simulate the worker responding
act(() => {
respondingWorker!.simulateResponse({
id: requestId,
tokens: [{ lineNumber: 10, tokens: [MOCK_TOKEN_A] }],
})
})
await waitFor(() => {
expect(result.current).not.toBeNull()
expect(result.current?.get(10)).toEqual([MOCK_TOKEN_A])
})
})
// ── Test 3: Worker error response → null ──────────────────────────────────
it('returns null when worker responds with an error field', async () => {
const lines = [makeLine('code here', 1, 'added')]
const { result } = renderHook(() => useHighlightedFile('bad.ts', lines))
let requestId = ''
let respondingWorker: MockWorker | undefined
await waitFor(() => {
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(respondingWorker).toBeDefined()
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
})
act(() => {
respondingWorker!.simulateResponse({
id: requestId,
tokens: [],
error: 'Worker crashed',
})
})
// Error → stays null (plain text fallback in the UI)
await new Promise<void>((r) => setTimeout(r, 20))
expect(result.current).toBeNull()
})
// ── Test 4: Unmount before response — no state update ────────────────────
it('silently discards a late worker response after unmount', async () => {
const lines = [makeLine('const z = 3', 5, 'added')]
const { result, unmount } = renderHook(() => useHighlightedFile('late.ts', lines))
let requestId = ''
let respondingWorker: MockWorker | undefined
await waitFor(() => {
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(respondingWorker).toBeDefined()
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
})
// Unmount before the response arrives
unmount()
// Simulate the late response — should be silently dropped
act(() => {
respondingWorker!.simulateResponse({
id: requestId,
tokens: [{ lineNumber: 5, tokens: [MOCK_TOKEN_B] }],
})
})
// result.current is frozen at last rendered value (null) — no update fired
expect(result.current).toBeNull()
})
// ── Test 5: Round-robin — two simultaneous requests go to different workers
it('distributes two simultaneous requests across both pool workers', async () => {
// Ensure the pool has been initialised (first test may have done this)
// and reset call counts for clean measurement.
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
const lines1 = [makeLine('alpha', 1, 'added')]
const lines2 = [makeLine('beta', 1, 'added')]
// Render two hook instances at the same time
renderHook(() => useHighlightedFile('file1.ts', lines1))
renderHook(() => useHighlightedFile('file2.ts', lines2))
await waitFor(() => {
const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
expect(total).toBe(2)
})
// Both pool workers should each have received exactly one request
// (round-robin: even requestCount → workers[0], odd → workers[1])
const counts = MockWorker.instances.map((w) => w.postMessage.mock.calls.length)
// Pool has 2 workers; each should have received 1 of the 2 requests
expect(counts[0]).toBe(1)
expect(counts[1]).toBe(1)
})
// ── Test 6: Unknown language → no request ────────────────────────────────
it('returns null immediately for files with no detectable language', async () => {
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
const lines = [makeLine('raw data', 1, 'added')]
const { result } = renderHook(() => useHighlightedFile('data.xyz', lines))
await new Promise<void>((r) => setTimeout(r, 50))
expect(result.current).toBeNull()
const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
expect(total).toBe(0)
})
})

View File

@@ -1,7 +1,59 @@
import { useState, useEffect, useMemo } from "react";
import type { ThemedToken } from "shiki";
import type { HighlightRequest, HighlightResponse } from "./highlight-worker";
/* ── Lazy singleton highlighter ─────────────────────────── */
/* ── Worker pool (module-level, shared across all hook instances) ─────── */
type PendingResolve = (response: HighlightResponse) => void;
let workers: Worker[] = [];
let requestCount = 0;
const MAX_WORKERS = 2;
const pending = new Map<string, PendingResolve>();
let workersInitialized = false;
function initWorkers(): void {
if (workersInitialized) return;
workersInitialized = true;
try {
workers = Array.from({ length: MAX_WORKERS }, () => {
const w = new Worker(
new URL("./highlight-worker.ts", import.meta.url),
{ type: "module" },
);
w.addEventListener("message", (event: MessageEvent<HighlightResponse>) => {
const resolve = pending.get(event.data.id);
if (resolve) {
pending.delete(event.data.id);
resolve(event.data);
}
});
return w;
});
} catch {
// CSP or browser compat — fall back to chunked main-thread highlighting
workers = [];
}
}
function highlightWithWorker(
id: string,
language: string,
code: string,
lineNumbers: number[],
filePath: string,
): Promise<HighlightResponse> {
return new Promise<HighlightResponse>((resolve) => {
pending.set(id, resolve);
const worker = workers[requestCount % MAX_WORKERS];
requestCount++;
const req: HighlightRequest = { id, filePath, language, code, lineNumbers };
worker.postMessage(req);
});
}
/* ── Lazy singleton highlighter (for main-thread fallback) ───────────── */
let highlighterPromise: Promise<Awaited<
ReturnType<typeof import("shiki")["createHighlighter"]>
@@ -40,10 +92,59 @@ function getHighlighter() {
return highlighterPromise;
}
// Pre-warm on module load (non-blocking)
getHighlighter();
/* ── Chunked main-thread fallback ────────────────────────────────────── */
/* ── Language detection ──────────────────────────────────── */
async function highlightChunked(
code: string,
language: string,
lineNumbers: number[],
signal: AbortSignal,
): Promise<LineTokenMap> {
const CHUNK = 200;
const result: LineTokenMap = new Map();
const lines = code.split("\n");
const highlighter = await getHighlighter();
if (!highlighter) return result;
for (let i = 0; i < lines.length; i += CHUNK) {
if (signal.aborted) break;
const chunkLines = lines.slice(i, i + CHUNK);
const chunkCode = chunkLines.join("\n");
try {
const tokenized = highlighter.codeToTokens(chunkCode, {
lang: language as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
theme: "github-dark-default",
});
tokenized.tokens.forEach((lineTokens: ThemedToken[], idx: number) => {
const lineNum = lineNumbers[i + idx];
if (lineNum !== undefined) result.set(lineNum, lineTokens);
});
} catch {
// Skip unparseable chunk
}
// Yield between chunks to avoid blocking the main thread
await new Promise<void>((r) => {
if (
"scheduler" in globalThis &&
"yield" in (globalThis as Record<string, unknown>).scheduler
) {
(
(globalThis as Record<string, unknown>).scheduler as {
yield: () => Promise<void>;
}
)
.yield()
.then(r);
} else {
setTimeout(r, 0);
}
});
}
return result;
}
/* ── Language detection ──────────────────────────────────────────────── */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
@@ -77,7 +178,7 @@ function detectLang(path: string): string | null {
return EXT_TO_LANG[ext] ?? null;
}
/* ── Types ───────────────────────────────────────────────── */
/* ── Types ───────────────────────────────────────────────────────────── */
export type TokenizedLine = ThemedToken[];
/** Maps newLineNumber → highlighted tokens for that line */
@@ -89,12 +190,23 @@ interface DiffLineInput {
type: "added" | "removed" | "context";
}
/* ── Hook ────────────────────────────────────────────────── */
/* ── Hook ────────────────────────────────────────────────────────────── */
/**
* Highlights the "new-side" content of a file diff.
* Returns null until highlighting is ready (progressive enhancement).
* Only context + added lines are highlighted (removed lines fall back to plain text).
* Highlights the "new-side" content of a file diff, returning a map of
* line number → syntax tokens.
*
* Progressive rendering: returns `null` while highlighting is in progress.
* Callers (HunkRows → LineWithComments) render plain text when `null` and
* patch in highlighted tokens on re-render once the worker or chunked call
* resolves.
*
* Worker path: uses a module-level pool of 2 Web Workers. Round-robin
* assignment. Late responses after unmount are silently discarded.
*
* Fallback path: if Worker construction fails (CSP, browser compat),
* falls back to chunked main-thread highlighting via codeToTokens (200
* lines/chunk) with scheduler.yield()/setTimeout(0) between chunks.
*/
export function useHighlightedFile(
filePath: string,
@@ -129,32 +241,37 @@ export function useHighlightedFile(
return;
}
let cancelled = false;
initWorkers(); // no-op after first call
getHighlighter().then((highlighter) => {
if (cancelled || !highlighter) return;
const id = crypto.randomUUID();
let unmounted = false;
const abortController = new AbortController();
try {
const result = highlighter.codeToTokens(code, {
lang: lang as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
theme: "github-dark-default",
});
if (workers.length > 0) {
highlightWithWorker(id, lang, code, lineNums, filePath).then((response) => {
if (unmounted) return; // ignore late responses after unmount
if (response.error || response.tokens.length === 0) {
setTokenMap(null);
return;
}
const map: LineTokenMap = new Map();
result.tokens.forEach((lineTokens: ThemedToken[], idx: number) => {
if (idx < lineNums.length) {
map.set(lineNums[idx], lineTokens);
}
});
if (!cancelled) setTokenMap(map);
} catch {
// Language not loaded or parse error — no highlighting
}
});
for (const { lineNumber, tokens } of response.tokens) {
map.set(lineNumber, tokens);
}
setTokenMap(map);
});
} else {
highlightChunked(code, lang, lineNums, abortController.signal).then((map) => {
if (unmounted) return;
setTokenMap(map.size > 0 ? map : null);
});
}
return () => {
cancelled = true;
unmounted = true;
abortController.abort();
// Remove pending resolver so a late worker response is silently dropped
pending.delete(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey]);

View File

@@ -9,10 +9,10 @@ import type { ConnectionState } from '@/hooks/useConnectionStatus'
const navItems = [
{ label: 'HQ', to: '/hq', badgeKey: null },
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
{ label: 'Errands', to: '/errands', badgeKey: 'pendingErrands' as const },
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
{ label: 'Settings', to: '/settings', badgeKey: null },
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
{ label: 'Radar', to: '/radar', badgeKey: null },
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
{ label: 'Settings', to: '/settings', badgeKey: null },
] as const
interface AppLayoutProps {
@@ -26,12 +26,9 @@ export function AppLayout({ children, onOpenCommandPalette, connectionState }: A
refetchInterval: 10000,
})
const errandsData = trpc.errand.list.useQuery()
const badgeCounts = {
running: agents.data?.filter((a) => a.status === 'running').length ?? 0,
questions: agents.data?.filter((a) => a.status === 'waiting_for_input').length ?? 0,
pendingErrands: errandsData.data?.filter((e) => e.status === 'pending_review').length ?? 0,
running: agents.data?.filter((a) => a.status === 'running').length ?? 0,
questions: agents.data?.filter((a) => a.status === 'waiting_for_input').length ?? 0,
}
return (
@@ -41,7 +38,7 @@ export function AppLayout({ children, onOpenCommandPalette, connectionState }: A
<div className="flex h-12 items-center justify-between px-5">
{/* Left: Logo + Nav */}
<div className="flex items-center gap-6">
<Link to="/initiatives" className="flex items-center gap-2">
<Link to="/hq" className="flex items-center gap-2">
<img src="/icon-dark-48.png" alt="" className="h-7 w-7 dark:hidden" />
<img src="/icon-light-48.png" alt="" className="hidden h-7 w-7 dark:block" />
<span className="hidden font-display font-bold tracking-tight sm:inline">

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as RadarRouteImport } from './routes/radar'
import { Route as InboxRouteImport } from './routes/inbox'
import { Route as HqRouteImport } from './routes/hq'
import { Route as AgentsRouteImport } from './routes/agents'
@@ -25,6 +26,11 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const RadarRoute = RadarRouteImport.update({
id: '/radar',
path: '/radar',
getParentRoute: () => rootRouteImport,
} as any)
const InboxRoute = InboxRouteImport.update({
id: '/inbox',
path: '/inbox',
@@ -76,6 +82,7 @@ export interface FileRoutesByFullPath {
'/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute
'/radar': typeof RadarRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
@@ -88,6 +95,7 @@ export interface FileRoutesByTo {
'/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute
'/radar': typeof RadarRoute
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute
@@ -100,6 +108,7 @@ export interface FileRoutesById {
'/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute
'/radar': typeof RadarRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
@@ -114,6 +123,7 @@ export interface FileRouteTypes {
| '/agents'
| '/hq'
| '/inbox'
| '/radar'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
@@ -126,6 +136,7 @@ export interface FileRouteTypes {
| '/agents'
| '/hq'
| '/inbox'
| '/radar'
| '/initiatives/$id'
| '/settings/health'
| '/settings/projects'
@@ -137,6 +148,7 @@ export interface FileRouteTypes {
| '/agents'
| '/hq'
| '/inbox'
| '/radar'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
@@ -150,6 +162,7 @@ export interface RootRouteChildren {
AgentsRoute: typeof AgentsRoute
HqRoute: typeof HqRoute
InboxRoute: typeof InboxRoute
RadarRoute: typeof RadarRoute
SettingsRoute: typeof SettingsRouteWithChildren
InitiativesIdRoute: typeof InitiativesIdRoute
InitiativesIndexRoute: typeof InitiativesIndexRoute
@@ -164,6 +177,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/radar': {
id: '/radar'
path: '/radar'
fullPath: '/radar'
preLoaderRoute: typeof RadarRouteImport
parentRoute: typeof rootRouteImport
}
'/inbox': {
id: '/inbox'
path: '/inbox'
@@ -251,6 +271,7 @@ const rootRouteChildren: RootRouteChildren = {
AgentsRoute: AgentsRoute,
HqRoute: HqRoute,
InboxRoute: InboxRoute,
RadarRoute: RadarRoute,
SettingsRoute: SettingsRouteWithChildren,
InitiativesIdRoute: InitiativesIdRoute,
InitiativesIndexRoute: InitiativesIndexRoute,

View File

@@ -29,6 +29,10 @@ vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({
HQNeedsApprovalSection: ({ items }: any) => <div data-testid="needs-approval">{items.length}</div>,
}))
vi.mock('@/components/hq/HQResolvingConflictsSection', () => ({
HQResolvingConflictsSection: ({ items }: any) => <div data-testid="resolving-conflicts">{items.length}</div>,
}))
vi.mock('@/components/hq/HQBlockedSection', () => ({
HQBlockedSection: ({ items }: any) => <div data-testid="blocked">{items.length}</div>,
}))
@@ -45,6 +49,7 @@ const emptyData = {
pendingReviewInitiatives: [],
pendingReviewPhases: [],
planningInitiatives: [],
resolvingConflicts: [],
blockedPhases: [],
}
@@ -109,7 +114,7 @@ describe('HeadquartersPage', () => {
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('renders all four sections when all arrays have items', () => {
it('renders all sections when all arrays have items', () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
@@ -118,7 +123,8 @@ describe('HeadquartersPage', () => {
pendingReviewInitiatives: [{ id: '2' }],
pendingReviewPhases: [{ id: '3' }],
planningInitiatives: [{ id: '4' }],
blockedPhases: [{ id: '5' }],
resolvingConflicts: [{ id: '5' }],
blockedPhases: [{ id: '6' }],
},
})
render(<HeadquartersPage />)
@@ -126,6 +132,7 @@ describe('HeadquartersPage', () => {
expect(screen.getByTestId('waiting')).toBeInTheDocument()
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
expect(screen.getByTestId('blocked')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})

View File

@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
import { HQBlockedSection } from "@/components/hq/HQBlockedSection";
import { HQEmptyState } from "@/components/hq/HQEmptyState";
@@ -74,6 +75,7 @@ export function HeadquartersPage() {
data.pendingReviewInitiatives.length > 0 ||
data.pendingReviewPhases.length > 0 ||
data.planningInitiatives.length > 0 ||
data.resolvingConflicts.length > 0 ||
data.blockedPhases.length > 0;
return (
@@ -107,6 +109,9 @@ export function HeadquartersPage() {
{data.planningInitiatives.length > 0 && (
<HQNeedsApprovalSection items={data.planningInitiatives} />
)}
{data.resolvingConflicts.length > 0 && (
<HQResolvingConflictsSection items={data.resolvingConflicts} />
)}
{data.blockedPhases.length > 0 && (
<HQBlockedSection items={data.blockedPhases} />
)}

View File

@@ -0,0 +1,388 @@
import { useState, useMemo } from 'react'
import { createFileRoute, useNavigate, useSearch, Link } from '@tanstack/react-router'
import { trpc } from '@/lib/trpc'
import { useLiveUpdates } from '@/hooks'
import type { LiveUpdateRule } from '@/hooks'
import { Card, CardContent } from '@/components/ui/card'
import { CompactionEventsDialog } from '@/components/radar/CompactionEventsDialog'
import { SubagentSpawnsDialog } from '@/components/radar/SubagentSpawnsDialog'
import { QuestionsAskedDialog } from '@/components/radar/QuestionsAskedDialog'
import { InterAgentMessagesDialog } from '@/components/radar/InterAgentMessagesDialog'
type TimeRange = '1h' | '6h' | '24h' | '7d' | 'all'
type StatusFilter = 'all' | 'running' | 'completed' | 'crashed'
type ModeFilter = 'all' | 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand'
type SortColumn =
| 'name'
| 'mode'
| 'status'
| 'initiative'
| 'task'
| 'started'
| 'questions'
| 'messages'
| 'subagents'
| 'compactions'
const VALID_TIME_RANGES: TimeRange[] = ['1h', '6h', '24h', '7d', 'all']
const VALID_STATUSES: StatusFilter[] = ['all', 'running', 'completed', 'crashed']
const VALID_MODES: ModeFilter[] = [
'all',
'execute',
'discuss',
'plan',
'detail',
'refine',
'chat',
'errand',
]
export const Route = createFileRoute('/radar')({
component: RadarPage,
validateSearch: (search: Record<string, unknown>) => ({
timeRange: VALID_TIME_RANGES.includes(search.timeRange as TimeRange)
? (search.timeRange as TimeRange)
: '24h',
status: VALID_STATUSES.includes(search.status as StatusFilter)
? (search.status as StatusFilter)
: 'all',
initiativeId: typeof search.initiativeId === 'string' ? search.initiativeId : undefined,
mode: VALID_MODES.includes(search.mode as ModeFilter) ? (search.mode as ModeFilter) : 'all',
}),
})
const RADAR_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
{ prefix: 'agent:waiting', invalidate: ['agent'] },
{ prefix: 'conversation:created', invalidate: ['agent'] },
{ prefix: 'agent:stopped', invalidate: ['agent'] },
{ prefix: 'agent:crashed', invalidate: ['agent'] },
]
export function RadarPage() {
const { timeRange, status, initiativeId, mode } = useSearch({ from: '/radar' }) as {
timeRange: TimeRange
status: StatusFilter
initiativeId: string | undefined
mode: ModeFilter
}
const navigate = useNavigate()
useLiveUpdates(RADAR_LIVE_UPDATE_RULES)
const { data: agents = [], isLoading } = trpc.agent.listForRadar.useQuery({
timeRange,
status: status === 'all' ? undefined : status,
initiativeId: initiativeId ?? undefined,
mode: mode === 'all' ? undefined : mode,
})
const { data: initiatives = [] } = trpc.listInitiatives.useQuery()
type DrilldownType = 'questions' | 'messages' | 'subagents' | 'compactions'
const [drilldown, setDrilldown] = useState<{
type: DrilldownType
agentId: string
agentName: string
} | null>(null)
const [sortState, setSortState] = useState<{ column: SortColumn; direction: 'asc' | 'desc' }>({
column: 'started',
direction: 'desc',
})
function handleSort(column: SortColumn) {
setSortState((prev) =>
prev.column === column
? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { column, direction: 'asc' },
)
}
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => {
let cmp = 0
switch (sortState.column) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'mode':
cmp = a.mode.localeCompare(b.mode)
break
case 'status':
cmp = a.status.localeCompare(b.status)
break
case 'initiative':
cmp = (a.initiativeName ?? '').localeCompare(b.initiativeName ?? '')
break
case 'task':
cmp = (a.taskName ?? '').localeCompare(b.taskName ?? '')
break
case 'started':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'questions':
cmp = a.questionsCount - b.questionsCount
break
case 'messages':
cmp = a.messagesCount - b.messagesCount
break
case 'subagents':
cmp = a.subagentsCount - b.subagentsCount
break
case 'compactions':
cmp = a.compactionsCount - b.compactionsCount
break
}
return sortState.direction === 'asc' ? cmp : -cmp
})
}, [agents, sortState])
const totalQuestions = agents.reduce((sum, a) => sum + a.questionsCount, 0)
const totalMessages = agents.reduce((sum, a) => sum + a.messagesCount, 0)
const totalSubagents = agents.reduce((sum, a) => sum + a.subagentsCount, 0)
const totalCompactions = agents.reduce((sum, a) => sum + a.compactionsCount, 0)
function sortIndicator(column: SortColumn) {
if (sortState.column !== column) return null
return sortState.direction === 'asc' ? ' ▲' : ' ▼'
}
function SortableTh({
column,
label,
className,
}: {
column: SortColumn
label: string
className?: string
}) {
return (
<th
className={`cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground ${className ?? ''}`}
onClick={() => handleSort(column)}
>
{label}
{sortIndicator(column)}
</th>
)
}
const isAgentRunning = drilldown
? agents.find((a) => a.id === drilldown.agentId)?.status === 'running'
: false
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Radar</h1>
{/* Summary stat cards */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalQuestions}</p>
<p className="text-sm text-muted-foreground">Total Questions Asked</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalMessages}</p>
<p className="text-sm text-muted-foreground">Total Inter-Agent Messages</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalSubagents}</p>
<p className="text-sm text-muted-foreground">Total Subagent Spawns</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalCompactions}</p>
<p className="text-sm text-muted-foreground">Total Compaction Events</p>
</CardContent>
</Card>
</div>
{/* Filter bar */}
<div className="flex gap-4 items-center">
<select
value={timeRange}
onChange={(e) => {
const val = e.target.value as TimeRange
navigate({ search: (prev) => ({ ...prev, timeRange: val }) })
}}
>
<option value="1h">Last 1h</option>
<option value="6h">Last 6h</option>
<option value="24h">Last 24h</option>
<option value="7d">Last 7d</option>
<option value="all">All time</option>
</select>
<select
value={status}
onChange={(e) => {
const val = e.target.value as StatusFilter
navigate({ search: (prev) => ({ ...prev, status: val }) })
}}
>
<option value="all">All</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="crashed">Crashed</option>
</select>
<select
value={initiativeId ?? ''}
onChange={(e) => {
const val = e.target.value
navigate({
search: (prev) => ({ ...prev, initiativeId: val === '' ? undefined : val }),
})
}}
>
<option value="">All Initiatives</option>
{initiatives.map((ini: { id: string; name: string }) => (
<option key={ini.id} value={ini.id}>
{ini.name}
</option>
))}
</select>
<select
value={mode}
onChange={(e) => {
const val = e.target.value as ModeFilter
navigate({ search: (prev) => ({ ...prev, mode: val }) })
}}
>
<option value="all">All</option>
<option value="execute">Execute</option>
<option value="discuss">Discuss</option>
<option value="plan">Plan</option>
<option value="detail">Detail</option>
<option value="refine">Refine</option>
<option value="chat">Chat</option>
<option value="errand">Errand</option>
</select>
</div>
{/* Empty state */}
{!isLoading && agents.length === 0 && (
<p className="text-center text-muted-foreground">No agent activity in this time period</p>
)}
{/* Agent activity table */}
{(isLoading || agents.length > 0) && (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border">
<SortableTh column="name" label="Agent Name" />
<SortableTh column="mode" label="Mode" />
<SortableTh column="status" label="Status" />
<SortableTh column="initiative" label="Initiative" />
<SortableTh column="task" label="Task" />
<SortableTh column="started" label="Started" />
<SortableTh column="questions" label="Questions" className="text-right" />
<SortableTh column="messages" label="Messages" className="text-right" />
<SortableTh column="subagents" label="Subagents" className="text-right" />
<SortableTh column="compactions" label="Compactions" className="text-right" />
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
<td colSpan={10} className="px-3 py-2">
<div className="h-4 bg-muted rounded animate-pulse" />
</td>
</tr>
))
: sortedAgents.map((agent) => (
<tr key={agent.id} className="border-b border-border/50 hover:bg-muted/30">
<td className="px-3 py-2">
<Link to="/agents" search={{ selected: agent.id }}>
{agent.name}
</Link>
</td>
<td className="px-3 py-2">{agent.mode}</td>
<td className="px-3 py-2">{agent.status}</td>
<td className="px-3 py-2">{agent.initiativeName ?? '—'}</td>
<td className="px-3 py-2">{agent.taskName ?? '—'}</td>
<td className="px-3 py-2">
{new Date(agent.createdAt).toLocaleString()}
</td>
<td
data-testid={`cell-questions-${agent.id}`}
className={agent.questionsCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
onClick={agent.questionsCount > 0
? () => setDrilldown({ type: 'questions', agentId: agent.id, agentName: agent.name })
: undefined}
>
{agent.questionsCount}
</td>
<td
data-testid={`cell-messages-${agent.id}`}
className={agent.messagesCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
onClick={agent.messagesCount > 0
? () => setDrilldown({ type: 'messages', agentId: agent.id, agentName: agent.name })
: undefined}
>
{agent.messagesCount}
</td>
<td
data-testid={`cell-subagents-${agent.id}`}
className={agent.subagentsCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
onClick={agent.subagentsCount > 0
? () => setDrilldown({ type: 'subagents', agentId: agent.id, agentName: agent.name })
: undefined}
>
{agent.subagentsCount}
</td>
<td
data-testid={`cell-compactions-${agent.id}`}
className={agent.compactionsCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
onClick={agent.compactionsCount > 0
? () => setDrilldown({ type: 'compactions', agentId: agent.id, agentName: agent.name })
: undefined}
>
{agent.compactionsCount}
</td>
</tr>
))}
</tbody>
</table>
)}
<CompactionEventsDialog
open={drilldown?.type === 'compactions'}
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
agentId={drilldown?.agentId ?? ''}
agentName={drilldown?.agentName ?? ''}
isAgentRunning={isAgentRunning}
/>
<SubagentSpawnsDialog
open={drilldown?.type === 'subagents'}
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
agentId={drilldown?.agentId ?? ''}
agentName={drilldown?.agentName ?? ''}
isAgentRunning={isAgentRunning}
/>
<QuestionsAskedDialog
open={drilldown?.type === 'questions'}
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
agentId={drilldown?.agentId ?? ''}
agentName={drilldown?.agentName ?? ''}
isAgentRunning={isAgentRunning}
/>
<InterAgentMessagesDialog
open={drilldown?.type === 'messages'}
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
agentId={drilldown?.agentId ?? ''}
agentName={drilldown?.agentName ?? ''}
isAgentRunning={isAgentRunning}
/>
</div>
)
}

File diff suppressed because one or more lines are too long

View File

@@ -4,13 +4,27 @@ import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [TanStackRouterVite({ autoCodeSplitting: true }), react()],
plugins: [
TanStackRouterVite({
autoCodeSplitting: true,
routeFileIgnorePattern: '__tests__',
}),
react(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
worker: {
// ES module workers are required when the app uses code-splitting (Rollup
// can't bundle IIFE workers alongside dynamic imports).
format: "es",
},
server: {
watch: {
ignored: ['**/routeTree.gen.ts'],
},
proxy: {
"/trpc": {
target: "http://127.0.0.1:3847",